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 index 67f3c9e..ea69858 100644 --- 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 @@ -419,3 +419,24 @@ background: #f8fafc; color: #64748b; } + +/* One message for the whole panel, kept above the fields it concerns. */ +.inputs-panel-alert { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 7px 9px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 12px; +} + +.inputs-panel-alert .mat-icon { + flex: 0 0 auto; + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} 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 27d849e..9fa4af7 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 @@ -71,6 +71,13 @@
No manual inputs required.
} @else { + @if (saveError(); as error) { + + } +
Inputs
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 index 2f289a7..1336470 100644 --- 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 @@ -27,6 +27,7 @@ async function build(inputs: EditableExecutionInput[], options: { pendingKeys?: string[]; saving?: Record; readOnly?: boolean; + saveError?: string | null; } = {}) { await TestBed.configureTestingModule({ imports: [TaskExecutionInputsPanelComponent] }).compileComponents(); @@ -35,6 +36,7 @@ async function build(inputs: EditableExecutionInput[], options: { fixture.componentRef.setInput('pendingKeys', options.pendingKeys ?? []); fixture.componentRef.setInput('savingInputs', options.saving ?? {}); fixture.componentRef.setInput('readOnly', options.readOnly ?? false); + fixture.componentRef.setInput('saveError', options.saveError ?? null); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -260,6 +262,26 @@ describe('TaskExecutionInputsPanelComponent', () => { expect(changed).not.toHaveBeenCalled(); }); + it('shows one message for a save that failed as a whole, not one per field', async () => { + // The globals travel in a single request, so a failure belongs to the batch. The same text + // repeated on three fields read as three separate problems. + const fixture = await build([ + makeInput({ key: 'g:a', inputName: 'a' }), + makeInput({ key: 'g:b', inputName: 'b' }), + makeInput({ key: 'g:c', inputName: 'c' }) + ], { saveError: 'Could not save the global inputs, so none of them were saved.' }); + + const alerts = fixture.nativeElement.querySelectorAll('.inputs-panel-alert'); + expect(alerts.length).toBe(1); + expect(alerts[0].textContent).toContain('none of them were saved'); + }); + + it('shows no message when nothing has failed', async () => { + const fixture = await build([makeInput()]); + + expect(fixture.nativeElement.querySelector('.inputs-panel-alert')).toBeNull(); + }); + it('offers the JSON import only on a multi-value input', async () => { const fixture = await build([ makeInput({ key: 'g:single', inputName: 'positionTitle' }), 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 4544d77..e1311f1 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 @@ -91,6 +91,12 @@ export class TaskExecutionInputsPanelComponent { 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([]); + /** + * One message for a save that failed as a whole. The globals go in a single request, so a failure + * is not attributable to any one of them - repeating it on each field said the same thing N times + * and implied N separate problems. + */ + readonly saveError = input(null); readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>(); 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 3b58e3d..1484aa0 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -400,6 +400,7 @@ [savingErrors]="savingErrors()" [readOnly]="inputsReadOnly()" [pendingKeys]="pendingInputKeys()" + [saveError]="globalSaveError()" (authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)" (authorizationSubmit)="submitAuthorization($event)" (textInputChange)="onTextInputChange($event.input, $event.value)" 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 1f4d75d..36e6637 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -143,6 +143,8 @@ 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())); + /** Set when the single request carrying every edited global fails; cleared on the next edit. */ + readonly globalSaveError = signal(null); readonly pendingAuthorizationValues = signal>({}); readonly savingAuthorizations = signal>({}); @@ -1113,6 +1115,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { onTextInputChange(input: EditableExecutionInput, value: string | string[]) { if (this.inputsReadOnly()) return; + this.globalSaveError.set(null); this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); this.savingErrors.update((current) => { const next = { ...current }; @@ -1283,6 +1286,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { values: Record, executionId: string ): Observable { + this.globalSaveError.set(null); globals.forEach((input) => this.setInputSaving(input.key, true)); return this.taskExecutionsService.prepareGlobalInputs(executionId, values).pipe( @@ -1290,9 +1294,11 @@ export class TaskExecutionViewerComponent implements OnDestroy { this.clearPendingInput(input.key); this.clearInputSaving(input.key); })), - // The batch failed as a batch, so say so on each input in it rather than guessing a culprit. + // One request, so either every global was saved or none was: one message says that, where + // the same text on each field implied as many separate problems as there were inputs. catchError(() => { - globals.forEach((input) => this.setInputError(input.key, 'Failed to update inputs')); + globals.forEach((input) => this.clearInputSaving(input.key)); + this.globalSaveError.set('Could not save the global inputs, so none of them were saved.'); return of(null); }) );