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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 14:13:09 +02:00
parent 0fe5a0109c
commit dcf3af5551
6 changed files with 490 additions and 145 deletions

View File

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

View File

@ -70,162 +70,119 @@
@if (!editableInputs().length && !authorizationRequirements().length) {
<div class="text-xs text-slate-500">No manual inputs required.</div>
} @else {
@if (globalExecutionInputs().length) {
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Globals</div>
<div class="mt-1 text-[11px] text-slate-500">Provide the shared flow-level inputs once before execution starts.</div>
<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-[11px] text-slate-500">
{{ providedGlobalCount() }} of {{ globalExecutionInputs().length }} provided
</div>
</div>
@for (executionInput of globalExecutionInputs(); track executionInput.key) {
<div class="rounded-md border border-violet-200 bg-violet-50/60 p-2">
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-violet-200 bg-gradient-to-b from-violet-50 to-violet-100 px-3 py-2 text-center text-violet-700">
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-violet-600">{{ executionInput.subtitle }}</div>
</div>
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
@if (isFileInput(executionInput)) {
<input
type="file"
class="block w-full text-sm text-slate-700"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
} @else {
@if (isMultipleInput(executionInput)) {
<div class="space-y-3">
@for (textValue of textValues(executionInput); track $index) {
<div class="flex items-start gap-2">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
<textarea matInput rows="3" [readonly]="readOnly()" [ngModel]="textValue" (ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
</mat-form-field>
<button type="button" mat-stroked-button class="shrink-0" [disabled]="readOnly() || textValues(executionInput).length <= 1" (click)="removeTextItem(executionInput, $index)">Remove</button>
</div>
}
<button type="button" mat-stroked-button [disabled]="readOnly()" (click)="addTextItem(executionInput)">Add item</button>
<div class="flex justify-end">
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
</div>
</div>
} @else {
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>{{ executionInput.subtitle }}</mat-label>
<textarea matInput rows="4" [readonly]="readOnly()" [ngModel]="executionInput.value" (ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
</mat-form-field>
<div class="flex justify-end">
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
</div>
</div>
}
}
@if (isInputSaving(executionInput.key)) {
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
}
@if (inputSavingError(executionInput.key); as errorMessage) {
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
}
</div>
<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-xs font-semibold uppercase tracking-wide text-slate-600">Manual inputs</div>
@for (executionInput of nodeExecutionInputs(); track executionInput.key) {
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-blue-200 bg-gradient-to-b from-blue-50 to-blue-100 px-3 py-2 text-center text-blue-700">
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-blue-600">{{ executionInput.subtitle }}</div>
</div>
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
<ng-container *ngTemplateOutlet="inputEditor; context: { $implicit: executionInput }"></ng-container>
}
}
@if (isFileInput(executionInput)) {
<input
type="file"
class="block w-full text-sm text-slate-700"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
} @else {
@if (isMultipleInput(executionInput)) {
<div class="space-y-3">
@for (textValue of textValues(executionInput); track $index) {
<div class="flex items-start gap-2">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
<textarea
matInput
rows="3"
[readonly]="readOnly()"
[ngModel]="textValue"
(ngModelChange)="updateTextItem(executionInput, $index, $event)">
</textarea>
</mat-form-field>
<button
type="button"
mat-stroked-button
class="shrink-0"
[disabled]="readOnly() || textValues(executionInput).length <= 1"
(click)="removeTextItem(executionInput, $index)">
Remove
</button>
</div>
}
<button
type="button"
mat-stroked-button
[disabled]="readOnly()"
(click)="addTextItem(executionInput)">
Add item
</button>
<div class="flex justify-end">
<button
type="button"
mat-flat-button
[disabled]="!canSubmitTextInput(executionInput)"
(click)="submitTextInput(executionInput, $event)">
Save
</button>
</div>
</div>
@if (editableInputs().length && !readOnly()) {
<div class="inputs-panel-savebar">
<span class="inputs-panel-savebar-status">
@if (pendingCount()) {
{{ pendingCount() }} unsaved {{ pendingCount() === 1 ? 'change' : 'changes' }}
} @else {
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>{{ executionInput.subtitle }}</mat-label>
<textarea
matInput
rows="4"
[readonly]="readOnly()"
[ngModel]="executionInput.value"
(ngModelChange)="onTextInputChange(executionInput, $event)">
</textarea>
</mat-form-field>
<div class="flex justify-end">
<button
type="button"
mat-flat-button
[disabled]="!canSubmitTextInput(executionInput)"
(click)="submitTextInput(executionInput, $event)">
Save
</button>
</div>
</div>
All changes saved
}
}
@if (isInputSaving(executionInput.key)) {
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
}
@if (inputSavingError(executionInput.key); as errorMessage) {
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
}
</span>
<button type="button" mat-flat-button [disabled]="!canSubmitAll()" (click)="submitAll($event)">
{{ anySaving() ? 'Saving…' : 'Save' }}
</button>
</div>
}
}
</div>
<!--
One editor for both scopes. It used to be four near-identical copies - global and node, each
single and multiple - which is why they drifted apart.
-->
<ng-template #inputEditor let-executionInput>
<div
class="inputs-panel-item"
[class.inputs-panel-item-missing]="isMissing(executionInput)"
[class.inputs-panel-item-pending]="isPending(executionInput)">
<div class="inputs-panel-item-head">
<span
class="inputs-panel-dot"
[class.inputs-panel-dot-missing]="isMissing(executionInput)"
[matTooltip]="isMissing(executionInput) ? 'Still required before this execution can start' : 'Provided'">
</span>
<span class="inputs-panel-name">{{ executionInput.subtitle }}</span>
<span class="inputs-panel-type">{{ inputTypeLabel(executionInput) }}</span>
@if (executionInput.scope === 'node') {
<span class="inputs-panel-owner" [matTooltip]="'Input of step ' + executionInput.title">
{{ executionInput.title }}
</span>
}
</div>
@if (isFileInput(executionInput)) {
<input
type="file"
class="block w-full text-sm text-slate-700"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
} @else if (isMultipleInput(executionInput)) {
<div class="inputs-panel-items">
@for (textValue of textValues(executionInput); track $index) {
<div class="inputs-panel-row">
<span class="inputs-panel-index">{{ $index + 1 }}</span>
<textarea
class="inputs-panel-field"
rows="1"
[readonly]="readOnly()"
[ngModel]="textValue"
(ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
<button
type="button"
class="inputs-panel-remove"
matTooltip="Remove item"
[disabled]="readOnly() || textValues(executionInput).length <= 1"
(click)="removeTextItem(executionInput, $index)">
<mat-icon fontIcon="close"></mat-icon>
</button>
</div>
}
<button
type="button"
class="inputs-panel-add"
[disabled]="readOnly()"
(click)="addTextItem(executionInput)">
<mat-icon fontIcon="add"></mat-icon>
Add item
</button>
</div>
} @else {
<textarea
class="inputs-panel-field"
rows="2"
[readonly]="readOnly()"
[ngModel]="executionInput.value"
(ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
}
@if (isInputSaving(executionInput.key)) {
<div class="mt-1 text-[11px] text-blue-600">Saving…</div>
}
@if (inputSavingError(executionInput.key); as errorMessage) {
<div class="mt-1 text-[11px] text-red-600">{{ errorMessage }}</div>
}
</div>
</ng-template>

View File

@ -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> = {}): 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<string, boolean>;
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[]');
});
});

View File

@ -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<Record<string, boolean>>({});
readonly savingErrors = input<Record<string, string>>({});
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>();
readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>();
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
readonly authorizationSubmit = output<TaskExecutionAuthorizationRequirement>();
readonly submitAllInputs = output<void>();
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<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);
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');
}

View File

@ -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)">
</app-task-execution-inputs-panel>
} @else if (activeAsideTab() === 'intermediate') {

View File

@ -137,6 +137,12 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly savingInputs = signal<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string | string[]>>({});
/** 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>>({});
@ -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;