Files
HIPCTF2/.kilo/plans/884.md
T

136 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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``showPageTitleError` is an Angular `computed()` that reads `this.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``pageTitleMessage` is a `computed()` that reads `this.form.controls.pageTitle.value` and `.errors`. Same problem: the computed is not subscribed to the form control's `valueChanges` / `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 is `ng-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 with `fb.nonNullable.group(...)`, `takeUntilDestroyed()` for RxJS → signal bridging. Validators and pure helpers live in sibling `*.pure.ts` files (e.g. `general.pure.ts`). `data-testid` attributes are the contract for every input, button, and status element.
- **Data Layer:** SQLite via `better-sqlite3` (per `package.json`); persisted key/value settings in the `setting` table. 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.ts` validators/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 test` runs both backend and frontend projects via `tests/jest.config.js`.
- **Required Tools & Dependencies:** None new. The fix uses Angular primitives (`signal`, `computed`, `toSignal` from `@angular/core/rxjs-interop`, `takeUntilDestroyed`) already present in the file. No `setup.sh` changes, no new packages.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/general.component.ts` — fix the reactivity gap for `showPageTitleError` and `pageTitleMessage` so the inline `<div data-testid="general-pageTitle-error">` renders when the field is `touched && invalid`.
- **To Create:**
- `tests/frontend/admin-general-page-title-error.spec.ts` — pure-function unit tests covering the new `pageTitleError` branches 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 + `1120` 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:
1. **Add explicit reactive signals for the pageTitle control**, declared at the top of the class alongside the other signals:
```ts
private readonly pageTitleValue = signal<string>('');
private readonly pageTitleInvalid = signal<boolean>(false);
private readonly pageTitleTouchedOrDirty = signal<boolean>(false);
```
2. **Wire the control to those signals** in the existing constructor, right after the `welcomeMarkdown.valueChanges` subscription (using the same `takeUntilDestroyed(this.destroyRef)` pattern already in the file):
```ts
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 below
```
Note: do NOT introduce a third subscription — the `blur`/`input` events that flip `touched`/`dirty` are dispatched by the DOM and re-validate via Angular's value sync, which already emits a `statusChanges` (and, when the value changes, a `valueChanges`). If `touched`/`dirty` flips without a status change (rare — e.g. focus loss while value is unchanged), also subscribe to the Angular `events` observable filtered by `EventType.Blur`:
```ts
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 `EventType` is exported from `@angular/forms` in the installed Angular version before relying on it; if not available in this version, fall back to a `focusout` listener bound via `@HostListener('focusout')` on a directive, OR simply call `this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty)` after every `valueChanges` AND `statusChanges` emission — both reliably fire on input/blur in modern Angular reactive forms. **Preferred approach (no extra imports):** set `pageTitleTouchedOrDirty` inside the existing `statusChanges` subscription AND inside the existing `valueChanges` subscription (covering both dirty/touched transitions).
3. **Replace the broken `computed`s** so they read from the new signals:
```ts
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 `pageTitleError` helper in `general.pure.ts:3-8` already 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.
4. **Patch the form after load** — extend the existing `applySettings()` (line 250) to also call `this.pageTitleTouchedOrDirty.set(false)` so loading valid settings from the backend does not flash the error on first paint. After patching the value, also sync `this.pageTitleValue` (a single `pt.setValue(...)` will trigger `valueChanges`, which already updates `pageTitleValue`).
5. **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 the `data-testid` will 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.ts` with two new `describe` blocks:
1. `describe('pageTitleMessage')` — pure-function mapping (the same mapping the component's computed now uses). Asserts:
- `pageTitleMessage('')` → required message
- `pageTitleMessage(' ')` → required message (the Job's specific edge case)
- `pageTitleMessage('\t\n ')` → required message
- `pageTitleMessage('OpenVelo')` → `null`
- `pageTitleMessage('a'.repeat(121))` → maxlength message
2. To keep this pure-function-friendly, **extract** the message-mapping function out of the component into `general.pure.ts` as `export 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's `pageTitleMessage` `computed` becomes a one-liner that calls this pure function.
- **Add (new file)** `tests/frontend/admin-general-page-title-error.spec.ts` only if the extracted helper's behavior isn't already covered by extending `admin-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 | null` mapping. 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`) executes `jest --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, or `setting` table — all already correct.
- Adding new validators (the existing `pageTitleNotBlankValidator` + `Validators.required` + `Validators.maxLength(120)` are sufficient).
- Visual / CSS changes — the existing `.field-error` style 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.