From 9d3414d3cb8219706e7e6aa5c81c0a6cdbe6ff25 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 14:35:32 +0200 Subject: [PATCH] Fold a list input away, and edit a long value in a box that fits it The import action moves onto the name line: it was a flex container in a baseline-aligned row, so it floated above the name and the type chip. It also loses the data_array glyph for a plain upload arrow, since the dialog it opens explains itself. A list input now folds, on the same rule the two groups follow: unfolded while it still needs attention, folded once it is satisfied. Five long answers filled the whole aside otherwise. Folded, the row says how many items it is hiding, so nothing disappears silently. Long values were being typed through a slit ~320px wide. Every text value now offers a larger box - on the head for a single value, per row for one item of a list. Both write back through the ordinary pending change, so the panel's single Save still decides when anything is sent, and the box is read-only when the panel is. Both dialogs now resolve the input by key when applying: editableInputs is rebuilt on every poll, so the object captured when the dialog opened could be a stale value to rebase an edit onto. Co-Authored-By: Claude Opus 5 (1M context) --- .../task-execution-inputs-panel.css | 84 +++++++++++++++++-- .../task-execution-inputs-panel.html | 74 +++++++++++++++- .../task-execution-inputs-panel.spec.ts | 81 ++++++++++++++++++ .../task-execution-inputs-panel.ts | 80 +++++++++++++++++- 4 files changed, 307 insertions(+), 12 deletions(-) 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); }