From 253e8ae48343c131dc2bdc4811b9bb05e034c219 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 14:17:31 +0200 Subject: [PATCH] Count node inputs too, and name the groups after the domain The tally said "0 of 3" while ignoring the manual inputs below it, which are just as required: the backend only reaches READY when every step is ready, so a step missing its manual input blocks the start exactly as an unsatisfied global does. It now counts the whole panel. "Provided" is decided per input and passed down, rather than inferred in the panel from a globals-only list. A global uses the backend's own missingGlobalInputKeys; a node input is judged on its stored value, ignoring unsaved edits - otherwise typing would make an input look satisfied before it was sent. An empty list, or a list of blanks, does not count as supplied. "Flow inputs" and "Manual inputs" are now "Global inputs" and "Node inputs", which is what they are called everywhere else in the codebase and in the API. Co-Authored-By: Claude Opus 5 (1M context) --- .../task-execution-inputs-panel.html | 10 +++-- .../task-execution-inputs-panel.spec.ts | 40 +++++++++++-------- .../task-execution-inputs-panel.ts | 16 ++++---- .../execution-viewer.utils.spec.ts | 30 ++++++++++---- .../execution-viewer.utils.ts | 12 ++++++ .../task-execution-viewer.html | 1 - .../task-execution-viewer.ts | 9 ++++- 7 files changed, 80 insertions(+), 38 deletions(-) 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 0b98586..768dbcb 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 @@ -71,20 +71,22 @@
No manual inputs required.
} @else { - @if (globalExecutionInputs().length) {
-
Flow inputs
+
Inputs
- {{ providedGlobalCount() }} of {{ globalExecutionInputs().length }} provided + {{ providedCount() }} of {{ editableInputs().length }} provided
+ + @if (globalExecutionInputs().length) { +
Global inputs
@for (executionInput of globalExecutionInputs(); track executionInput.key) { } } @if (nodeExecutionInputs().length) { -
Manual inputs
+
Node inputs
@for (executionInput of nodeExecutionInputs(); track executionInput.key) { } 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 cb62b08..5694fa3 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 @@ -14,13 +14,13 @@ function makeInput(overrides: Partial = {}): EditableExe type: 'TEXT', multiple: false, value: '', + provided: false, ...overrides }; } async function build(inputs: EditableExecutionInput[], options: { pendingKeys?: string[]; - missing?: string[]; saving?: Record; readOnly?: boolean; } = {}) { @@ -29,7 +29,6 @@ async function build(inputs: EditableExecutionInput[], options: { 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(); @@ -41,30 +40,37 @@ async function build(inputs: EditableExecutionInput[], options: { 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'] } - ); + it('counts every input, node ones included, since all of them are required', async () => { + // A step without its manual input never reaches READY, so a node input blocks the start just + // as a global one does and must be part of the tally. + const fixture = await build([ + makeInput({ key: 'g:a', inputName: 'a', provided: true }), + makeInput({ key: 'g:b', inputName: 'b' }), + makeInput({ key: 'n:c', scope: 'node', inputName: 'c', title: 'Reviewer' }) + ]); - expect(fixture.componentInstance.providedGlobalCount()).toBe(1); - expect(fixture.nativeElement.textContent).toContain('1 of 2 provided'); + expect(fixture.componentInstance.providedCount()).toBe(1); + expect(fixture.nativeElement.textContent).toContain('1 of 3 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'] }); + it('marks exactly the inputs with nothing stored', async () => { + const provided = makeInput({ key: 'g:a', inputName: 'a', provided: true }); + const missing = makeInput({ key: 'n:b', scope: 'node', inputName: 'b', title: 'Reviewer' }); + const fixture = await build([provided, missing]); 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'] }); + it('names the two groups after the domain: global and node inputs', async () => { + const fixture = await build([ + makeInput({ key: 'g:a', inputName: 'a' }), + makeInput({ key: 'n:b', scope: 'node', inputName: 'b', title: 'Reviewer' }) + ]); - expect(fixture.componentInstance.isMissing(nodeInput)).toBe(false); + const text = fixture.nativeElement.textContent; + expect(text).toContain('Global inputs'); + expect(text).toContain('Node inputs'); }); it('offers a single save for every pending edit', async () => { 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 70c275f..18ba2c7 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 @@ -18,6 +18,11 @@ export type EditableExecutionInput = { type: string; multiple: boolean; value: string | string[]; + /** + * Whether a value is actually stored in the execution - unsaved edits do not count. Node inputs + * gate the start just as globals do: a step without its manual input never reaches READY. + */ + provided: boolean; }; @Component({ @@ -38,8 +43,7 @@ export class TaskExecutionInputsPanelComponent { 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(); @@ -53,7 +57,6 @@ export class TaskExecutionInputsPanelComponent { 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); @@ -63,16 +66,15 @@ export class TaskExecutionInputsPanelComponent { 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); + /** Every manual input is required, node ones included, so the count covers the whole panel. */ + readonly providedCount = computed(() => this.editableInputs().filter((input) => input.provided).length); isPending(input: EditableExecutionInput): boolean { return this.pendingKeySet().has(input.key); } isMissing(input: EditableExecutionInput): boolean { - return input.scope === 'global' && this.missingNameSet().has(input.inputName); + return !input.provided; } submitAll(event?: Event) { diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts index 4843098..b27600b 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -1,11 +1,5 @@ import { TaskExecution, TaskExecutionStep } from '@models/task-execution'; -import { - buildAuthorizationGate, - buildVisibleExecutionLogs, - getExecutionInputValues, - getExecutionOutputValues, - isExecutionStartable -} from './execution-viewer.utils'; +import { buildAuthorizationGate, buildVisibleExecutionLogs, getExecutionInputValues, getExecutionOutputValues, hasStoredValue, isExecutionStartable } from './execution-viewer.utils'; describe('execution viewer runtime values', () => { const documentedStep: TaskExecutionStep = { @@ -172,3 +166,25 @@ describe('authorization gate', () => { expect(isExecutionStartable(running, buildAuthorizationGate(running, readyState))).toBe(false); }); }); + +describe('hasStoredValue', () => { + it('treats an empty or blank value as not supplied', () => { + expect(hasStoredValue(null)).toBe(false); + expect(hasStoredValue(undefined)).toBe(false); + expect(hasStoredValue('')).toBe(false); + expect(hasStoredValue(' ')).toBe(false); + }); + + it('treats an empty list, or a list of blanks, as not supplied', () => { + // The step would still be waiting for it, so the panel must not report it as provided. + expect(hasStoredValue([])).toBe(false); + expect(hasStoredValue(['', ' '])).toBe(false); + }); + + it('accepts any non-blank content', () => { + expect(hasStoredValue('Backend Developer')).toBe(true); + expect(hasStoredValue(['', 'one'])).toBe(true); + expect(hasStoredValue(0)).toBe(true); + expect(hasStoredValue(false)).toBe(true); + }); +}); diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index 068f890..3b94c19 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -535,3 +535,15 @@ export function isExecutionStartable( return true; } + +/** + * Whether a value counts as supplied. An empty string, an empty list, or a list of blanks is not: + * the step would still be waiting for it. + */ +export function hasStoredValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) { + return value.some((item) => String(item ?? '').trim().length > 0); + } + return String(value).trim().length > 0; +} 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 1744061..3b58e3d 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -400,7 +400,6 @@ [savingErrors]="savingErrors()" [readOnly]="inputsReadOnly()" [pendingKeys]="pendingInputKeys()" - [missingGlobalInputNames]="missingGlobalInputNames()" (authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)" (authorizationSubmit)="submitAuthorization($event)" (textInputChange)="onTextInputChange($event.input, $event.value)" 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 f1af8bd..da55f60 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -83,6 +83,7 @@ import { getConnectedInputs, getConnectedOutputs, getExecutionErrors, + hasStoredValue, getExecutionWarnings, AuthorizationGate, VaultAuthorizationEntry, @@ -141,8 +142,6 @@ export class TaskExecutionViewerComponent implements OnDestroy { /** 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>({}); @@ -699,6 +698,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); readonly editableInputs = computed(() => { + const missingGlobalInputNames = new Set(this.execution()?.missingGlobalInputKeys ?? []); const execution = this.execution(); if (!execution) return []; @@ -726,6 +726,9 @@ export class TaskExecutionViewerComponent implements OnDestroy { subtitle: inputName, type: String(descriptor?.kind ?? 'TEXT').toUpperCase(), multiple: Boolean(descriptor?.multiple), + // The backend's own answer, and it reports what is stored - so an unsaved edit does not + // make an input look satisfied. + provided: !missingGlobalInputNames.has(inputName), value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(descriptor?.multiple)) }); } @@ -752,6 +755,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { subtitle: inputName, type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(), multiple: Boolean(input.descriptor?.multiple), + // No per-input flag from the backend here, so judge the stored value, ignoring pending. + provided: hasStoredValue(rawValue), value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple)) }); }