+ }
+
+
+
+}
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
index bb7cb03..397d351 100644
--- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
@@ -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 {
- return {
+ const input: EditableExecutionInput = {
key: 'global:role',
scope: 'global',
nodeId: null,
@@ -17,6 +17,10 @@ function makeInput(overrides: Partial = {}): 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']);
+ });
});
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
index 62cb719..d6143c0 100644
--- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
@@ -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(null);
+ readonly importText = signal('');
+ readonly importError = signal(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);
}