feat(dev): WP-33 — in-app dev switchers for scenario + role
Surface the ?scenario= and ?role= dev stand-ins as dropdowns in the existing debug-state devtool, so a demo can flip them with a click instead of editing the URL. scenario.ts/role.ts gain set* setters + exported valid-value lists (reused by the panel, no duplicated source of truth); scenario becomes tab-sticky like role so it survives navigation. Applied via location.reload() since both are read per-request in interceptors. Extends the debug-state eslint exemption to the ui→infrastructure rule (same devtool precedent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
|
||||
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
|
||||
| [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done |
|
||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | todo |
|
||||
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
|
||||
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | todo |
|
||||
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | todo |
|
||||
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | todo |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# WP-33 — In-app dev switchers (scenario + role)
|
||||
|
||||
Status: done
|
||||
Phase: 7 — refinements
|
||||
|
||||
## Why
|
||||
|
||||
The two dev-only stand-ins — the async `?scenario=` toggle (`scenario.interceptor.ts`) and the
|
||||
faked `?role=` (`role.interceptor.ts`) — were driven by hand-editing the URL query string.
|
||||
Awkward for demos: you had to remember the valid values and retype them. This WP surfaces both
|
||||
as dropdowns in the existing dev panel so a scenario/role can be flipped with a click.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
- **No new component/shell wiring.** The switchers live inside the existing `debug-state`
|
||||
devtool (the sanctioned dev-only fab/panel already mounted in the shell under `isDevMode()`).
|
||||
- **Reuse the mechanism modules, don't duplicate their source of truth.** `scenario.ts`/`role.ts`
|
||||
gain a `set*` setter + an exported valid-values list; the panel imports them. `debug-state` is
|
||||
added to the `ui→infrastructure` eslint exemption (same precedent as its existing cross-context
|
||||
exemption) rather than re-declaring the storage keys / valid lists in the UI.
|
||||
- **Scenario becomes tab-sticky (sessionStorage), mirroring role.** Without this the switcher
|
||||
would be near-useless: navigation drops the query param and reverts to `default` mid-demo.
|
||||
- **Apply by `location.reload()`.** Both values are read per-request in interceptors and gate
|
||||
server-computed decision flags already fetched by eager `httpResource`s — a reload is the
|
||||
simplest correct way to re-run them. Acceptable for a dev tool.
|
||||
|
||||
## Files
|
||||
|
||||
- `shared/infrastructure/scenario.ts` — tab-sticky read (mirrors role), `setScenario`, exported
|
||||
`SCENARIOS`; co-located `scenario.spec.ts`.
|
||||
- `shared/infrastructure/role.ts` — `setRole`, exported `ROLES`.
|
||||
- `shared/ui/debug-state/debug-state.component.ts` — two `<select>`s in the panel.
|
||||
- `eslint.config.mjs` — extend the debug-state devtool exemption to the ui→infrastructure rule.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Dev panel shows role + scenario dropdowns; changing one persists it and reloads.
|
||||
- [x] Scenario survives navigation within the tab (sticky).
|
||||
- [x] `npm run ci` green (lint, tests incl. new scenario spec, localized build).
|
||||
+4
-1
@@ -230,7 +230,10 @@ export default [
|
||||
// no-restricted-imports context-direction rules above (last-wins is per rule name).
|
||||
{
|
||||
files: ['src/app/**/ui/**/*.ts', 'src/app/**/layout/**/*.ts'],
|
||||
ignores: ['**/*.stories.ts', '**/*.spec.ts'],
|
||||
// debug-state is the sanctioned devtool (same precedent as the cross-context
|
||||
// exemption above): its WP-33 role/scenario switchers write the infrastructure
|
||||
// dev-mechanism helpers directly. Never a product feature — isDevMode()-gated.
|
||||
ignores: ['**/*.stories.ts', '**/*.spec.ts', 'src/app/shared/ui/debug-state/**'],
|
||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||
rules: {
|
||||
'@typescript-eslint/no-restricted-imports': [
|
||||
|
||||
@@ -17,8 +17,8 @@ import { Role } from '@shared/domain/role';
|
||||
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
|
||||
*/
|
||||
const STORAGE_KEY = 'dev-role';
|
||||
const isRole = (v: string | null): v is Role =>
|
||||
v === 'drafter' || v === 'approver' || v === 'admin';
|
||||
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
|
||||
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
|
||||
|
||||
export function currentRole(): Role {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('role');
|
||||
@@ -29,3 +29,8 @@ export function currentRole(): Role {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isRole(stored) ? stored : 'drafter';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */
|
||||
export function setRole(r: Role): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, r);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { currentScenario, setScenario } from './scenario';
|
||||
|
||||
const setUrl = (search: string) => history.pushState({}, '', search || '/');
|
||||
|
||||
describe('scenario (dev mechanism)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
setUrl('/');
|
||||
});
|
||||
|
||||
it('reads a valid ?scenario= from the URL and persists it for the tab', () => {
|
||||
setUrl('?scenario=error');
|
||||
expect(currentScenario()).toBe('error');
|
||||
setUrl('/'); // navigation drops the query param — value stays sticky
|
||||
expect(currentScenario()).toBe('error');
|
||||
});
|
||||
|
||||
it('falls back to default when nothing is set or the value is invalid', () => {
|
||||
expect(currentScenario()).toBe('default');
|
||||
setUrl('?scenario=nonsense');
|
||||
expect(currentScenario()).toBe('default');
|
||||
});
|
||||
|
||||
it('setScenario persists the chosen scenario', () => {
|
||||
setScenario('slow');
|
||||
expect(currentScenario()).toBe('slow');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export type Scenario =
|
||||
| 'upload-slow'
|
||||
| 'upload-fail';
|
||||
|
||||
const VALID: Scenario[] = [
|
||||
export const SCENARIOS: readonly Scenario[] = [
|
||||
'default',
|
||||
'slow',
|
||||
'loading',
|
||||
@@ -19,8 +19,27 @@ const VALID: Scenario[] = [
|
||||
'upload-fail',
|
||||
];
|
||||
|
||||
/** Reads ?scenario= from the URL so a demo can force each async state. */
|
||||
const STORAGE_KEY = 'dev-scenario';
|
||||
const isScenario = (v: string | null): v is Scenario => !!v && SCENARIOS.includes(v as Scenario);
|
||||
|
||||
/**
|
||||
* Reads the active demo scenario so a demo can force each async state.
|
||||
* Sticky within the tab (sessionStorage), mirroring `role.ts`: a `?scenario=` in the
|
||||
* URL sets it; later navigation (which drops the query param) keeps the remembered
|
||||
* value. Set `?scenario=default`, use the dev switcher, or open a fresh tab to reset.
|
||||
* Dev-only — the interceptor that consumes this is wired only under `isDevMode()`.
|
||||
*/
|
||||
export function currentScenario(): Scenario {
|
||||
const s = new URLSearchParams(window.location.search).get('scenario') as Scenario | null;
|
||||
return s && VALID.includes(s) ? s : 'default';
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('scenario');
|
||||
if (isScenario(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isScenario(stored) ? stored : 'default';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */
|
||||
export function setScenario(s: Scenario): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, s);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { SessionStore } from '@auth/application/session.store';
|
||||
import { Session } from '@auth/domain/session';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { map } from '@shared/application/remote-data';
|
||||
import { Role } from '@shared/domain/role';
|
||||
import { ROLES, currentRole, setRole } from '@shared/infrastructure/role';
|
||||
import { Scenario, SCENARIOS, currentScenario, setScenario } from '@shared/infrastructure/scenario';
|
||||
import { maskBsn, redactProfile } from './mask';
|
||||
|
||||
// CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately
|
||||
@@ -57,6 +60,27 @@ import { maskBsn, redactProfile } from './mask';
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.switchers {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: var(--rhc-border-width-sm) solid var(--app-devpanel-border);
|
||||
}
|
||||
.switchers label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font: 11px/1.3 monospace;
|
||||
color: var(--app-devpanel-accent);
|
||||
}
|
||||
.switchers select {
|
||||
font: 12px/1 monospace;
|
||||
background: var(--app-devpanel-bg);
|
||||
color: var(--app-devpanel-fg);
|
||||
border: var(--rhc-border-width-sm) solid var(--app-devpanel-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@@ -66,6 +90,24 @@ import { maskBsn, redactProfile } from './mask';
|
||||
</button>
|
||||
@if (visible()) {
|
||||
<div class="panel">
|
||||
<div class="switchers">
|
||||
<label
|
||||
>role
|
||||
<select [value]="role" (change)="switchRole($any($event.target).value)">
|
||||
@for (r of roles; track r) {
|
||||
<option [value]="r">{{ r }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>scenario
|
||||
<select [value]="scenario" (change)="switchScenario($any($event.target).value)">
|
||||
@for (s of scenarios; track s) {
|
||||
<option [value]="s">{{ s }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<pre>{{ snapshot() | json }}</pre>
|
||||
</div>
|
||||
}
|
||||
@@ -92,6 +134,22 @@ export class DebugStateComponent {
|
||||
pendingHerregistratie: this.profileStore?.pendingHerregistratie(),
|
||||
}));
|
||||
|
||||
// Dev switchers (WP-33): flip role/scenario without hand-editing the URL. Both are
|
||||
// read per-request in interceptors, so a reload re-runs them and re-fetches decisions.
|
||||
protected readonly roles = ROLES;
|
||||
protected readonly scenarios = SCENARIOS;
|
||||
protected readonly role = currentRole();
|
||||
protected readonly scenario = currentScenario();
|
||||
|
||||
switchRole(r: Role): void {
|
||||
setRole(r);
|
||||
location.reload();
|
||||
}
|
||||
switchScenario(s: Scenario): void {
|
||||
setScenario(s);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (!this.visible()) this.profileStore ??= this.injector.get(BigProfileStore);
|
||||
this.visible.update((v) => !v);
|
||||
|
||||
Reference in New Issue
Block a user