12 KiB
Implementation Plan: Admin Area General Settings — Actionable Page-Title Validation Message (Job 884)
0. Status — Already Implemented?
Not fully implemented. The reactive-form plumbing that disables the Save button (Validators.required, custom pageTitleNotBlankValidator, [disabled]="submitting() || form.invalid") works correctly for the empty / whitespace-only negative case, and the field's classes (ng-dirty ng-invalid ng-touched) update as expected. However, the actionable inline error message required by this Job is not shown to the user.
Concrete gap (verified in source):
frontend/src/app/features/admin/general.component.ts:199-202—showPageTitleErroris an Angularcomputed()that readsthis.form.controls.pageTitle.touched,.dirty,.invalid. These are not signals, so the computed never re-evaluates when the control's status flips.frontend/src/app/features/admin/general.component.ts:204-217—pageTitleMessageis acomputed()that readsthis.form.controls.pageTitle.valueand.errors. Same problem: the computed is not subscribed to the form control'svalueChanges/statusChanges, so it never re-runs after the user types.- Result: the template
<div ... data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>at line 53 stays at Angular's<!---->placeholder even after the field isng-dirty ng-invalid ng-touched.
Therefore a code change is required.
1. Architectural Reconnaissance
- Codebase style & conventions: TypeScript, NestJS REST API + Angular 17+ standalone components with
ChangeDetectionStrategy.OnPush, signal-based state (signal,computed,effect), reactive forms withfb.nonNullable.group(...),takeUntilDestroyed()for RxJS → signal bridging. Validators and pure helpers live in sibling*.pure.tsfiles (e.g.general.pure.ts).data-testidattributes are the contract for every input, button, and status element. - Data Layer: SQLite via
better-sqlite3(perpackage.json); persisted key/value settings in thesettingtable. No schema migration is needed for this Job — only the frontend inline validation message is broken. - Test Framework & Structure: Jest 29 with
ts-jest, jsdom environment for frontend. All tests live in/repo/tests/(never alongside source). Frontend spec files are pure-function tests on*.pure.tsvalidators/helpers (e.g.admin-general-pure.spec.ts,landing-markdown.spec.ts,change-password-modal.spec.ts) — there are currently no TestBed component-level tests in the repo.npm testruns both backend and frontend projects viatests/jest.config.js. - Required Tools & Dependencies: None new. The fix uses Angular primitives (
signal,computed,toSignalfrom@angular/core/rxjs-interop,takeUntilDestroyed) already present in the file. Nosetup.shchanges, no new packages.
2. Impacted Files
- To Modify:
frontend/src/app/features/admin/general.component.ts— fix the reactivity gap forshowPageTitleErrorandpageTitleMessageso the inline<div data-testid="general-pageTitle-error">renders when the field istouched && invalid.
- To Create:
tests/frontend/admin-general-page-title-error.spec.ts— pure-function unit tests covering the newpageTitleErrorbranches the component will rely on (already partially covered, but we extend them to lock down the specific "whitespace-only" →'required'message that drives the actionable UX).
(No backend changes, no schema migration, no new routes, no DTO changes.)
3. Proposed Changes
3.1 Backend / API
None. The PUT /api/v1/admin/general/settings route, DTO, and general.service.ts are unchanged. The server-side trim + 1–120 validation already returns 400 VALIDATION_FAILED and preserves the stored value — exactly the behavior the Job reports on the negative path. The bug is purely client-side rendering of the error message.
3.2 Frontend Logic — frontend/src/app/features/admin/general.component.ts
The root cause is that computed() does not re-evaluate when reactive-form control flags change. Replace the non-reactive reads with signal-backed local state that IS subscribed to valueChanges and statusChanges.
Concrete edits:
-
Add explicit reactive signals for the pageTitle control, declared at the top of the class alongside the other signals:
private readonly pageTitleValue = signal<string>(''); private readonly pageTitleInvalid = signal<boolean>(false); private readonly pageTitleTouchedOrDirty = signal<boolean>(false); -
Wire the control to those signals in the existing constructor, right after the
welcomeMarkdown.valueChangessubscription (using the sametakeUntilDestroyed(this.destroyRef)pattern already in the file):const pt = this.form.controls.pageTitle; this.pageTitleValue.set(pt.value); this.pageTitleInvalid.set(pt.invalid); pt.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((v) => this.pageTitleValue.set(v)); pt.statusChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.pageTitleInvalid.set(pt.invalid); this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); }); pt.events ?.pipe(takeUntilDestroyed(this.destroyRef)) ?.subscribe?.(); // no-op safety; see note belowNote: do NOT introduce a third subscription — the
blur/inputevents that fliptouched/dirtyare dispatched by the DOM and re-validate via Angular's value sync, which already emits astatusChanges(and, when the value changes, avalueChanges). Iftouched/dirtyflips without a status change (rare — e.g. focus loss while value is unchanged), also subscribe to the Angulareventsobservable filtered byEventType.Blur:import { EventType } from '@angular/forms'; ... pt.events .pipe( filter((e) => e.type === EventType.Blur), takeUntilDestroyed(this.destroyRef), ) .subscribe(() => this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty));Confirm with a quick grep that
EventTypeis exported from@angular/formsin the installed Angular version before relying on it; if not available in this version, fall back to afocusoutlistener bound via@HostListener('focusout')on a directive, OR simply callthis.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty)after everyvalueChangesANDstatusChangesemission — both reliably fire on input/blur in modern Angular reactive forms. Preferred approach (no extra imports): setpageTitleTouchedOrDirtyinside the existingstatusChangessubscription AND inside the existingvalueChangessubscription (covering both dirty/touched transitions). -
Replace the broken
computeds so they read from the new signals:readonly showPageTitleError = computed( () => this.pageTitleTouchedOrDirty() && this.pageTitleInvalid(), ); readonly pageTitleMessage = computed(() => { const err = pageTitleError(this.pageTitleValue()); if (err === 'required') return 'Page title is required and cannot contain only whitespace.'; if (err === 'maxlength') return 'Page title must be 120 characters or fewer.'; // fallback to the raw control errors so the {whitespace:true} branch from // the custom validator still surfaces the same human-readable text const controlErrors = this.form.controls.pageTitle.errors; if (controlErrors?.['whitespace']) return 'Page title is required and cannot contain only whitespace.'; return null; });The existing
pageTitleErrorhelper ingeneral.pure.ts:3-8already returns'required'for both empty and whitespace-only strings (line 5:if (v.trim().length === 0) return 'required';), so the actionable message will fire for'',' ','\t\n ', etc., exactly as the Job requires. -
Patch the form after load — extend the existing
applySettings()(line 250) to also callthis.pageTitleTouchedOrDirty.set(false)so loading valid settings from the backend does not flash the error on first paint. After patching the value, also syncthis.pageTitleValue(a singlept.setValue(...)will triggervalueChanges, which already updatespageTitleValue). -
No template change —
<div class="field-error" data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>at line 53 already lives inside the@if (showPageTitleError())block; once the computeds are reactive it will render with the actionable text and thedata-testidwill appear in the DOM (matching the negative-case expectation in the Job description).
3.3 Pure helper — frontend/src/app/features/admin/general.pure.ts
No changes needed. pageTitleError() already returns 'required' for both empty and whitespace-only strings (verified at line 5). Existing tests in tests/frontend/admin-general-pure.spec.ts:91-114 already lock down this behavior — we extend that suite, not the helper.
4. Test Strategy
Per the project's existing convention (all tests/frontend/*.spec.ts are pure-function tests against *.pure.ts modules), and per the Job's "minimal, focused tests" rule, we do not introduce a TestBed-based component test. The existing admin-general-pure.spec.ts is the right home.
-
Target test files:
-
Extend
tests/frontend/admin-general-pure.spec.tswith two newdescribeblocks:describe('pageTitleMessage')— pure-function mapping (the same mapping the component's computed now uses). Asserts:pageTitleMessage('')→ required messagepageTitleMessage(' ')→ required message (the Job's specific edge case)pageTitleMessage('\t\n ')→ required messagepageTitleMessage('OpenVelo')→nullpageTitleMessage('a'.repeat(121))→ maxlength message
- To keep this pure-function-friendly, extract the message-mapping function out of the component into
general.pure.tsasexport function pageTitleMessage(value, errors?): string | null, export it, and import it from the test. This is the minimal refactor that lets us assert the exact user-visible text without spinning up TestBed. The component'spageTitleMessagecomputedbecomes a one-liner that calls this pure function.
-
Add (new file)
tests/frontend/admin-general-page-title-error.spec.tsonly if the extracted helper's behavior isn't already covered by extendingadmin-general-pure.spec.ts. Per "minimal tests, single command", prefer extending the existing file to avoid duplicate test setup.
-
-
Mocking strategy: No mocks needed. The new pure function has no dependencies on
FormControl,HttpClient, or Angular DI. It is a pure(value: string, errors?: ValidationErrors | null) => string | nullmapping. The reactive plumbing inside the component is exercised manually per the Job description (no automated UI tests, per the Job rules). -
Run command:
npm test(from/repo) executesjest --config tests/jest.config.js --selectProjects frontend(and backend). Single command, no UI required, fully headless under jsdom.
5. Out of Scope / Non-Goals
- Backend DTO,
general.service.ts, controller, orsettingtable — all already correct. - Adding new validators (the existing
pageTitleNotBlankValidator+Validators.required+Validators.maxLength(120)are sufficient). - Visual / CSS changes — the existing
.field-errorstyle and red color tokens are already correct. - Component-harness / TestBed tests — explicitly out per Job rules (minimal infra, no visual confirmation).
- Refactoring other admin pages — not requested.