## What & why S-15b, second of the S-15 (#16) split, on top of S-15a (#133). A beheerder edits the ACL's ZGW **default-fill** values from the beheer portal, and the next zaak is stamped with the new values — no restart. Closes #131 ### The vertical portal → BFF `GET/PUT /beheer/default-fill` (medewerker realm + `beheerder` role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads **per zaak**. - **ACL**: `IDefaultFillStore` / `InMemoryDefaultFillStore` (thread-safe, seeded from `Acl:Defaults`); `AclService` reads `fill.Current` per zaak (not cached at construction); `GET`/`PUT /default-fill` with required-field validation. - **BFF**: `IAclClient` gains `GetDefaultFillAsync`/`UpdateDefaultFillAsync`; `GET`/`PUT /beheer/default-fill` behind the `beheerder` policy. OpenAPI + generated client regenerated. - **Frontend**: a *Default-fill* editor page in the beheer app (load → edit → save, with saved/failure states) + nav between Catalogus and Default-fill. ### Scope decision → ADR-0026 Only the **three ZGW fill fields** (bronorganisatie, verantwoordelijke organisatie, vertrouwelijkheidaanduiding) are editable. The S-27 catalog-resolution keys stay **static config** — editing them would desync the zaaktype-URL cache (ADR-0021), and they're catalogus wiring, not "default fill". The store is **in-memory** (seeded from config): an edit reverts on restart. That's the reference-app-appropriate ceiling (no DB added to the stateless ACL); upgrade path documented. Recorded in **ADR-0026**. ## Verified locally lint (`dotnet format`) ✓ · .NET unit — acl 60 / bff 45 / domain 152 / event-subscriber 19 / acceptance 17 ✓ · frontend lint+test (8 projects) ✓ · beheer build ✓. Clean full-solution build (caught + fixed the acceptance `AclService` ctor drift). TDD red→green per layer (ACL store, ACL endpoints, BFF, frontend). ## Definition of Done - [x] Failing test committed before each implementation (red→green per layer). - [x] Conventional Commits referencing #131. - [ ] CI green — see note below. - [x] Docs: ADR-0026 + S-15b demo note. - [x] Demo note in `docs/demo-script.md`. ## Note on CI The bulk validates in the fast jobs (lint/build/unit/frontend/mutation). The **verify-stack e2e** (incl. the new `default-fill.spec.ts`) can't go green until the pre-existing **verify-stack bring-up failure on the 1.27/2.0.0 runner** is resolved (that fails on plain `main` too — unrelated to this PR). Additive change; no existing e2e touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #138
This commit was merged in pull request #138.
This commit is contained in:
@@ -1 +1,5 @@
|
||||
<nav aria-label="Beheer" class="utrecht-theme">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
|
||||
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authenticatedGuard } from 'auth';
|
||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Default-fill</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
|
||||
voor de eerstvolgende zaak.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (loaded()) {
|
||||
<form (submit)="save(); $event.preventDefault()">
|
||||
<p>
|
||||
<label for="bronorganisatie">Bronorganisatie</label><br />
|
||||
<input
|
||||
id="bronorganisatie"
|
||||
name="bronorganisatie"
|
||||
[value]="bronorganisatie()"
|
||||
(input)="bronorganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
|
||||
<input
|
||||
id="verantwoordelijkeOrganisatie"
|
||||
name="verantwoordelijkeOrganisatie"
|
||||
[value]="verantwoordelijkeOrganisatie()"
|
||||
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
|
||||
<input
|
||||
id="vertrouwelijkheidaanduiding"
|
||||
name="vertrouwelijkheidaanduiding"
|
||||
[value]="vertrouwelijkheidaanduiding()"
|
||||
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
|
||||
Opslaan
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (saved()) {
|
||||
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
|
||||
}
|
||||
@if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
|
||||
het opnieuw.
|
||||
</p>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { DefaultFillPage } from './default-fill-page';
|
||||
|
||||
const current: BeheerDefaultFill = {
|
||||
bronorganisatie: '517439943',
|
||||
verantwoordelijkeOrganisatie: '517439943',
|
||||
vertrouwelijkheidaanduiding: 'openbaar',
|
||||
};
|
||||
|
||||
class FakeAuth extends AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly bsn = signal<string | undefined>(undefined);
|
||||
override readonly roles = signal<readonly string[]>(['beheerder']);
|
||||
login(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: {
|
||||
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
|
||||
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
|
||||
return {
|
||||
getBeheerDefaultFill,
|
||||
putBeheerDefaultFill,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DefaultFillPage', () => {
|
||||
it('loads the current default-fill into the form on open', async () => {
|
||||
const { getBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
expect(getBeheerDefaultFill).toHaveBeenCalled();
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
expect(bron.value).toBe('517439943');
|
||||
});
|
||||
|
||||
it('saves the edited values via the BFF', async () => {
|
||||
const { putBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
fireEvent.input(bron, { target: { value: '999999999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
|
||||
);
|
||||
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a save failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(DefaultFillPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
|
||||
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
|
||||
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-default-fill-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './default-fill-page.html',
|
||||
})
|
||||
export class DefaultFillPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly bronorganisatie = signal('');
|
||||
protected readonly verantwoordelijkeOrganisatie = signal('');
|
||||
protected readonly vertrouwelijkheidaanduiding = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly saved = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff.getBeheerDefaultFill().subscribe({
|
||||
next: (d: BeheerDefaultFill) => {
|
||||
this.bronorganisatie.set(d.bronorganisatie);
|
||||
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
|
||||
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.saving.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff
|
||||
.putBeheerDefaultFill({
|
||||
bronorganisatie: this.bronorganisatie(),
|
||||
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
|
||||
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.saved.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user