From 54706231974caf089e4a4ca422c83e6ee0cc3dca Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Wed, 22 Jul 2026 12:31:57 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/api/admin.md | 3 ++- docs/guides/admin-general-settings.md | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/api/admin.md b/docs/api/admin.md index b534c0c..445e7ce 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -41,7 +41,8 @@ removes the row. `PUT /settings` is validated by `GeneralSettingsSchema` (zod): -* `pageTitle` 1–120 chars +* `pageTitle` is trimmed server-side and must be 1–120 chars after + trimming (whitespace-only values are rejected). * `logo` up to 2048 chars (a public URL — uploaded separately) * `welcomeMarkdown` up to 64 000 chars * `themeKey` is one of `THEME_IDS` diff --git a/docs/guides/admin-general-settings.md b/docs/guides/admin-general-settings.md index b5442dc..b9feecf 100644 --- a/docs/guides/admin-general-settings.md +++ b/docs/guides/admin-general-settings.md @@ -38,7 +38,7 @@ The page is a single reactive form with these controls (every | Label | `data-testid` | Backend field | Notes | |------------------------|---------------------------|-------------------------|-------| -| Page title | `general-pageTitle` | `pageTitle` | Required, max 120 chars. | +| Page title | `general-pageTitle` | `pageTitle` | Required, non-blank, max 120 chars; trimmed server-side. | | Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. | | Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. | | Global theme | `general-themeKey` | `themeKey` | ` + @if (showPageTitleError()) { +
{{ pageTitleMessage() }}
+ } @@ -165,7 +175,7 @@ export class AdminGeneralComponent implements OnInit { readonly form = this.fb.nonNullable.group( { - pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]), + pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), logo: this.fb.nonNullable.control(''), welcomeMarkdown: this.fb.nonNullable.control(''), themeKey: this.fb.nonNullable.control('classic'), @@ -186,6 +196,26 @@ 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 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; + }); + constructor() { this.form.controls.welcomeMarkdown.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) @@ -208,6 +238,15 @@ export class AdminGeneralComponent implements OnInit { } } + private pageTitleNotBlankValidator(): (ctrl: { value: string | null }) => { whitespace: true } | null { + return (ctrl) => { + const value = ctrl?.value ?? ''; + if (value.length === 0) return null; + if (value.trim().length === 0) return { whitespace: true }; + return null; + }; + } + private applySettings(s: GeneralSettings): void { this.form.patchValue({ pageTitle: s.pageTitle, @@ -232,7 +271,7 @@ export class AdminGeneralComponent implements OnInit { try { const v = this.form.getRawValue(); const updated = await this.admin.updateGeneralSettings({ - pageTitle: v.pageTitle, + pageTitle: normalizePageTitle(v.pageTitle), logo: v.logo, welcomeMarkdown: v.welcomeMarkdown, themeKey: v.themeKey, diff --git a/frontend/src/app/features/admin/general.pure.ts b/frontend/src/app/features/admin/general.pure.ts index e0e95fc..56a346d 100644 --- a/frontend/src/app/features/admin/general.pure.ts +++ b/frontend/src/app/features/admin/general.pure.ts @@ -1,3 +1,16 @@ +export type PageTitleError = 'required' | 'maxlength' | null; + +export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError { + const v = value ?? ''; + if (v.trim().length === 0) return 'required'; + if (v.length > maxLength) return 'maxlength'; + return null; +} + +export function normalizePageTitle(value: string): string { + return value.trim(); +} + export type EventDerivedState = 'running' | 'countdown' | 'stopped' | 'unconfigured'; export function deriveEventState(start: string, end: string): EventDerivedState { diff --git a/tests/backend/admin-general-service.spec.ts b/tests/backend/admin-general-service.spec.ts index a204a82..8f37606 100644 --- a/tests/backend/admin-general-service.spec.ts +++ b/tests/backend/admin-general-service.spec.ts @@ -56,6 +56,37 @@ describe('GeneralSettingsSchema - validation rules', () => { }); expect(r.success).toBe(true); }); + + it('rejects a whitespace-only pageTitle', () => { + const r = GeneralSettingsSchema.safeParse({ + pageTitle: ' ', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: '2026-01-01T00:00:00Z', + eventEndUtc: '2026-02-01T00:00:00Z', + defaultChallengeIp: '127.0.0.1', + registrationsEnabled: false, + }); + expect(r.success).toBe(false); + }); + + it('trims surrounding whitespace from a valid pageTitle', () => { + const r = GeneralSettingsSchema.safeParse({ + pageTitle: ' OpenVelo ', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: '2026-01-01T00:00:00Z', + eventEndUtc: '2026-02-01T00:00:00Z', + defaultChallengeIp: '127.0.0.1', + registrationsEnabled: false, + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.pageTitle).toBe('OpenVelo'); + } + }); }); describe('AdminGeneralService.updateSettings - happy path', () => { diff --git a/tests/frontend/admin-general-pure.spec.ts b/tests/frontend/admin-general-pure.spec.ts index 2c34f85..545f995 100644 --- a/tests/frontend/admin-general-pure.spec.ts +++ b/tests/frontend/admin-general-pure.spec.ts @@ -1,4 +1,11 @@ -import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from '../../frontend/src/app/features/admin/general.pure'; +import { + deriveEventState, + endAfterStartValidator, + normalizePageTitle, + pageTitleError, + toDatetimeLocal, + toIsoUtc, +} from '../../frontend/src/app/features/admin/general.pure'; describe('deriveEventState', () => { const start = '2026-01-01T00:00:00Z'; @@ -80,3 +87,42 @@ describe('datetime helpers', () => { expect(toIsoUtc('')).toBe(''); }); }); + +describe('pageTitleError', () => { + it('returns "required" for empty input', () => { + expect(pageTitleError('')).toBe('required'); + expect(pageTitleError(null)).toBe('required'); + expect(pageTitleError(undefined)).toBe('required'); + }); + + it('returns "required" for whitespace-only input', () => { + expect(pageTitleError(' ')).toBe('required'); + expect(pageTitleError('\t\n ')).toBe('required'); + }); + + it('returns null for a valid title', () => { + expect(pageTitleError('OpenVelo')).toBeNull(); + }); + + it('returns "maxlength" when the raw string exceeds 120 characters', () => { + expect(pageTitleError('a'.repeat(121))).toBe('maxlength'); + }); + + it('returns null when surrounding whitespace keeps the trimmed length within bounds', () => { + expect(pageTitleError(' OpenVelo ')).toBeNull(); + }); +}); + +describe('normalizePageTitle', () => { + it('returns an empty string for empty input', () => { + expect(normalizePageTitle('')).toBe(''); + }); + + it('returns an empty string for whitespace-only input', () => { + expect(normalizePageTitle(' ')).toBe(''); + }); + + it('trims surrounding whitespace from a valid title', () => { + expect(normalizePageTitle(' OpenVelo ')).toBe('OpenVelo'); + }); +}); -- 2.52.0