AI Implementation feature(884): Admin Area General Settings and Categories 1.02 (#24)

This commit was merged in pull request #24.
This commit is contained in:
2026-07-22 12:45:45 +00:00
parent 0a2b2664b4
commit b7e0f3c608
7 changed files with 252 additions and 52 deletions
-27
View File
@@ -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 1120 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 1120-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`.
+136
View File
@@ -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 `<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.
+35 -5
View File
@@ -3,7 +3,7 @@ type: guide
title: Admin — General Settings 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. 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] tags: [guide, admin, settings, general, tester]
timestamp: 2026-07-22T12:00:00Z timestamp: 2026-07-22T12:44:45Z
--- ---
# When this view is available # 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 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 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`. | | 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) # 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 never issued. The server trims surrounding whitespace and re-validates
against the same 1120 character rule, so invalid payloads return against the same 1120 character rule, so invalid payloads return
`400 VALIDATION_FAILED` and the stored Page title is unchanged. `400 VALIDATION_FAILED` and the stored Page title is unchanged.
On success the form is patched with the response, On success the form is patched with the response, the Page-title
`general-save-ok` renders briefly, and the backend emits an SSE control is marked untouched/pristine (so a freshly-loaded valid
`general` event via `SseHubService` so other tabs refresh their theme. 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 * **Error states:** load failures render `general-error`; save failures
render `general-save-error` with the `error.message` (or render `general-save-error` with the `error.message` (or
`error.error.message`) from the standard envelope. `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 # Visual elements
| Element | Selector | | Element | Selector |
@@ -98,6 +127,7 @@ The page is a single reactive form with these controls (every
| Form | `[data-testid="general-form"]` | | Form | `[data-testid="general-form"]` |
| Welcome preview | `[data-testid="general-welcome-preview"]` | | Welcome preview | `[data-testid="general-welcome-preview"]` |
| End-before-start message | `[data-testid="general-endBeforeStart"]` | | End-before-start message | `[data-testid="general-endBeforeStart"]` |
| Page-title inline error | `[data-testid="general-pageTitle-error"]` |
# Architecture map # Architecture map
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice 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 # Architecture
@@ -9,7 +9,7 @@ import {
deriveEventState, deriveEventState,
endAfterStartValidator, endAfterStartValidator,
normalizePageTitle, normalizePageTitle,
pageTitleError, pageTitleMessage,
toDatetimeLocal, toDatetimeLocal,
toIsoUtc, toIsoUtc,
} from './general.pure'; } from './general.pure';
@@ -173,6 +173,10 @@ export class AdminGeneralComponent implements OnInit {
readonly uploadingLogo = signal(false); readonly uploadingLogo = signal(false);
readonly logoUploadError = signal<string | null>(null); readonly logoUploadError = signal<string | null>(null);
private readonly pageTitleValue = signal<string>('');
private readonly pageTitleInvalid = signal<boolean>(false);
private readonly pageTitleTouchedOrDirty = signal<boolean>(false);
readonly form = this.fb.nonNullable.group( readonly form = this.fb.nonNullable.group(
{ {
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
@@ -196,30 +200,35 @@ export class AdminGeneralComponent implements OnInit {
return state.toUpperCase(); return state.toUpperCase();
}); });
readonly showPageTitleError = computed(() => { readonly showPageTitleError = computed(
const c = this.form.controls.pageTitle; () => this.pageTitleTouchedOrDirty() && this.pageTitleInvalid(),
return (c.touched || c.dirty) && c.invalid; );
});
readonly pageTitleMessage = computed(() => { readonly pageTitleMessage = computed(() =>
const err = pageTitleError(this.form.controls.pageTitle.value); pageTitleMessage(this.pageTitleValue(), this.form.controls.pageTitle.errors),
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;
});
constructor() { constructor() {
this.form.controls.welcomeMarkdown.valueChanges this.form.controls.welcomeMarkdown.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => this.previewHtml.set(this.markdown.render(v))); .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<void> { async ngOnInit(): Promise<void> {
@@ -258,6 +267,9 @@ export class AdminGeneralComponent implements OnInit {
defaultChallengeIp: s.defaultChallengeIp, defaultChallengeIp: s.defaultChallengeIp,
registrationsEnabled: s.registrationsEnabled, registrationsEnabled: s.registrationsEnabled,
}); });
this.form.controls.pageTitle.markAsUntouched();
this.form.controls.pageTitle.markAsPristine();
this.pageTitleTouchedOrDirty.set(false);
} }
async onSubmit(): Promise<void> { async onSubmit(): Promise<void> {
@@ -7,6 +7,20 @@ export function pageTitleError(value: string | null | undefined, maxLength = 120
return null; 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 { export function normalizePageTitle(value: string): string {
return value.trim(); return value.trim();
} }
+35
View File
@@ -3,6 +3,7 @@ import {
endAfterStartValidator, endAfterStartValidator,
normalizePageTitle, normalizePageTitle,
pageTitleError, pageTitleError,
pageTitleMessage,
toDatetimeLocal, toDatetimeLocal,
toIsoUtc, toIsoUtc,
} from '../../frontend/src/app/features/admin/general.pure'; } from '../../frontend/src/app/features/admin/general.pure';
@@ -126,3 +127,37 @@ describe('normalizePageTitle', () => {
expect(normalizePageTitle(' OpenVelo ')).toBe('OpenVelo'); 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);
});
});