Report a failed global save once, at the top of the panel

The globals go in a single request, so a failure belongs to the batch and not to
any field in it. Repeating the same text on every input said one thing N times
and implied N separate problems.

One message now sits above the fields, and it can say what the per-field version
could not: the request was all-or-nothing, so none of the values were saved. It
clears on the next edit, so a stale notice cannot outlive the state it described.

Node inputs keep their per-input errors: each is its own request, so a failure
there is attributable and partial success is real. File uploads likewise.

489 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 16:11:41 +02:00
parent fee24f174b
commit 96c3679f2b
6 changed files with 65 additions and 2 deletions

View File

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

View File

@ -71,6 +71,13 @@
<div class="text-xs text-slate-500">No manual inputs required.</div>
} @else {
@if (saveError(); as error) {
<div class="inputs-panel-alert" role="alert">
<mat-icon fontIcon="error_outline"></mat-icon>
<span>{{ error }}</span>
</div>
}
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Inputs</div>
<div class="text-[11px] text-slate-500">

View File

@ -27,6 +27,7 @@ async function build(inputs: EditableExecutionInput[], options: {
pendingKeys?: string[];
saving?: Record<string, boolean>;
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' }),

View File

@ -91,6 +91,12 @@ 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[]>([]);
/**
* 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<string | null>(null);
readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>();

View File

@ -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)"

View File

@ -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<string | null>(null);
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
readonly savingAuthorizations = signal<Record<string, boolean>>({});
@ -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<string, string | string[]>,
executionId: string
): Observable<unknown> {
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);
})
);