diff --git a/.kilo/plans/883.md b/.kilo/plans/883.md deleted file mode 100644 index f29c29a..0000000 --- a/.kilo/plans/883.md +++ /dev/null @@ -1,27 +0,0 @@ -# Implementation Plan: Admin Area General Settings and Categories 1.01 - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** TypeScript monorepo with a NestJS REST API and an Angular 17 standalone-component SPA. The General page is an OnPush standalone component using a non-nullable reactive form, signals for request state, and a typed `AdminService`; server request validation uses Zod through `ZodValidationPipe`, while controllers delegate persistence to services. The affected flow is `frontend/src/app/features/admin/general.component.ts` → `AdminService.updateGeneralSettings()` → `AdminGeneralController.updateSettings()` → `GeneralSettingsSchema` → `AdminGeneralService.updateSettings()` → `SettingsService`, with `/api/v1/bootstrap` subsequently exposing the stored `pageTitle` to `HomeComponent` and `ShellHeaderComponent`. -- **Data Layer:** SQLite via TypeORM. General settings are key/value rows in the `setting` table; `pageTitle` is stored under `SETTINGS_KEYS.PAGE_TITLE`. No schema or migration change is required. Invalid requests must be rejected before `AdminGeneralService.updateSettings()` executes, ensuring the existing row remains unchanged. -- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`: Node for `tests/backend/**/*.spec.ts` and jsdom for `tests/frontend/**/*.spec.ts`. Tests are kept in the dedicated `/repo/tests` tree and all run from the repository root with `npm test`; focused projects can run with `npm run test:frontend` and `npm run test:backend`. Add minimal pure frontend validation coverage and extend the existing backend schema/service test rather than introducing browser/UI infrastructure. -- **Required Tools & Dependencies:** No new system tools, global CLIs, package dependencies, persistent `/data` fixtures, or `setup.sh` changes are required. Existing Angular Forms, Zod, Jest, and ts-jest cover the implementation and tests. Verification should use `npm test` and `npm run build`; the repository has no lint script or dedicated typecheck script, and the build scripts perform TypeScript/Angular compilation. - -## 2. Impacted Files -- **To Modify:** - - `frontend/src/app/features/admin/general.pure.ts` — add a small typed whitespace-aware page-title validator/helper that can be shared by the reactive form and tested without Angular UI setup. - - `frontend/src/app/features/admin/general.component.ts` — apply the page-title validator, render actionable field-level feedback, normalize the submitted title, and keep invalid forms from invoking the API. - - `backend/src/modules/admin/dto/general.dto.ts` — trim `pageTitle` before enforcing the existing 1–120 character contract so whitespace-only input is rejected and valid surrounding whitespace is not persisted. - - `tests/frontend/admin-general-pure.spec.ts` — cover empty/whitespace rejection and valid-title normalization/acceptance using the pure helper. - - `tests/backend/admin-general-service.spec.ts` — cover schema rejection of whitespace-only titles and schema output trimming for a valid title, proving invalid payloads cannot reach persistence. - - `docs/guides/admin-general-settings.md` — update the Page Title contract and expected save behavior to document trimming, inline validation feedback, and preservation of the stored value on invalid input. - - `docs/api/admin.md` — document the server contract as a trimmed, non-empty 1–120-character page title. -- **To Create:** None. - -## 3. Proposed Changes -1. **Database / Schema Migration:** No migration is needed. Continue using the existing `setting` row and `SettingsService.set`; rely on validation occurring before the service call so empty or whitespace-only requests perform no writes and the prior `pageTitle` remains available from both admin settings and `/api/v1/bootstrap`. -2. **Backend Logic & APIs:** Change `GeneralSettingsSchema.pageTitle` from a raw `z.string().min(1).max(120)` to a trimmed string contract with the same bounds. Because `ZodValidationPipe` passes `parsed.data` onward, valid values such as `' OpenVelo '` will reach `AdminGeneralService.updateSettings()` as `'OpenVelo'`, while `''` and `' '` will produce the existing `400 VALIDATION_FAILED` envelope before any `Promise.all` persistence or SSE emission. Keep endpoint paths, response types, error envelope, settings service, and bootstrap logic unchanged. -3. **Frontend UI Integration:** Add a standalone page-title validator/helper in `general.pure.ts` that treats `value.trim().length === 0` as invalid while preserving Angular's max-length validation. Attach it to the non-nullable `pageTitle` control in `AdminGeneralComponent`, show an inline field error beneath `general-pageTitle` when the control is touched/dirty and either required/whitespace-invalid, and use an actionable message such as “Page title is required and cannot contain only whitespace.” Keep the existing disabled condition (`form.invalid`) and `onSubmit()` invalid guard/`markAllAsTouched()` so no PUT is issued for invalid values. For valid submissions, trim `v.pageTitle` before calling `AdminService.updateGeneralSettings()` so the immediate SPA request agrees with the server normalization. Clear stale save success/error state when a new submission begins as already done; only display `Saved.` after a successful response and patch the normalized response back into the form. No shell-header fallback change is needed because invalid titles will no longer be persisted. - -## 4. Test Strategy -- **Target Unit Test File:** Extend `tests/frontend/admin-general-pure.spec.ts` with minimal tests for the page-title helper/validator: whitespace-only input is invalid, a normal title is valid, and valid surrounding whitespace normalizes to the intended persisted value. Extend `tests/backend/admin-general-service.spec.ts` under `GeneralSettingsSchema - validation rules` with one rejection assertion for `' '` and one successful parse assertion proving `' OpenVelo '` becomes `'OpenVelo'`. These tests directly guard both client pre-validation and the authoritative server boundary without a visual/browser test. -- **Mocking Strategy:** Frontend tests invoke only the pure helper with simple typed control-like values, so no TestBed, DOM interaction, or HTTP mock is needed. Backend schema tests use `safeParse` directly, so no Nest application or database is needed; the existing `AdminGeneralService` fake settings/hub test remains unchanged. This keeps tests fast and isolated while proving that invalid input never produces data suitable for persistence. Implement in TDD order: add the focused failing tests, verify the expected failures, make the minimal production changes, then run `npm test` and `npm run build` from `/repo`. diff --git a/.kilo/plans/884.md b/.kilo/plans/884.md new file mode 100644 index 0000000..a02fca5 --- /dev/null +++ b/.kilo/plans/884.md @@ -0,0 +1,136 @@ +# 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 `
{{ pageTitleMessage() }}
` 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 `
` 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 + `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: + +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(''); + private readonly pageTitleInvalid = signal(false); + private readonly pageTitleTouchedOrDirty = signal(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** — `
{{ pageTitleMessage() }}
` 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. \ No newline at end of file diff --git a/docs/guides/admin-general-settings.md b/docs/guides/admin-general-settings.md index b9feecf..d2a6ccb 100644 --- a/docs/guides/admin-general-settings.md +++ b/docs/guides/admin-general-settings.md @@ -3,7 +3,7 @@ type: guide title: Admin — General Settings description: How an admin edits global platform settings (page title, logo, theme, event window, default challenge IP, registrations, welcome Markdown) from the /admin/general page. tags: [guide, admin, settings, general, tester] -timestamp: 2026-07-22T12:00:00Z +timestamp: 2026-07-22T12:44:45Z --- # When this view is available @@ -17,7 +17,7 @@ by navigating directly to the URL. |------------------|----------------------------------------------------------------------------|------------------------------------------------| | Client route | `frontend/src/app/app.routes.ts` | `/admin/general` child of `adminGuard`. | | Client component | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent.ngOnInit` fetches settings + themes. | -| Client predicate | `frontend/src/app/features/admin/general.pure.ts` (`deriveEventState`) | Computes the read-only event-state label. | +| Client predicate | `frontend/src/app/features/admin/general.pure.ts` (`deriveEventState`, `pageTitleError`, `pageTitleMessage`) | Pure helpers for event-state derivation and Page-title error/message mapping. | | Server route | `backend/src/modules/admin/admin-general.controller.ts` | `GET/PUT /api/v1/admin/general/settings` + `GET /api/v1/admin/general/themes`. | # How to access (tester steps) @@ -81,13 +81,42 @@ The page is a single reactive form with these controls (every never issued. The server trims surrounding whitespace and re-validates against the same 1–120 character rule, so invalid payloads return `400 VALIDATION_FAILED` and the stored Page title is unchanged. - On success the form is patched with the response, - `general-save-ok` renders briefly, and the backend emits an SSE - `general` event via `SseHubService` so other tabs refresh their theme. + On success the form is patched with the response, the Page-title + control is marked untouched/pristine (so a freshly-loaded valid + form does not flash an error), `general-save-ok` renders briefly, and + the backend emits an SSE `general` event via `SseHubService` so + other tabs refresh their theme. * **Error states:** load failures render `general-error`; save failures render `general-save-error` with the `error.message` (or `error.error.message`) from the standard envelope. +# Page-title inline error message + +The inline error message under the Page-title field +(`general-pageTitle-error`) is driven by the pure helper +`pageTitleMessage(value, controlErrors, maxLength = 120)` exported from +`frontend/src/app/features/admin/general.pure.ts:10-22`. The component +keeps three local `signal`s — `pageTitleValue`, `pageTitleInvalid`, +`pageTitleTouchedOrDirty` — and subscribes to the reactive form +control's `valueChanges` and `statusChanges` (via +`takeUntilDestroyed(this.destroyRef)`) so the `computed`s for +`showPageTitleError` and `pageTitleMessage` re-evaluate when the user +types or blurs the input. The helper returns: + +| Condition | Message | +|--------------------------------------------------------------|-----------------------------------------------------------| +| Empty or whitespace-only value | `Page title is required and cannot contain only whitespace.` | +| Value length > 120 | `Page title must be 120 characters or fewer.` | +| Control has `required`, `maxlength`, or `whitespace` error | Matching human-readable message (whitespace falls back to "required" wording). | +| Valid value | `null` (no message rendered, Save becomes enabled). | + +As a result, the `general-pageTitle-error` element renders as soon as +the Page-title control becomes both `touched || dirty` AND `invalid`, +without requiring a full component re-render. After a successful +Save, the control is reset to untouched/pristine and the +`pageTitleTouchedOrDirty` signal is cleared, so reloading valid +settings does not flash a stale error. + # Visual elements | Element | Selector | @@ -98,6 +127,7 @@ The page is a single reactive form with these controls (every | Form | `[data-testid="general-form"]` | | Welcome preview | `[data-testid="general-welcome-preview"]` | | End-before-start message | `[data-testid="general-endBeforeStart"]` | +| Page-title inline error | `[data-testid="general-pageTitle-error"]` | # Architecture map diff --git a/docs/index.md b/docs/index.md index 79602aa..e56426b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-22T10:32:00Z. +they need. Last regenerated 2026-07-22T12:44:45Z. # Architecture diff --git a/frontend/src/app/features/admin/general.component.ts b/frontend/src/app/features/admin/general.component.ts index 3b8f251..643545b 100644 --- a/frontend/src/app/features/admin/general.component.ts +++ b/frontend/src/app/features/admin/general.component.ts @@ -9,7 +9,7 @@ import { deriveEventState, endAfterStartValidator, normalizePageTitle, - pageTitleError, + pageTitleMessage, toDatetimeLocal, toIsoUtc, } from './general.pure'; @@ -173,6 +173,10 @@ export class AdminGeneralComponent implements OnInit { readonly uploadingLogo = signal(false); readonly logoUploadError = signal(null); + private readonly pageTitleValue = signal(''); + private readonly pageTitleInvalid = signal(false); + private readonly pageTitleTouchedOrDirty = signal(false); + readonly form = this.fb.nonNullable.group( { pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), @@ -196,30 +200,35 @@ export class AdminGeneralComponent implements OnInit { return state.toUpperCase(); }); - readonly showPageTitleError = computed(() => { - const c = this.form.controls.pageTitle; - return (c.touched || c.dirty) && c.invalid; - }); + readonly showPageTitleError = computed( + () => this.pageTitleTouchedOrDirty() && this.pageTitleInvalid(), + ); - readonly pageTitleMessage = computed(() => { - const err = pageTitleError(this.form.controls.pageTitle.value); - 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.'; - } - const controlErrors = this.form.controls.pageTitle.errors; - if (controlErrors?.['required']) return 'Page title is required and cannot contain only whitespace.'; - if (controlErrors?.['maxlength']) return 'Page title must be 120 characters or fewer.'; - if (controlErrors?.['whitespace']) return 'Page title is required and cannot contain only whitespace.'; - return null; - }); + readonly pageTitleMessage = computed(() => + pageTitleMessage(this.pageTitleValue(), this.form.controls.pageTitle.errors), + ); constructor() { this.form.controls.welcomeMarkdown.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((v) => this.previewHtml.set(this.markdown.render(v))); + + const pt = this.form.controls.pageTitle; + this.pageTitleValue.set(pt.value); + this.pageTitleInvalid.set(pt.invalid); + this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); + pt.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.pageTitleValue.set(v); + this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); + }); + pt.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.pageTitleInvalid.set(pt.invalid); + this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); + }); } async ngOnInit(): Promise { @@ -258,6 +267,9 @@ export class AdminGeneralComponent implements OnInit { defaultChallengeIp: s.defaultChallengeIp, registrationsEnabled: s.registrationsEnabled, }); + this.form.controls.pageTitle.markAsUntouched(); + this.form.controls.pageTitle.markAsPristine(); + this.pageTitleTouchedOrDirty.set(false); } async onSubmit(): Promise { diff --git a/frontend/src/app/features/admin/general.pure.ts b/frontend/src/app/features/admin/general.pure.ts index 56a346d..6a44b06 100644 --- a/frontend/src/app/features/admin/general.pure.ts +++ b/frontend/src/app/features/admin/general.pure.ts @@ -7,6 +7,20 @@ export function pageTitleError(value: string | null | undefined, maxLength = 120 return null; } +export function pageTitleMessage( + value: string | null | undefined, + controlErrors: { [key: string]: unknown } | null | undefined, + maxLength = 120, +): string | null { + const err = pageTitleError(value, maxLength); + 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.'; + if (controlErrors?.['required']) return 'Page title is required and cannot contain only whitespace.'; + if (controlErrors?.['maxlength']) return 'Page title must be 120 characters or fewer.'; + if (controlErrors?.['whitespace']) return 'Page title is required and cannot contain only whitespace.'; + return null; +} + export function normalizePageTitle(value: string): string { return value.trim(); } diff --git a/tests/frontend/admin-general-pure.spec.ts b/tests/frontend/admin-general-pure.spec.ts index 545f995..e5c5df2 100644 --- a/tests/frontend/admin-general-pure.spec.ts +++ b/tests/frontend/admin-general-pure.spec.ts @@ -3,6 +3,7 @@ import { endAfterStartValidator, normalizePageTitle, pageTitleError, + pageTitleMessage, toDatetimeLocal, toIsoUtc, } from '../../frontend/src/app/features/admin/general.pure'; @@ -126,3 +127,37 @@ describe('normalizePageTitle', () => { expect(normalizePageTitle(' OpenVelo ')).toBe('OpenVelo'); }); }); + +describe('pageTitleMessage', () => { + const requiredMsg = 'Page title is required and cannot contain only whitespace.'; + const maxLengthMsg = 'Page title must be 120 characters or fewer.'; + + it('returns the required message for empty input', () => { + expect(pageTitleMessage('', null)).toBe(requiredMsg); + }); + + it('returns the required message for whitespace-only input', () => { + expect(pageTitleMessage(' ', null)).toBe(requiredMsg); + expect(pageTitleMessage('\t\n ', null)).toBe(requiredMsg); + }); + + it('returns null for a valid title', () => { + expect(pageTitleMessage('OpenVelo', null)).toBeNull(); + }); + + it('returns the maxlength message when the raw string exceeds 120 characters', () => { + expect(pageTitleMessage('a'.repeat(121), null)).toBe(maxLengthMsg); + }); + + it('surfaces the whitespace validator error when control errors are provided', () => { + expect(pageTitleMessage(' ', { whitespace: true })).toBe(requiredMsg); + }); + + it('surfaces the required control error', () => { + expect(pageTitleMessage('', { required: true })).toBe(requiredMsg); + }); + + it('surfaces the maxlength control error', () => { + expect(pageTitleMessage('a'.repeat(121), { maxlength: { requiredLength: 120 } })).toBe(maxLengthMsg); + }); +});