diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css
new file mode 100644
index 0000000..2f96eea
--- /dev/null
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.css
@@ -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;
+}
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 98b6227..0b98586 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
@@ -70,162 +70,119 @@
@if (!editableInputs().length && !authorizationRequirements().length) {
No manual inputs required.
} @else {
+
@if (globalExecutionInputs().length) {
-
-
Globals
-
Provide the shared flow-level inputs once before execution starts.
+
+
Flow inputs
+
+ {{ providedGlobalCount() }} of {{ globalExecutionInputs().length }} provided
+
@for (executionInput of globalExecutionInputs(); track executionInput.key) {
-
-
-
-
{{ executionInput.subtitle }}
-
-
Type: {{ inputTypeLabel(executionInput) }}
-
- @if (isFileInput(executionInput)) {
-
- } @else {
- @if (isMultipleInput(executionInput)) {
-
- @for (textValue of textValues(executionInput); track $index) {
-
-
- {{ executionInput.subtitle }} {{ $index + 1 }}
-
-
-
-
- }
-
-
-
-
-
- } @else {
-
-
- {{ executionInput.subtitle }}
-
-
-
-
-
-
- }
- }
-
- @if (isInputSaving(executionInput.key)) {
-
Saving...
- }
- @if (inputSavingError(executionInput.key); as errorMessage) {
-
{{ errorMessage }}
- }
-
+
}
}
@if (nodeExecutionInputs().length) {
-
Manual Inputs
- }
+
Manual inputs
@for (executionInput of nodeExecutionInputs(); track executionInput.key) {
-
-
-
-
{{ executionInput.subtitle }}
-
-
Type: {{ inputTypeLabel(executionInput) }}
+
+ }
+ }
- @if (isFileInput(executionInput)) {
-
- } @else {
- @if (isMultipleInput(executionInput)) {
-
- @for (textValue of textValues(executionInput); track $index) {
-
-
- {{ executionInput.subtitle }} {{ $index + 1 }}
-
-
-
-
-
- }
-
-
-
-
-
-
-
+ @if (editableInputs().length && !readOnly()) {
+
}
}
+
+
+
+
+
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
new file mode 100644
index 0000000..cb62b08
--- /dev/null
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
@@ -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 {
+ 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;
+ 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[]');
+ });
+});
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 0dd724e..70c275f 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
@@ -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>({});
readonly savingErrors = input>({});
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();
readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>();
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
readonly authorizationSubmit = output();
+ readonly submitAllInputs = output();
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();
+ 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');
}
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 8115260..1744061 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.html
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html
@@ -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)">
} @else if (activeAsideTab() === 'intermediate') {
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 8e2089b..f1af8bd 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -137,6 +137,12 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly savingInputs = signal>({});
readonly savingErrors = signal>({});
readonly pendingTextInputs = signal>({});
+
+ /** 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>({});
@@ -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;