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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 14:17:31 +02:00
parent dcf3af5551
commit 253e8ae483
7 changed files with 80 additions and 38 deletions

View File

@ -71,20 +71,22 @@
<div class="text-xs text-slate-500">No manual inputs required.</div>
} @else {
@if (globalExecutionInputs().length) {
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Flow inputs</div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Inputs</div>
<div class="text-[11px] text-slate-500">
{{ providedGlobalCount() }} of {{ globalExecutionInputs().length }} provided
{{ providedCount() }} of {{ editableInputs().length }} provided
</div>
</div>
@if (globalExecutionInputs().length) {
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Global inputs</div>
@for (executionInput of globalExecutionInputs(); track executionInput.key) {
<ng-container *ngTemplateOutlet="inputEditor; context: { $implicit: executionInput }"></ng-container>
}
}
@if (nodeExecutionInputs().length) {
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Manual inputs</div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Node inputs</div>
@for (executionInput of nodeExecutionInputs(); track executionInput.key) {
<ng-container *ngTemplateOutlet="inputEditor; context: { $implicit: executionInput }"></ng-container>
}

View File

@ -14,13 +14,13 @@ function makeInput(overrides: Partial<EditableExecutionInput> = {}): EditableExe
type: 'TEXT',
multiple: false,
value: '',
provided: false,
...overrides
};
}
async function build(inputs: EditableExecutionInput[], options: {
pendingKeys?: string[];
missing?: string[];
saving?: Record<string, boolean>;
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 () => {

View File

@ -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<boolean>(false);
/** Keys the user has edited but not saved; drives the single Save at the foot of the panel. */
readonly pendingKeys = input<string[]>([]);
/** Names the backend still considers unsatisfied - the ones actually blocking the start. */
readonly missingGlobalInputNames = input<string[]>([]);
readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>();
readonly textInputSubmit = output<EditableExecutionInput>();
@ -53,7 +57,6 @@ export class TaskExecutionInputsPanelComponent {
private readonly authorizationVisibility = new Map<string, boolean>();
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) {

View File

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

View File

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

View File

@ -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)"

View File

@ -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<Record<string, string>>({});
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
@ -699,6 +698,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
readonly editableInputs = computed<EditableExecutionInput[]>(() => {
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))
});
}