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.title }}
-
{{ 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.title }}
-
{{ 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()) { +
+ + @if (pendingCount()) { + {{ pendingCount() }} unsaved {{ pendingCount() === 1 ? 'change' : 'changes' }} } @else { -
- - {{ executionInput.subtitle }} - - - -
- -
-
+ All changes saved } - } - - @if (isInputSaving(executionInput.key)) { -
Saving...
- } - @if (inputSavingError(executionInput.key); as errorMessage) { -
{{ errorMessage }}
- } +
+
} }
+ + + +
+ +
+ + + {{ executionInput.subtitle }} + {{ inputTypeLabel(executionInput) }} + @if (executionInput.scope === 'node') { + + {{ executionInput.title }} + + } +
+ + @if (isFileInput(executionInput)) { + + } @else if (isMultipleInput(executionInput)) { +
+ @for (textValue of textValues(executionInput); track $index) { +
+ {{ $index + 1 }} + + +
+ } + +
+ } @else { + + } + + @if (isInputSaving(executionInput.key)) { +
Saving…
+ } + @if (inputSavingError(executionInput.key); as errorMessage) { +
{{ errorMessage }}
+ } +
+
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;