Step 2 (code quality): dedup + stop FE recomputing a server rule
- H1: tasksFromProfile takes the server's eligibleForHerregistratie decision
instead of recomputing isHerregistratieEligible — the FE renders the rule,
doesn't own it (ADR-0001). Policy reference impl kept for tests.
- M1: one shared runSubmit(fn, fallback) wrapper; the 4 submit-* commands keep
only their payload mapping. +spec.
- M2: whenTag() kernel helper removes 10 repeated `as Extract<U,{tag}>` casts
across the wizard/form components.
M4 (shared JA_NEE) folded into the upcoming i18n pass (clean dedup needs
$localize labels to sit in shared without breaking the English-shared-UI rule).
L1 already resolved by the restyle commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,15 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Valid } from '../domain/herregistratie.machine';
|
import { Valid } from '../domain/herregistratie.machine';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
|
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
|
||||||
* The "uren must be > 0" rule now lives server-side; the backend returns a 422
|
* The "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced
|
||||||
* ProblemDetails on rejection, which we surface as the error.
|
* as the error by `runSubmit`.
|
||||||
*/
|
*/
|
||||||
export async function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
||||||
try {
|
return runSubmit<void>(async () => {
|
||||||
await client.herregistraties({ uren: data.uren });
|
await client.herregistraties({ uren: data.uren });
|
||||||
return ok(undefined);
|
}, SUBMIT_FAILED);
|
||||||
} catch (e) {
|
|
||||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { ValidIntake } from '../domain/intake.machine';
|
import { ValidIntake } from '../domain/intake.machine';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
|
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
|
||||||
* "uren must be > 0" rule now lives server-side; the backend returns a 422
|
* "uren must be > 0" rule lives server-side; a 422 ProblemDetails is surfaced as
|
||||||
* ProblemDetails on rejection, which we surface as the error.
|
* the error by `runSubmit`.
|
||||||
*/
|
*/
|
||||||
export async function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
|
export function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
|
||||||
try {
|
return runSubmit<void>(async () => {
|
||||||
await client.intakes({ uren: data.uren });
|
await client.intakes({ uren: data.uren });
|
||||||
return ok(undefined);
|
}, SUBMIT_FAILED);
|
||||||
} catch (e) {
|
|
||||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
|||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
||||||
@@ -71,13 +72,13 @@ export class HerregistratieWizardComponent {
|
|||||||
readonly stepLabels = ['Werkervaring', 'Nascholing'];
|
readonly stepLabels = ['Werkervaring', 'Nascholing'];
|
||||||
private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing'];
|
private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing'];
|
||||||
|
|
||||||
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||||
protected step = computed(() => this.editing()?.step ?? 1);
|
protected step = computed(() => this.editing()?.step ?? 1);
|
||||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
||||||
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import {
|
import {
|
||||||
IntakeState,
|
IntakeState,
|
||||||
@@ -126,7 +127,7 @@ export class IntakeWizardComponent {
|
|||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract<IntakeState, { tag: 'Answering' }>) : null));
|
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
||||||
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||||
readonly steps = STEPS;
|
readonly steps = STEPS;
|
||||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||||
@@ -136,7 +137,7 @@ export class IntakeWizardComponent {
|
|||||||
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
protected scholingThreshold = computed(() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
|
||||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];
|
readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Valid } from '@registratie/domain/change-request.machine';
|
import { Valid } from '@registratie/domain/change-request.machine';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: POST an address change to the backend (`/api/v1/change-requests`),
|
* Command: POST an address change to the backend (`/api/v1/change-requests`),
|
||||||
* which re-validates and returns a reference. Same shape as the other submit
|
* which re-validates and returns a reference. Same shape as the other submit
|
||||||
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
|
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
|
||||||
*/
|
*/
|
||||||
export async function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
|
export function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
|
||||||
try {
|
return runSubmit(async () => {
|
||||||
const res = await client.changeRequests({
|
const res = await client.changeRequests({
|
||||||
straat: data.straat,
|
straat: data.straat,
|
||||||
postcode: data.postcode,
|
postcode: data.postcode,
|
||||||
woonplaats: data.woonplaats,
|
woonplaats: data.woonplaats,
|
||||||
});
|
});
|
||||||
return ok(res.referentie ?? '');
|
return res.referentie ?? '';
|
||||||
} catch (e) {
|
}, SUBMIT_FAILED);
|
||||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: POST the completed registration to the backend (`/api/registrations`),
|
* Command: POST the completed registration to the backend (`/api/registrations`),
|
||||||
* which re-validates and decides. The business rule that a manually entered
|
* which re-validates and decides. The rule that a manually entered diploma cannot
|
||||||
* diploma cannot be auto-verified now lives server-side; the backend returns it as
|
* be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On
|
||||||
* a 422 ProblemDetails, which we surface as the error. On success it yields the
|
* success it yields the server-generated confirmation reference (PRD §9).
|
||||||
* server-generated confirmation reference (PRD §9).
|
|
||||||
*/
|
*/
|
||||||
export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
||||||
try {
|
return runSubmit(async () => {
|
||||||
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
|
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
|
||||||
return ok(res.referentie ?? '');
|
return res.referentie ?? '';
|
||||||
} catch (e) {
|
}, SUBMIT_FAILED);
|
||||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,20 @@ const base: Registration = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('tasksFromProfile', () => {
|
describe('tasksFromProfile', () => {
|
||||||
it('offers herregistratie when within the deadline window', () => {
|
it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
|
||||||
const tasks = tasksFromProfile(base, new Date('2026-06-27'));
|
const tasks = tasksFromProfile(base, true);
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0].to).toBe('/herregistratie');
|
expect(tasks[0].to).toBe('/herregistratie');
|
||||||
expect(tasks[0].description).toContain('31 december 2026');
|
expect(tasks[0].description).toContain('31 december 2026');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers nothing when the deadline is far away', () => {
|
it('offers nothing when the server says not eligible', () => {
|
||||||
const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } };
|
expect(tasksFromProfile(base, false)).toHaveLength(0);
|
||||||
expect(tasksFromProfile(far, new Date('2026-06-27'))).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces a notice for a suspended registration', () => {
|
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
|
||||||
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
|
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
|
||||||
const tasks = tasksFromProfile(reg, new Date('2026-06-27'));
|
const tasks = tasksFromProfile(reg, false);
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0].title).toContain('geschorst');
|
expect(tasks[0].title).toContain('geschorst');
|
||||||
expect(tasks[0].description).toBe('Onderzoek');
|
expect(tasks[0].description).toBe('Onderzoek');
|
||||||
@@ -34,7 +33,7 @@ describe('tasksFromProfile', () => {
|
|||||||
|
|
||||||
it('surfaces a notice for a struck-off registration', () => {
|
it('surfaces a notice for a struck-off registration', () => {
|
||||||
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
|
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
|
||||||
const tasks = tasksFromProfile(reg, new Date('2026-06-27'));
|
const tasks = tasksFromProfile(reg, false);
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0].title).toContain('doorgehaald');
|
expect(tasks[0].title).toContain('doorgehaald');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Registration } from './registration';
|
import { Registration } from './registration';
|
||||||
import { herregistratieDeadline, isHerregistratieEligible } from './registration.policy';
|
import { herregistratieDeadline } from './registration.policy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data
|
* What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data
|
||||||
@@ -17,15 +17,15 @@ function formatNL(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the open tasks for a professional from their registration (pure;
|
* Derive the open tasks for a professional (pure). Eligibility is the server's
|
||||||
* `today` is injected so it's testable). Herregistratie within its window is the
|
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
|
||||||
* primary action; a suspended/struck-off registration surfaces as a notice-task.
|
* it does not recompute the rule (ADR-0001). The deadline is still formatted
|
||||||
* Reuses the same rules the detail/summary use (registration.policy).
|
* client-side for the task copy (presentation, not a rule).
|
||||||
*/
|
*/
|
||||||
export function tasksFromProfile(reg: Registration, today: Date): PortalTask[] {
|
export function tasksFromProfile(reg: Registration, eligibleForHerregistratie: boolean): PortalTask[] {
|
||||||
const tasks: PortalTask[] = [];
|
const tasks: PortalTask[] = [];
|
||||||
|
|
||||||
if (isHerregistratieEligible(reg, today)) {
|
if (eligibleForHerregistratie) {
|
||||||
const deadline = herregistratieDeadline(reg);
|
const deadline = herregistratieDeadline(reg);
|
||||||
tasks.push({
|
tasks.push({
|
||||||
title: 'Vraag uw herregistratie aan',
|
title: 'Vraag uw herregistratie aan',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
|||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
|
import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||||
import { submitChangeRequest } from '@registratie/application/submit-change-request';
|
import { submitChangeRequest } from '@registratie/application/submit-change-request';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
@@ -55,10 +56,10 @@ export class ChangeRequestFormComponent {
|
|||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
protected dispatch = this.store.dispatch;
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<State, { tag: 'Editing' }>) : null));
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||||
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
|
protected errors = computed<AdresErrors>(() => this.editing()?.errors ?? {});
|
||||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<State, { tag: 'Failed' }>).error : ''));
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||||
protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract<State, { tag: 'Submitted' }>).referentie : ''));
|
protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');
|
||||||
|
|
||||||
/** The address shown in the fields — the live draft while editing, the parsed
|
/** The address shown in the fields — the live draft while editing, the parsed
|
||||||
data while submitting/failed (so the user sees what they sent). */
|
data while submitting/failed (so the user sees what they sent). */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||||
@@ -103,10 +103,15 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
|||||||
})
|
})
|
||||||
export class DashboardPage {
|
export class DashboardPage {
|
||||||
protected store = inject(BigProfileStore);
|
protected store = inject(BigProfileStore);
|
||||||
private readonly today = new Date();
|
|
||||||
|
/** Server-computed eligibility (rendered, not recomputed). */
|
||||||
|
private readonly eligible = computed(() => {
|
||||||
|
const d = this.store.decisions();
|
||||||
|
return d.tag === 'Success' && d.value.eligibleForHerregistratie;
|
||||||
|
});
|
||||||
|
|
||||||
protected tasksFor(reg: Registration) {
|
protected tasksFor(reg: Registration) {
|
||||||
return tasksFromProfile(reg, this.today);
|
return tasksFromProfile(reg, this.eligible());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Portal sections (navigation, not actions). */
|
/** Portal sections (navigation, not actions). */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/
|
|||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||||
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
||||||
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
||||||
@@ -180,13 +181,13 @@ export class RegistratieWizardComponent {
|
|||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
||||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||||
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
||||||
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));
|
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));
|
||||||
|
|||||||
23
src/app/shared/application/submit.spec.ts
Normal file
23
src/app/shared/application/submit.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { runSubmit } from './submit';
|
||||||
|
|
||||||
|
describe('runSubmit', () => {
|
||||||
|
it('folds a resolved call into ok(value)', async () => {
|
||||||
|
const r = await runSubmit(async () => 'BIG-123', 'fallback');
|
||||||
|
expect(r).toEqual({ ok: true, value: 'BIG-123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a ProblemDetails rejection to err(detail)', async () => {
|
||||||
|
const r = await runSubmit(async () => {
|
||||||
|
throw { detail: 'Aanvraag afgewezen.' };
|
||||||
|
}, 'fallback');
|
||||||
|
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when the rejection has no detail', async () => {
|
||||||
|
const r = await runSubmit(async () => {
|
||||||
|
throw new Error('network');
|
||||||
|
}, 'fallback');
|
||||||
|
expect(r).toEqual({ ok: false, error: 'fallback' });
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/app/shared/application/submit.ts
Normal file
20
src/app/shared/application/submit.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a mutating API call and fold it into a `Result` — the one place the
|
||||||
|
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||||
|
* its own payload mapping. The backend re-validates and returns a 422
|
||||||
|
* ProblemDetails on rejection, surfaced here as the error string.
|
||||||
|
*/
|
||||||
|
export async function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
|
||||||
|
try {
|
||||||
|
return ok(await fn());
|
||||||
|
} catch (e) {
|
||||||
|
return err(problemDetail(e, fallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: i18n in Step 3/M3 wraps this in $localize with a stable @@id, which
|
||||||
|
// dedupes it at the translation layer; for now it's the single shared default.
|
||||||
|
export const SUBMIT_FAILED = 'Het indienen is niet gelukt. Probeer het later opnieuw.';
|
||||||
@@ -21,3 +21,13 @@ export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
|
|||||||
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
|
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
|
||||||
only through an explicit cast — so a smart constructor is the only minter. */
|
only through an explicit cast — so a smart constructor is the only minter. */
|
||||||
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
||||||
|
|
||||||
|
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
|
||||||
|
the cast lives — TS can't narrow through a runtime tag argument, so callers get
|
||||||
|
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
|
||||||
|
export function whenTag<U extends { tag: string }, K extends U['tag']>(
|
||||||
|
u: U,
|
||||||
|
tag: K,
|
||||||
|
): Extract<U, { tag: K }> | null {
|
||||||
|
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user