From dcf3af555150ff38bce1b4bb03d8add848e41bd9 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 14:13:09 +0200 Subject: [PATCH] Make filling in execution inputs one form, not a stack of cards Each input opened with a centred gradient badge whose largest text read "Flow" - the same word on every global input, so the most visual weight carried the least information - followed by a "Type:" line and a four-row textarea. A one-word positionTitle got the same box as a CV, and three short answers filled the panel. An input is now one line of chrome - name, a small type chip, and a dot saying whether it is still required - over a field that starts small. Removing an item from a list is an icon rather than a full-width "Remove" button, which at this width used to push the field out of the panel. Saving is one action for the panel. Per-input buttons meant a click and a round trip each, with nothing to say how much was still unsaved; a sticky bar now reports the pending count and saves them together. Each one still goes through the same single-input request, so a failure is still reported against its own input. The panel also stops ignoring missingGlobalInputKeys, which the backend has been sending all along: it now shows "2 of 5 provided" and marks exactly the inputs that block the start. The editor is one template instead of four near-identical copies - global and node, each single and multiple. That was not the goal here, but writing the same change four times is how those four drifted apart in the first place. First tests for this component. Co-Authored-By: Claude Opus 5 (1M context) --- .../task-execution-inputs-panel.css | 208 +++++++++++++++ .../task-execution-inputs-panel.html | 245 ++++++++---------- .../task-execution-inputs-panel.spec.ts | 121 +++++++++ .../task-execution-inputs-panel.ts | 40 ++- .../task-execution-viewer.html | 3 + .../task-execution-viewer.ts | 18 ++ 6 files changed, 490 insertions(+), 145 deletions(-) create mode 100644 src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css create mode 100644 src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts 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 new file mode 100644 index 0000000..2f96eea --- /dev/null +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css @@ -0,0 +1,208 @@ +/* + * A form you fill in before starting a run, in a narrow aside. Each input used to open with a + * centred gradient badge repeating the word "Flow", a "Type:" line and a four-row textarea, so + * three short answers filled the panel. One line of chrome per input, and the field grows instead. + */ +.inputs-panel-item { + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-left: 3px solid transparent; + border-radius: 6px; + background: #ffffff; +} + +/* Still required: the backend says this one blocks the start. */ +.inputs-panel-item-missing { + border-left-color: #f59e0b; +} + +.inputs-panel-item-pending { + border-left-color: #2563eb; + background: #f8fafc; +} + +.inputs-panel-item-head { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + margin-bottom: 4px; +} + +.inputs-panel-dot { + flex: 0 0 auto; + width: 7px; + height: 7px; + border-radius: 999px; + background: #22c55e; + cursor: help; +} + +.inputs-panel-dot-missing { + background: #f59e0b; +} + +.inputs-panel-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + color: #0f172a; +} + +.inputs-panel-type { + flex: 0 0 auto; + padding: 0 4px; + border-radius: 3px; + background: #f1f5f9; + color: #64748b; + font-size: 10px; + font-weight: 600; +} + +.inputs-panel-owner { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; + color: #94a3b8; + font-size: 10px; + cursor: help; +} + +/* A plain field, not a Material form-field: those carry ~28px of label and subscript furniture. */ +.inputs-panel-field { + display: block; + width: 100%; + min-height: 34px; + padding: 5px 7px; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; + color: #0f172a; + font: inherit; + font-size: 12.5px; + line-height: 1.4; + resize: vertical; +} + +.inputs-panel-field:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); +} + +.inputs-panel-field[readonly] { + background: #f8fafc; + color: #64748b; +} + +.inputs-panel-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.inputs-panel-row { + display: flex; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.inputs-panel-index { + flex: 0 0 auto; + padding-top: 7px; + width: 12px; + color: #94a3b8; + font-size: 10px; + text-align: right; +} + +/* An icon, not a wide "Remove" button: at this width a text button pushed the field out. */ +.inputs-panel-remove { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-top: 5px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: #94a3b8; + cursor: pointer; +} + +.inputs-panel-remove:hover:not(:disabled) { + background: #fee2e2; + color: #b91c1c; +} + +.inputs-panel-remove:disabled { + color: #e2e8f0; + cursor: default; +} + +.inputs-panel-remove .mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + line-height: 15px; +} + +.inputs-panel-add { + display: flex; + align-items: center; + gap: 2px; + align-self: flex-start; + padding: 2px 6px 2px 2px; + border: none; + background: transparent; + color: #2563eb; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.inputs-panel-add:disabled { + color: #cbd5e1; + cursor: default; +} + +.inputs-panel-add .mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + line-height: 15px; +} + +/* + * One save for the whole panel. Per-input buttons meant a click and a round trip each, and nothing + * ever said how much was still unsaved. + */ +.inputs-panel-savebar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 4px -12px -12px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + background: #ffffff; +} + +.inputs-panel-savebar-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #64748b; + font-size: 11px; +} 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 98b6227..0b98586 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 @@ -70,162 +70,119 @@ @if (!editableInputs().length && !authorizationRequirements().length) {
No manual inputs required.
} @else { + @if (globalExecutionInputs().length) { -
-
Globals
-
Provide the shared flow-level inputs once before execution starts.
+
+
Flow inputs
+
+ {{ providedGlobalCount() }} of {{ globalExecutionInputs().length }} provided +
@for (executionInput of globalExecutionInputs(); track executionInput.key) { -
-
-
{{ executionInput.title }}
-
{{ executionInput.subtitle }}
-
-
Type: {{ inputTypeLabel(executionInput) }}
- - @if (isFileInput(executionInput)) { - - } @else { - @if (isMultipleInput(executionInput)) { -
- @for (textValue of textValues(executionInput); track $index) { -
- - {{ executionInput.subtitle }} {{ $index + 1 }} - - - -
- } - -
- -
-
- } @else { -
- - {{ executionInput.subtitle }} - - -
- -
-
- } - } - - @if (isInputSaving(executionInput.key)) { -
Saving...
- } - @if (inputSavingError(executionInput.key); as errorMessage) { -
{{ errorMessage }}
- } -
+ } } @if (nodeExecutionInputs().length) { -
Manual Inputs
- } +
Manual inputs
@for (executionInput of nodeExecutionInputs(); track executionInput.key) { -
-
-
{{ executionInput.title }}
-
{{ executionInput.subtitle }}
-
-
Type: {{ inputTypeLabel(executionInput) }}
+ + } + } - @if (isFileInput(executionInput)) { - - } @else { - @if (isMultipleInput(executionInput)) { -
- @for (textValue of textValues(executionInput); track $index) { -
- - {{ executionInput.subtitle }} {{ $index + 1 }} - - - - -
- } - - - -
- -
-
+ @if (editableInputs().length && !readOnly()) { +
+ + @if (pendingCount()) { + {{ pendingCount() }} unsaved {{ pendingCount() === 1 ? 'change' : 'changes' }} } @else { -
- - {{ executionInput.subtitle }} - - - -
- -
-
+ All changes saved } - } - - @if (isInputSaving(executionInput.key)) { -
Saving...
- } - @if (inputSavingError(executionInput.key); as errorMessage) { -
{{ errorMessage }}
- } +
+
} }
+ + + +
+ +
+ + + {{ executionInput.subtitle }} + {{ inputTypeLabel(executionInput) }} + @if (executionInput.scope === 'node') { + + {{ executionInput.title }} + + } +
+ + @if (isFileInput(executionInput)) { + + } @else if (isMultipleInput(executionInput)) { +
+ @for (textValue of textValues(executionInput); track $index) { +
+ {{ $index + 1 }} + + +
+ } + +
+ } @else { + + } + + @if (isInputSaving(executionInput.key)) { +
Saving…
+ } + @if (inputSavingError(executionInput.key); as errorMessage) { +
{{ errorMessage }}
+ } +
+
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 new file mode 100644 index 0000000..cb62b08 --- /dev/null +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts @@ -0,0 +1,121 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; + +import { EditableExecutionInput, TaskExecutionInputsPanelComponent } from './task-execution-inputs-panel'; + +function makeInput(overrides: Partial = {}): EditableExecutionInput { + return { + key: 'global:role', + scope: 'global', + nodeId: null, + inputName: 'role', + title: 'Flow', + subtitle: 'role', + type: 'TEXT', + multiple: false, + value: '', + ...overrides + }; +} + +async function build(inputs: EditableExecutionInput[], options: { + pendingKeys?: string[]; + missing?: string[]; + saving?: Record; + readOnly?: boolean; +} = {}) { + await TestBed.configureTestingModule({ imports: [TaskExecutionInputsPanelComponent] }).compileComponents(); + + const fixture = TestBed.createComponent(TaskExecutionInputsPanelComponent); + fixture.componentRef.setInput('editableInputs', inputs); + fixture.componentRef.setInput('pendingKeys', options.pendingKeys ?? []); + fixture.componentRef.setInput('missingGlobalInputNames', options.missing ?? []); + fixture.componentRef.setInput('savingInputs', options.saving ?? {}); + fixture.componentRef.setInput('readOnly', options.readOnly ?? false); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture; +} + +describe('TaskExecutionInputsPanelComponent', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('reports how many flow inputs are still missing', async () => { + const fixture = await build( + [makeInput({ key: 'g:a', inputName: 'a' }), makeInput({ key: 'g:b', inputName: 'b' })], + { missing: ['b'] } + ); + + expect(fixture.componentInstance.providedGlobalCount()).toBe(1); + expect(fixture.nativeElement.textContent).toContain('1 of 2 provided'); + }); + + it('marks only the inputs the backend still considers unsatisfied', async () => { + const provided = makeInput({ key: 'g:a', inputName: 'a' }); + const missing = makeInput({ key: 'g:b', inputName: 'b' }); + const fixture = await build([provided, missing], { missing: ['b'] }); + + expect(fixture.componentInstance.isMissing(provided)).toBe(false); + expect(fixture.componentInstance.isMissing(missing)).toBe(true); + }); + + it('never marks a node input as missing, since only globals gate the start', async () => { + const nodeInput = makeInput({ key: 'n:x', scope: 'node', inputName: 'x', title: 'Reviewer' }); + const fixture = await build([nodeInput], { missing: ['x'] }); + + expect(fixture.componentInstance.isMissing(nodeInput)).toBe(false); + }); + + it('offers a single save for every pending edit', async () => { + const fixture = await build( + [makeInput({ key: 'g:a', inputName: 'a' }), makeInput({ key: 'g:b', inputName: 'b' })], + { pendingKeys: ['g:a', 'g:b'] } + ); + + expect(fixture.componentInstance.pendingCount()).toBe(2); + expect(fixture.componentInstance.canSubmitAll()).toBe(true); + expect(fixture.nativeElement.textContent).toContain('2 unsaved changes'); + + const submitted = vi.fn(); + fixture.componentInstance.submitAllInputs.subscribe(submitted); + fixture.componentInstance.submitAll(); + expect(submitted).toHaveBeenCalledTimes(1); + }); + + it('says everything is saved, and refuses to save, with nothing pending', async () => { + const fixture = await build([makeInput()], {}); + + expect(fixture.componentInstance.canSubmitAll()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('All changes saved'); + + const submitted = vi.fn(); + fixture.componentInstance.submitAllInputs.subscribe(submitted); + fixture.componentInstance.submitAll(); + expect(submitted).not.toHaveBeenCalled(); + }); + + it('does not let a second save start while one is in flight', async () => { + const fixture = await build([makeInput({ key: 'g:a', inputName: 'a' })], + { pendingKeys: ['g:a'], saving: { 'g:a': true } }); + + expect(fixture.componentInstance.anySaving()).toBe(true); + expect(fixture.componentInstance.canSubmitAll()).toBe(false); + }); + + it('hides the save bar when the panel is read-only', async () => { + const fixture = await build([makeInput()], { readOnly: true, pendingKeys: ['global:role'] }); + + expect(fixture.nativeElement.querySelector('.inputs-panel-savebar')).toBeNull(); + expect(fixture.componentInstance.canSubmitAll()).toBe(false); + }); + + it('renders one row per item of a multi-value input, plus a way to add one', async () => { + const fixture = await build([makeInput({ multiple: true, type: 'TEXT', value: ['one', 'two'] })]); + + expect(fixture.nativeElement.querySelectorAll('.inputs-panel-row').length).toBe(2); + expect(fixture.nativeElement.querySelector('.inputs-panel-add')).not.toBeNull(); + // The type label says it is a list, which is what the editor is offering. + expect(fixture.nativeElement.textContent).toContain('TEXT[]'); + }); +}); 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 0dd724e..70c275f 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 @@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TaskExecutionAuthorizationRequirement } from '@models/task-execution'; @@ -20,8 +22,9 @@ export type EditableExecutionInput = { @Component({ selector: 'app-task-execution-inputs-panel', - imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule], + imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule], templateUrl: './task-execution-inputs-panel.html', + styleUrl: './task-execution-inputs-panel.css', changeDetection: ChangeDetectionStrategy.OnPush }) export class TaskExecutionInputsPanelComponent { @@ -33,17 +36,52 @@ export class TaskExecutionInputsPanelComponent { readonly savingInputs = input>({}); readonly savingErrors = input>({}); readonly readOnly = input(false); + /** Keys the user has edited but not saved; drives the single Save at the foot of the panel. */ + readonly pendingKeys = input([]); + /** Names the backend still considers unsatisfied - the ones actually blocking the start. */ + readonly missingGlobalInputNames = input([]); readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>(); readonly textInputSubmit = output(); readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>(); readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>(); readonly authorizationSubmit = output(); + readonly submitAllInputs = output(); readonly globalExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'global')); readonly nodeExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'node')); private readonly authorizationVisibility = new Map(); + private readonly pendingKeySet = computed(() => new Set(this.pendingKeys())); + private readonly missingNameSet = computed(() => new Set(this.missingGlobalInputNames())); + + readonly pendingCount = computed(() => this.editableInputs() + .filter((input) => this.pendingKeySet().has(input.key)).length); + + readonly anySaving = computed(() => Object.values(this.savingInputs()).some(Boolean)); + + readonly canSubmitAll = computed(() => + !this.readOnly() && this.pendingCount() > 0 && !this.anySaving()); + + /** Completion is reported for globals only: those are what gate the start. */ + readonly providedGlobalCount = computed(() => this.globalExecutionInputs() + .filter((input) => !this.isMissing(input)).length); + + isPending(input: EditableExecutionInput): boolean { + return this.pendingKeySet().has(input.key); + } + + isMissing(input: EditableExecutionInput): boolean { + return input.scope === 'global' && this.missingNameSet().has(input.inputName); + } + + submitAll(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (!this.canSubmitAll()) return; + this.submitAllInputs.emit(); + } + isFileInput(input: EditableExecutionInput): boolean { return input.type.includes('FILE') || input.type.includes('BINARY'); } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 8115260..1744061 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -399,10 +399,13 @@ [savingInputs]="savingInputs()" [savingErrors]="savingErrors()" [readOnly]="inputsReadOnly()" + [pendingKeys]="pendingInputKeys()" + [missingGlobalInputNames]="missingGlobalInputNames()" (authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)" (authorizationSubmit)="submitAuthorization($event)" (textInputChange)="onTextInputChange($event.input, $event.value)" (textInputSubmit)="submitTextInput($event)" + (submitAllInputs)="submitAllTextInputs()" (fileInputChange)="onFileInputChange($event.input, $event.files)"> } @else if (activeAsideTab() === 'intermediate') { diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 8e2089b..f1af8bd 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -137,6 +137,12 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly savingInputs = signal>({}); readonly savingErrors = signal>({}); readonly pendingTextInputs = signal>({}); + + /** Edited but not yet sent, so the panel can offer one Save for the lot. */ + readonly pendingInputKeys = computed(() => Object.keys(this.pendingTextInputs())); + + /** The backend's own answer on what still blocks the start, by input name. */ + readonly missingGlobalInputNames = computed(() => this.execution()?.missingGlobalInputKeys ?? []); readonly pendingAuthorizationValues = signal>({}); readonly savingAuthorizations = signal>({}); readonly authorizationErrors = signal>({}); @@ -1232,6 +1238,18 @@ export class TaskExecutionViewerComponent implements OnDestroy { this.authorizationErrors.update((current) => ({ ...current, [key]: message })); } + /** + * Saves every edited input in one go. Each still goes through the same single-input request the + * per-field button used - only the trigger is shared - so a failure is reported per input. + */ + submitAllTextInputs() { + if (this.inputsReadOnly()) return; + const pending = new Set(Object.keys(this.pendingTextInputs())); + this.editableInputs() + .filter((input) => pending.has(input.key)) + .forEach((input) => this.submitTextInput(input)); + } + private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) { if (this.inputsReadOnly() || this.execution()?.id !== executionId) return;