Import the items of a list input from a pasted JSON array
Filling a TEXT[] input meant clicking Add item and typing into one box after another - painful when the items are five long interview questions that already exist as an array somewhere. A multi-value input now carries an import icon next to its type chip, opening a dialog to paste a JSON array whose elements become the items. The parser is deliberately strict and specific about what it refuses, because the whole point is to save typing: a silent misread would be worse than typing the items by hand. It names the offending element rather than coercing it - an object would otherwise have arrived as "[object Object]" - and refuses an empty array instead of quietly wiping the items. Numbers and booleans are converted, since those are unambiguous as text. The imported items are emitted as an ordinary pending change, so the panel's single Save still governs when they are sent, and an import can be reviewed or abandoned like any other edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d7c92325c4
commit
df8c122c12
|
|
@ -269,3 +269,83 @@
|
|||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Pasting a JSON array beats typing five long questions one box at a time. */
|
||||
.inputs-panel-import {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputs-panel-import:hover:not(:disabled) {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.inputs-panel-import:disabled {
|
||||
color: #e2e8f0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputs-panel-import .mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.inputs-panel-import-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.inputs-panel-import-field:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.inputs-panel-import-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inputs-panel-import-error .mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.inputs-panel-import-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,17 @@
|
|||
</span>
|
||||
<span class="inputs-panel-name">{{ executionInput.subtitle }}</span>
|
||||
<span class="inputs-panel-type">{{ inputTypeLabel(executionInput) }}</span>
|
||||
@if (isMultipleInput(executionInput) && !isFileInput(executionInput)) {
|
||||
<button
|
||||
type="button"
|
||||
class="inputs-panel-import"
|
||||
matTooltip="Import from JSON array"
|
||||
[attr.aria-label]="'Import ' + executionInput.subtitle + ' from a JSON array'"
|
||||
[disabled]="readOnly()"
|
||||
(click)="openImport(executionInput, $event)">
|
||||
<mat-icon fontIcon="data_array"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (executionInput.scope === 'node') {
|
||||
<span class="inputs-panel-owner" [matTooltip]="'Input of step ' + executionInput.title">
|
||||
{{ executionInput.title }}
|
||||
|
|
@ -212,3 +223,38 @@
|
|||
</span>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (importTarget(); as importInput) {
|
||||
<app-modal-shell
|
||||
title="Import from JSON array"
|
||||
[subtitle]="'Each element becomes one item of ' + importInput.subtitle + '.'"
|
||||
ariaLabel="Import items from a JSON array"
|
||||
maxWidth="640px"
|
||||
closeLabel="Cancel"
|
||||
(backdropClick)="closeImport()"
|
||||
(closeClick)="closeImport()">
|
||||
|
||||
<textarea
|
||||
class="inputs-panel-import-field"
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
[ngModel]="importText()"
|
||||
(ngModelChange)="importText.set($event)"
|
||||
placeholder='[
|
||||
"Describe a Java and Spring Boot service you built or maintained.",
|
||||
"Give an example of a REST API you designed or improved."
|
||||
]'></textarea>
|
||||
|
||||
@if (importError(); as error) {
|
||||
<div class="inputs-panel-import-error">
|
||||
<mat-icon fontIcon="error_outline"></mat-icon>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<footer class="inputs-panel-import-footer">
|
||||
<button type="button" mat-stroked-button (click)="closeImport()">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" (click)="applyImport()">Import</button>
|
||||
</footer>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { EditableExecutionInput, TaskExecutionInputsPanelComponent } from './task-execution-inputs-panel';
|
||||
import { EditableExecutionInput, TaskExecutionInputsPanelComponent, parseJsonArrayInput } from './task-execution-inputs-panel';
|
||||
|
||||
function makeInput(overrides: Partial<EditableExecutionInput> = {}): EditableExecutionInput {
|
||||
return {
|
||||
const input: EditableExecutionInput = {
|
||||
key: 'global:role',
|
||||
scope: 'global',
|
||||
nodeId: null,
|
||||
|
|
@ -17,6 +17,10 @@ function makeInput(overrides: Partial<EditableExecutionInput> = {}): EditableExe
|
|||
provided: false,
|
||||
...overrides
|
||||
};
|
||||
// The panel labels an input by its subtitle, so keep the two in step unless a test sets both.
|
||||
return overrides.inputName && !overrides.subtitle
|
||||
? { ...input, subtitle: overrides.inputName }
|
||||
: input;
|
||||
}
|
||||
|
||||
async function build(inputs: EditableExecutionInput[], options: {
|
||||
|
|
@ -174,4 +178,92 @@ describe('TaskExecutionInputsPanelComponent', () => {
|
|||
expect(fixture.nativeElement.querySelector('.inputs-panel-savebar')).not.toBeNull();
|
||||
expect(fixture.componentInstance.canSubmitAll()).toBe(true);
|
||||
});
|
||||
|
||||
it('offers the JSON import only on a multi-value input', async () => {
|
||||
const fixture = await build([
|
||||
makeInput({ key: 'g:single', inputName: 'positionTitle' }),
|
||||
makeInput({ key: 'g:many', inputName: 'interviewQuestions', multiple: true, value: [''] })
|
||||
]);
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.inputs-panel-import');
|
||||
expect(buttons.length).toBe(1);
|
||||
expect(buttons[0].getAttribute('aria-label')).toContain('interviewQuestions');
|
||||
});
|
||||
|
||||
it('imports a pasted array as the input items, through the normal pending change', async () => {
|
||||
const input = makeInput({ key: 'g:many', inputName: 'interviewQuestions', multiple: true, value: [''] });
|
||||
const fixture = await build([input]);
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.textInputChange.subscribe(changed);
|
||||
|
||||
fixture.componentInstance.openImport(input, new Event('click'));
|
||||
fixture.componentInstance.importText.set('["first question", "second question"]');
|
||||
fixture.componentInstance.applyImport();
|
||||
|
||||
// Emitted like any other edit, so the single Save still governs when it is sent.
|
||||
expect(changed).toHaveBeenCalledWith({ input, value: ['first question', 'second question'] });
|
||||
expect(fixture.componentInstance.importTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the dialog open and explains why when the text will not do', async () => {
|
||||
const input = makeInput({ key: 'g:many', inputName: 'q', multiple: true, value: [''] });
|
||||
const fixture = await build([input]);
|
||||
const changed = vi.fn();
|
||||
fixture.componentInstance.textInputChange.subscribe(changed);
|
||||
|
||||
fixture.componentInstance.openImport(input, new Event('click'));
|
||||
fixture.componentInstance.importText.set('not json at all');
|
||||
fixture.componentInstance.applyImport();
|
||||
|
||||
expect(fixture.componentInstance.importTarget()).not.toBeNull();
|
||||
expect(fixture.componentInstance.importError()).toContain('Not valid JSON');
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonArrayInput', () => {
|
||||
it('accepts the array of interview questions it exists for', () => {
|
||||
const pasted = `[
|
||||
"Describe a Java and Spring Boot service you built or maintained. What was your specific contribution?",
|
||||
"Give an example of a REST API you designed or improved. How did you handle errors, validation, and API versioning?",
|
||||
"Describe a performance or reliability problem involving a relational database. What did you do and what was the measurable result?",
|
||||
"Explain how you have used Docker and CI/CD in a production or project environment.",
|
||||
"Describe a situation where you collaborated with product, QA, or other engineers to deliver a backend feature. What was the outcome?"
|
||||
]`;
|
||||
|
||||
const result = parseJsonArrayInput(pasted);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.values).toHaveLength(5);
|
||||
expect(result.values?.[3]).toContain('Docker and CI/CD');
|
||||
});
|
||||
|
||||
it('rejects text that is not JSON, quoting the reason', () => {
|
||||
expect(parseJsonArrayInput('["unterminated').error).toContain('Not valid JSON');
|
||||
});
|
||||
|
||||
it('rejects valid JSON that is not an array', () => {
|
||||
expect(parseJsonArrayInput('{"a": 1}').error).toContain('Expected a JSON array');
|
||||
expect(parseJsonArrayInput('"just a string"').error).toContain('Expected a JSON array');
|
||||
});
|
||||
|
||||
it('rejects an empty array rather than wiping the items', () => {
|
||||
expect(parseJsonArrayInput('[]').error).toContain('empty');
|
||||
});
|
||||
|
||||
it('rejects nothing pasted at all', () => {
|
||||
expect(parseJsonArrayInput(' ').error).toContain('Paste a JSON array');
|
||||
});
|
||||
|
||||
it('names the offending item when one is not a text value', () => {
|
||||
// Silently coercing an object to "[object Object]" would be worse than refusing.
|
||||
expect(parseJsonArrayInput('["ok", {"a": 1}]').error).toBe(
|
||||
'Every item must be a text value; item 2 is an object.');
|
||||
expect(parseJsonArrayInput('["ok", ["nested"]]').error).toContain('item 2 is an array');
|
||||
expect(parseJsonArrayInput('["ok", null]').error).toContain('item 2 is null');
|
||||
});
|
||||
|
||||
it('converts plain numbers and booleans to text', () => {
|
||||
expect(parseJsonArrayInput('[1, true]').values).toEqual(['1', 'true']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,10 +4,58 @@ import { FormsModule } from '@angular/forms';
|
|||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
|
||||
|
||||
export type JsonArrayParseResult =
|
||||
| { values: string[]; error: null }
|
||||
| { values: null; error: string };
|
||||
|
||||
/**
|
||||
* Parses the pasted text into the items of a multi-value input.
|
||||
*
|
||||
* Deliberately strict about what it accepts, and specific about what it rejects: the point of the
|
||||
* dialog is to save typing, so a silent misread would be worse than typing the items by hand.
|
||||
*/
|
||||
export function parseJsonArrayInput(text: string): JsonArrayParseResult {
|
||||
const trimmed = (text ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return { values: null, error: 'Paste a JSON array first.' };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
return { values: null, error: `Not valid JSON: ${(error as Error).message}` };
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { values: null, error: 'Expected a JSON array, for example ["first", "second"].' };
|
||||
}
|
||||
if (!parsed.length) {
|
||||
return { values: null, error: 'The array is empty, so there is nothing to import.' };
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
for (let index = 0; index < parsed.length; index++) {
|
||||
const item = parsed[index];
|
||||
if (item === null || typeof item === 'object') {
|
||||
return {
|
||||
values: null,
|
||||
error: `Every item must be a text value; item ${index + 1} is ${Array.isArray(item)
|
||||
? 'an array'
|
||||
: item === null ? 'null' : 'an object'}.`
|
||||
};
|
||||
}
|
||||
values.push(String(item));
|
||||
}
|
||||
|
||||
return { values, error: null };
|
||||
}
|
||||
|
||||
export type EditableExecutionInput = {
|
||||
key: string;
|
||||
scope: 'global' | 'node';
|
||||
|
|
@ -27,7 +75,7 @@ export type EditableExecutionInput = {
|
|||
|
||||
@Component({
|
||||
selector: 'app-task-execution-inputs-panel',
|
||||
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule],
|
||||
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule, ModalShellComponent],
|
||||
templateUrl: './task-execution-inputs-panel.html',
|
||||
styleUrl: './task-execution-inputs-panel.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
@ -92,6 +140,38 @@ export class TaskExecutionInputsPanelComponent {
|
|||
this.nodesOverride.set(!this.nodesOpen());
|
||||
}
|
||||
|
||||
readonly importTarget = signal<EditableExecutionInput | null>(null);
|
||||
readonly importText = signal('');
|
||||
readonly importError = signal<string | null>(null);
|
||||
|
||||
openImport(input: EditableExecutionInput, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (this.readOnly()) return;
|
||||
this.importTarget.set(input);
|
||||
this.importText.set('');
|
||||
this.importError.set(null);
|
||||
}
|
||||
|
||||
closeImport() {
|
||||
this.importTarget.set(null);
|
||||
}
|
||||
|
||||
applyImport() {
|
||||
const input = this.importTarget();
|
||||
if (!input) return;
|
||||
|
||||
const result = parseJsonArrayInput(this.importText());
|
||||
if (result.error !== null) {
|
||||
this.importError.set(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emitted like any other edit, so the imported items land in the panel's single Save rather
|
||||
// than being written straight through.
|
||||
this.onTextInputChange(input, result.values);
|
||||
this.closeImport();
|
||||
}
|
||||
|
||||
isPending(input: EditableExecutionInput): boolean {
|
||||
return this.pendingKeySet().has(input.key);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue