Add dev-only state debug view (Elm-style "show the Model")
A floating, read-only panel that renders the current root-store state (SessionStore + BigProfileStore) live via the json pipe. Whole thing is gated by isDevMode() so it never renders or ships in production. - Observer only — no new store/library/state pattern (PRD prime directive). - BigProfileStore resolved lazily on first open; its httpResources fetch eagerly on construction, so we avoid a personal-data fetch on pages that don't use it. - bsn masked before render; no persistence/logging/network in the feature. - maskBsn has a unit spec; UI exercised via a Devtools Storybook story, per repo convention (no TestBed component tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
67
src/app/shared/ui/debug-state/debug-state.component.ts
Normal file
67
src/app/shared/ui/debug-state/debug-state.component.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core';
|
||||
import { JsonPipe } from '@angular/common';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { Session } from '@auth/domain/session';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { maskBsn } from './mask';
|
||||
|
||||
/**
|
||||
* Dev-only "show the current Model" panel (Elm-debugger style, read-only).
|
||||
* Observes the root singletons and renders them via the json pipe. Never a
|
||||
* product feature: the whole template is gated by isDevMode(), so it does not
|
||||
* render and is unreachable in a production build.
|
||||
*
|
||||
* ponytail: dev tool — intentionally off-theme (not Rijkshuisstijl) and exempt
|
||||
* from the design-token convention; plain values keep it visually distinct.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-debug-state',
|
||||
imports: [JsonPipe],
|
||||
styles: [`
|
||||
.fab{position:fixed;bottom:1rem;right:1rem;z-index:9999;font:600 12px/1 monospace;
|
||||
background:#1e1e1e;color:#9cdcfe;border:1px solid #444;border-radius:4px;
|
||||
padding:.5rem .75rem;cursor:pointer}
|
||||
.panel{position:fixed;bottom:3.25rem;right:1rem;z-index:9999;width:min(90vw,28rem);
|
||||
max-height:70vh;overflow:auto;background:#1e1e1e;color:#d4d4d4;border:1px solid #444;
|
||||
border-radius:6px;box-shadow:0 6px 24px rgba(0,0,0,.4)}
|
||||
.panel pre{margin:0;padding:.75rem;font:12px/1.5 monospace;white-space:pre-wrap;
|
||||
word-break:break-word}
|
||||
`],
|
||||
template: `
|
||||
@if (isDev) {
|
||||
<button type="button" class="fab" (click)="toggle()">
|
||||
{{ visible() ? '× state' : '⚙ state' }}
|
||||
</button>
|
||||
@if (visible()) {
|
||||
<div class="panel"><pre>{{ snapshot() | json }}</pre></div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DebugStateComponent {
|
||||
protected readonly isDev = isDevMode();
|
||||
protected readonly visible = signal(false);
|
||||
|
||||
private session = inject(SessionStore);
|
||||
private injector = inject(Injector);
|
||||
// Resolved on first open only — BigProfileStore's httpResources fetch eagerly
|
||||
// on construction, so we must not instantiate it until the dev asks to look.
|
||||
private profileStore?: BigProfileStore;
|
||||
|
||||
protected readonly snapshot = computed(() => ({
|
||||
session: maskSession(this.session.session()),
|
||||
profile: this.profileStore?.profile(),
|
||||
decisions: this.profileStore?.decisions(),
|
||||
aantekeningen: this.profileStore?.aantekeningen(),
|
||||
pendingHerregistratie: this.profileStore?.pendingHerregistratie(),
|
||||
}));
|
||||
|
||||
toggle(): void {
|
||||
if (!this.visible()) this.profileStore ??= this.injector.get(BigProfileStore);
|
||||
this.visible.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
||||
function maskSession(s: Session | null): Session | null {
|
||||
return s ? { ...s, bsn: maskBsn(s.bsn) } : null;
|
||||
}
|
||||
14
src/app/shared/ui/debug-state/debug-state.stories.ts
Normal file
14
src/app/shared/ui/debug-state/debug-state.stories.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { DebugStateComponent } from './debug-state.component';
|
||||
|
||||
// Devtools, not a design-system atom — titled accordingly.
|
||||
const meta: Meta<DebugStateComponent> = {
|
||||
title: 'Devtools/State debug',
|
||||
component: DebugStateComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DebugStateComponent>;
|
||||
|
||||
// The floating button; click it to open the read-only state panel. Stores
|
||||
// resolve to their default (empty/loading) state here.
|
||||
export const Default: Story = {};
|
||||
13
src/app/shared/ui/debug-state/mask.spec.ts
Normal file
13
src/app/shared/ui/debug-state/mask.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maskBsn } from './mask';
|
||||
|
||||
describe('maskBsn', () => {
|
||||
it('keeps the last 3 digits and masks the rest', () => {
|
||||
expect(maskBsn('123456789')).toBe('******789');
|
||||
});
|
||||
|
||||
it('handles short and empty input without throwing', () => {
|
||||
expect(maskBsn('12')).toBe('**');
|
||||
expect(maskBsn('')).toBe('');
|
||||
});
|
||||
});
|
||||
5
src/app/shared/ui/debug-state/mask.ts
Normal file
5
src/app/shared/ui/debug-state/mask.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */
|
||||
export function maskBsn(bsn: string): string {
|
||||
if (bsn.length <= 3) return '*'.repeat(bsn.length);
|
||||
return '*'.repeat(bsn.length - 3) + bsn.slice(-3);
|
||||
}
|
||||
Reference in New Issue
Block a user