fix: give Failed its own branch in the two single-step forms (RD-06)

Before this fix, a failed submit on the besluit-form or the
change-request-form left the user stuck. Both templates rendered `Failed`
through the same `@else` branch as the editable form. In besluit-form the
fields read from `Editing` only, so they went blank. In change-request-form
the fields still showed the sent value, but `SetField` only applies to
`Editing`, so typing did nothing. In both forms the submit button stayed
enabled, but `Submit` is a no-op outside `Editing`. The only escape was a
page reload.

After this fix, `Failed` gets its own template branch: an error message, a
read-only summary of what was sent (an `app-data-block`, reused from the
existing BRP-address pattern), and a "Opnieuw proberen" button that
dispatches `Retry`. Both machines already handle `Retry` (`Failed ->
Submitting` with the preserved data), so no machine change was needed.

Both components also move to `createStore`'s effect map (RD-05): the
`Submitting` handler replaces the hand-called `runIfSubmitting`, so
`onSubmit` is now a single `dispatch`. `runIfSubmitting`/`runIfIndienen`
now remain only in the three wizards, migrated later by RD-08.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 16:55:20 +02:00
co-authored by Claude Sonnet 5
parent a71887a843
commit 0c6fd37ed8
4 changed files with 265 additions and 43 deletions
@@ -6,6 +6,8 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
@@ -29,10 +31,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
DataBlockComponent,
DataRowComponent,
],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="besluitLabelText">
<div app-data-row [key]="besluitLabelText" [value]="besluitOptieLabel()"></div>
@if (toelichting()) {
<div app-data-row [key]="toelichtingLabelText" [value]="toelichting()"></div>
}
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else {
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
@@ -70,13 +97,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
/>
</app-form-field>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
@@ -86,7 +106,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
})
export class BesluitFormComponent {
private submit = createSubmitBesluit();
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<BesluitState, BesluitMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(this.id(), s.data);
if (r.ok) {
store.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
store.dispatch({ tag: 'SubmitFailed', error: r.error });
}
},
});
id = input.required<string>();
decided = output<void>();
@@ -109,12 +141,33 @@ export class BesluitFormComponent {
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
// Same ids as the form-field labels above, reused for the Failed data-block's row
// keys (the pattern change-request-form already uses for its read-only BRP rows).
protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`;
protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
/** The value shown in the field — the live draft while editing, the parsed value
while submitting/failed (so the user sees what they sent, same idiom as
change-request-form.telefoon()). */
protected besluit = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.besluit;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit;
return '';
});
protected toelichting = computed(() => {
const s = this.state();
if (s.tag === 'Editing') return s.draft.toelichting;
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? '';
return '';
});
protected besluitOptieLabel = computed(
() => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '',
);
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
@@ -122,19 +175,5 @@ export class BesluitFormComponent {
onSubmit() {
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(this.id(), s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.decided.emit();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}
}
@@ -65,6 +65,26 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
>Nieuwe wijziging doorgeven</app-button
>
</div>
} @else if (state().tag === 'Failed') {
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
<app-data-block class="app-section" [ariaLabel]="telefoonLabelText">
<div app-data-row [key]="telefoonLabelText" [value]="telefoon()"></div>
</app-data-block>
<div class="app-section">
<app-button
variant="secondary"
(click)="dispatch({ tag: 'Retry' })"
i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
} @else {
<app-heading [level]="2" i18n="@@changeRequest.heading">Contactgegevens wijzigen</app-heading>
@@ -108,13 +128,6 @@ import { createSubmitChangeRequest } from '@registratie/application/submit-chang
</app-form-field>
</fieldset>
@if (failedError()) {
<app-alert type="error"
><ng-container i18n="@@changeRequest.failed">Het indienen is niet gelukt:</ng-container>
{{ failedError() }}</app-alert
>
}
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
</app-button>
@@ -127,7 +140,15 @@ export class ChangeRequestFormComponent {
// adapter); the UI holds only this bound command. Field initializer = injection
// context, like createStore below.
private submit = createSubmitChangeRequest();
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce);
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
// exempt, so a story mounting straight into `Submitting` does not call the network).
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce, {
Submitting: async (s, store) => {
const r = await this.submit(s.data);
if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
},
});
/** BRP address, shown read-only. Undefined until the profile loads. */
brpAdres = input<Adres | undefined>(undefined);
@@ -148,6 +169,10 @@ export class ChangeRequestFormComponent {
protected readonly postcodeLabel = $localize`:@@address.postcode:Postcode`;
protected readonly woonplaatsLabel = $localize`:@@address.woonplaats:Woonplaats`;
// Same id as the telefoon form-field's label above, reused for the Failed
// data-block's row key.
protected readonly telefoonLabelText = $localize`:@@changeRequest.telefoonLabel:Telefoonnummer`;
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected errors = computed(() => this.editing()?.errors ?? {});
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
@@ -168,15 +193,5 @@ export class ChangeRequestFormComponent {
onSubmit() {
this.dispatch({ tag: 'Submit' });
this.runIfSubmitting();
}
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await this.submit(s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}
}