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:
parent
dcf3af5551
commit
253e8ae483
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue