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 2c14234..67f3c9e 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 @@ -86,12 +86,50 @@ .inputs-panel-item-head { display: flex; - align-items: baseline; + align-items: center; gap: 6px; min-width: 0; margin-bottom: 4px; } +.inputs-panel-fold { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-left: -4px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #64748b; + cursor: pointer; +} + +.inputs-panel-fold:hover { + background: #f1f5f9; + color: #0f172a; +} + +.inputs-panel-fold .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + +.inputs-panel-count { + flex: 0 0 auto; + padding: 0 5px; + border-radius: 999px; + background: #eef2ff; + color: #4338ca; + font-size: 10px; + font-weight: 600; +} + .inputs-panel-dot { flex: 0 0 auto; width: 7px; @@ -192,7 +230,6 @@ justify-content: center; width: 24px; height: 24px; - margin-top: 5px; padding: 0; border: none; border-radius: 4px; @@ -270,8 +307,11 @@ font-size: 11px; } -/* Pasting a JSON array beats typing five long questions one box at a time. */ -.inputs-panel-import { +/* + * The two secondary actions on a value: paste a whole JSON array instead of typing five long + * questions one box at a time, and open the value in a box wide enough to read it. + */ +.inputs-panel-icon { display: flex; flex: 0 0 auto; align-items: center; @@ -286,23 +326,28 @@ cursor: pointer; } -.inputs-panel-import:hover:not(:disabled) { +.inputs-panel-icon:hover:not(:disabled) { background: #e0e7ff; color: #4338ca; } -.inputs-panel-import:disabled { +.inputs-panel-icon:disabled { color: #e2e8f0; cursor: default; } -.inputs-panel-import .mat-icon { +.inputs-panel-icon .mat-icon { font-size: 15px; width: 15px; height: 15px; line-height: 15px; } +/* On an item row the actions sit beside a textarea, so they line up with its first line. */ +.inputs-panel-row-action { + margin-top: 5px; +} + .inputs-panel-import-field { display: block; width: 100%; @@ -349,3 +394,28 @@ gap: 8px; padding: 0.75rem 1.25rem 1.25rem; } + +.inputs-panel-editor-field { + display: block; + width: 100%; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + color: #0f172a; + font: inherit; + font-size: 13.5px; + line-height: 1.55; + resize: vertical; +} + +.inputs-panel-editor-field:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); +} + +.inputs-panel-editor-field[readonly] { + background: #f8fafc; + color: #64748b; +} 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 1c9afb8..27d849e 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 @@ -132,6 +132,18 @@ [class.inputs-panel-item-pending]="isPending(executionInput)">
+ @if (isListInput(executionInput)) { + + } + {{ executionInput.subtitle }} {{ inputTypeLabel(executionInput) }} - @if (isMultipleInput(executionInput) && !isFileInput(executionInput)) { + + + @if (isListInput(executionInput) && !itemsOpen(executionInput)) { + {{ itemCountLabel(executionInput) }} + } + + @if (isListInput(executionInput)) { + } @else if (!isFileInput(executionInput)) { + } + @if (executionInput.scope === 'node') { {{ executionInput.title }} @@ -165,6 +193,7 @@ [disabled]="readOnly()" (change)="onFileInputChange(executionInput, $event)" /> } @else if (isMultipleInput(executionInput)) { + @if (itemsOpen(executionInput)) {
@for (textValue of textValues(executionInput); track $index) {
@@ -177,7 +206,15 @@ (ngModelChange)="updateTextItem(executionInput, $index, $event)"> +
+ } } @else { + +
+ + +
+ +} 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 397d351..2f289a7 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 @@ -179,6 +179,87 @@ describe('TaskExecutionInputsPanelComponent', () => { expect(fixture.componentInstance.canSubmitAll()).toBe(true); }); + it('unfolds a list that still needs attention and folds a satisfied one', async () => { + // Five long answers otherwise fill the whole aside, so a finished list gets out of the way. + const missing = makeInput({ key: 'g:many', inputName: 'questions', multiple: true, value: ['', ''] }); + const done = makeInput({ + key: 'g:done', inputName: 'answers', multiple: true, value: ['a', 'b'], provided: true + }); + const fixture = await build([missing, done]); + + expect(fixture.componentInstance.itemsOpen(missing)).toBe(true); + expect(fixture.componentInstance.itemsOpen(done)).toBe(false); + // Only the unfolded one renders its rows. + expect(fixture.nativeElement.querySelectorAll('.inputs-panel-row').length).toBe(2); + // Folded, it still says how much it is hiding. + expect(fixture.nativeElement.textContent).toContain('2 items'); + }); + + it('lets the user fold either list, in both directions', async () => { + const missing = makeInput({ key: 'g:many', inputName: 'questions', multiple: true, value: [''] }); + const fixture = await build([missing]); + + fixture.componentInstance.toggleItems(missing); + fixture.detectChanges(); + expect(fixture.componentInstance.itemsOpen(missing)).toBe(false); + expect(fixture.nativeElement.querySelectorAll('.inputs-panel-row').length).toBe(0); + expect(fixture.nativeElement.textContent).toContain('1 item'); + + fixture.componentInstance.toggleItems(missing); + fixture.detectChanges(); + expect(fixture.componentInstance.itemsOpen(missing)).toBe(true); + }); + + it('offers a larger box for a single value, and edits it through the pending change', async () => { + const input = makeInput({ key: 'g:brief', inputName: 'jobRequirements', value: 'short' }); + const fixture = await build([input]); + const changed = vi.fn(); + fixture.componentInstance.textInputChange.subscribe(changed); + + const enlarge = fixture.nativeElement.querySelector('.inputs-panel-icon:not(.inputs-panel-import)'); + expect(enlarge).not.toBeNull(); + + fixture.componentInstance.openEditor(input, null, new Event('click')); + // It opens on the value that is there, rather than on an empty box. + expect(fixture.componentInstance.editorText()).toBe('short'); + + fixture.componentInstance.editorText.set('a much longer requirement'); + fixture.componentInstance.applyEditor(); + + expect(changed).toHaveBeenCalledWith({ input, value: 'a much longer requirement' }); + expect(fixture.componentInstance.editorTarget()).toBeNull(); + }); + + it('edits one item of a list in the larger box, leaving its siblings alone', async () => { + const input = makeInput({ + key: 'g:many', inputName: 'questions', multiple: true, value: ['first', 'second'] + }); + const fixture = await build([input]); + const changed = vi.fn(); + fixture.componentInstance.textInputChange.subscribe(changed); + + fixture.componentInstance.openEditor(input, 1, new Event('click')); + expect(fixture.componentInstance.editorText()).toBe('second'); + + fixture.componentInstance.editorText.set('second, at length'); + fixture.componentInstance.applyEditor(); + + expect(changed).toHaveBeenCalledWith({ input, value: ['first', 'second, at length'] }); + }); + + it('does not write back from the larger box while the panel is read-only', async () => { + const input = makeInput({ key: 'g:brief', inputName: 'jobRequirements', value: 'short' }); + const fixture = await build([input], { readOnly: true }); + const changed = vi.fn(); + fixture.componentInstance.textInputChange.subscribe(changed); + + fixture.componentInstance.openEditor(input, null, new Event('click')); + fixture.componentInstance.editorText.set('edited anyway'); + fixture.componentInstance.applyEditor(); + + expect(changed).not.toHaveBeenCalled(); + }); + it('offers the JSON import only on a multi-value input', async () => { const fixture = await build([ makeInput({ key: 'g:single', inputName: 'positionTitle' }), 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 d6143c0..4544d77 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 @@ -140,6 +140,76 @@ export class TaskExecutionInputsPanelComponent { this.nodesOverride.set(!this.nodesOpen()); } + /** + * A list input is folded once it is satisfied and unfolded while it still needs attention - the + * same rule the two groups follow. Five long answers otherwise fill the whole aside on their own. + */ + private readonly itemsOverrides = signal>({}); + + itemsOpen(input: EditableExecutionInput): boolean { + return this.itemsOverrides()[input.key] ?? !input.provided; + } + + toggleItems(input: EditableExecutionInput, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.itemsOverrides.update((current) => ({ ...current, [input.key]: !this.itemsOpen(input) })); + } + + itemCountLabel(input: EditableExecutionInput): string { + const count = this.textValues(input).length; + return count === 1 ? '1 item' : `${count} items`; + } + + /** A list of texts: the file inputs are multiple too, but the browser picker handles those. */ + isListInput(input: EditableExecutionInput): boolean { + return this.isMultipleInput(input) && !this.isFileInput(input); + } + + /** + * The value being edited in the large box: `index` names one item of a list input, null the + * whole single-valued input. + */ + readonly editorTarget = signal<{ input: EditableExecutionInput; index: number | null } | null>(null); + readonly editorText = signal(''); + + readonly editorSubtitle = computed(() => { + const target = this.editorTarget(); + if (!target) return null; + return target.index === null + ? `Value of ${target.input.subtitle}.` + : `Item ${target.index + 1} of ${target.input.subtitle}.`; + }); + + openEditor(input: EditableExecutionInput, index: number | null, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + const current = index === null + ? (Array.isArray(input.value) ? input.value.join('\n') : input.value ?? '') + : this.textValues(input)[index] ?? ''; + this.editorTarget.set({ input, index }); + this.editorText.set(current); + } + + closeEditor() { + this.editorTarget.set(null); + } + + applyEditor() { + const target = this.editorTarget(); + if (!target || this.readOnly()) return; + + // Routed through the ordinary edit path, so the box is only a bigger way to type: the panel's + // single Save still decides when the value is sent. + const input = this.liveInput(target.input); + if (target.index === null) { + this.onTextInputChange(input, this.editorText()); + } else { + this.updateTextItem(input, target.index, this.editorText()); + } + this.closeEditor(); + } + readonly importTarget = signal(null); readonly importText = signal(''); readonly importError = signal(null); @@ -168,10 +238,18 @@ export class TaskExecutionInputsPanelComponent { // 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.onTextInputChange(this.liveInput(input), result.values); this.closeImport(); } + /** + * The current copy of an input the dialogs were opened on. `editableInputs` is rebuilt on every + * poll, so the captured object can be a stale snapshot to rebase an edit onto. + */ + private liveInput(input: EditableExecutionInput): EditableExecutionInput { + return this.editableInputs().find((candidate) => candidate.key === input.key) ?? input; + } + isPending(input: EditableExecutionInput): boolean { return this.pendingKeySet().has(input.key); }