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)) }); }