diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css index 2adffad..2c14234 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css @@ -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; +} diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index c130472..1c9afb8 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -139,6 +139,17 @@ {{ executionInput.subtitle }} {{ inputTypeLabel(executionInput) }} + @if (isMultipleInput(executionInput) && !isFileInput(executionInput)) { + + } @if (executionInput.scope === 'node') { {{ executionInput.title }} @@ -212,3 +223,38 @@ } + +@if (importTarget(); as importInput) { + + + + + @if (importError(); as error) { +
+ + {{ error }} +
+ } + + +
+} 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); }