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,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 { 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`),
|
||||
* 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.
|
||||
*/
|
||||
export async function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
|
||||
try {
|
||||
export function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
|
||||
return runSubmit(async () => {
|
||||
const res = await client.changeRequests({
|
||||
straat: data.straat,
|
||||
postcode: data.postcode,
|
||||
woonplaats: data.woonplaats,
|
||||
});
|
||||
return ok(res.referentie ?? '');
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||
}
|
||||
return res.referentie ?? '';
|
||||
}, SUBMIT_FAILED);
|
||||
}
|
||||
|
||||
@@ -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 { 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`),
|
||||
* which re-validates and decides. The business rule that a manually entered
|
||||
* diploma cannot be auto-verified now lives server-side; the backend returns it as
|
||||
* a 422 ProblemDetails, which we surface as the error. On success it yields the
|
||||
* server-generated confirmation reference (PRD §9).
|
||||
* which re-validates and decides. The rule that a manually entered diploma cannot
|
||||
* be auto-verified lives server-side (surfaced as a 422 by `runSubmit`). On
|
||||
* success it yields the server-generated confirmation reference (PRD §9).
|
||||
*/
|
||||
export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
||||
try {
|
||||
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
||||
return runSubmit(async () => {
|
||||
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
|
||||
return ok(res.referentie ?? '');
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||
}
|
||||
return res.referentie ?? '';
|
||||
}, SUBMIT_FAILED);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,20 @@ const base: Registration = {
|
||||
};
|
||||
|
||||
describe('tasksFromProfile', () => {
|
||||
it('offers herregistratie when within the deadline window', () => {
|
||||
const tasks = tasksFromProfile(base, new Date('2026-06-27'));
|
||||
it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
|
||||
const tasks = tasksFromProfile(base, true);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].to).toBe('/herregistratie');
|
||||
expect(tasks[0].description).toContain('31 december 2026');
|
||||
});
|
||||
|
||||
it('offers nothing when the deadline is far away', () => {
|
||||
const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } };
|
||||
expect(tasksFromProfile(far, new Date('2026-06-27'))).toHaveLength(0);
|
||||
it('offers nothing when the server says not eligible', () => {
|
||||
expect(tasksFromProfile(base, false)).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 tasks = tasksFromProfile(reg, new Date('2026-06-27'));
|
||||
const tasks = tasksFromProfile(reg, false);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toContain('geschorst');
|
||||
expect(tasks[0].description).toBe('Onderzoek');
|
||||
@@ -34,7 +33,7 @@ describe('tasksFromProfile', () => {
|
||||
|
||||
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 tasks = tasksFromProfile(reg, new Date('2026-06-27'));
|
||||
const tasks = tasksFromProfile(reg, false);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toContain('doorgehaald');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
@@ -17,15 +17,15 @@ function formatNL(d: Date): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the open tasks for a professional from their registration (pure;
|
||||
* `today` is injected so it's testable). Herregistratie within its window is the
|
||||
* primary action; a suspended/struck-off registration surfaces as a notice-task.
|
||||
* Reuses the same rules the detail/summary use (registration.policy).
|
||||
* Derive the open tasks for a professional (pure). Eligibility is the server's
|
||||
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
|
||||
* it does not recompute the rule (ADR-0001). The deadline is still formatted
|
||||
* 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[] = [];
|
||||
|
||||
if (isHerregistratieEligible(reg, today)) {
|
||||
if (eligibleForHerregistratie) {
|
||||
const deadline = herregistratieDeadline(reg);
|
||||
tasks.push({
|
||||
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 { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui/address-fields/address-fields.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||
import { submitChangeRequest } from '@registratie/application/submit-change-request';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
@@ -55,10 +56,10 @@ export class ChangeRequestFormComponent {
|
||||
readonly state = this.store.model;
|
||||
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 failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<State, { tag: 'Failed' }>).error : ''));
|
||||
protected referentie = computed(() => (this.state().tag === 'Submitted' ? (this.state() as Extract<State, { tag: 'Submitted' }>).referentie : ''));
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');
|
||||
|
||||
/** The address shown in the fields — the live draft while editing, the parsed
|
||||
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 { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
@@ -103,10 +103,15 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
||||
})
|
||||
export class DashboardPage {
|
||||
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) {
|
||||
return tasksFromProfile(reg, this.today);
|
||||
return tasksFromProfile(reg, this.eligible());
|
||||
}
|
||||
|
||||
/** 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 { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
|
||||
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
|
||||
@@ -180,13 +181,13 @@ export class RegistratieWizardComponent {
|
||||
readonly state = this.store.model;
|
||||
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 draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||
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 referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
||||
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));
|
||||
|
||||
Reference in New Issue
Block a user