# NFR Design Patterns — Unit 3: frontend-cms-page ## Pattern 1 — Validation: Centralised Zod Schema Module **NFR**: NFR-FE-01, NFR-FE-13, NFR-FE-14 **Decision**: Q1 Other — all Zod form schemas live in `src/lib/schemas/` ### Rule Every form schema in the project is defined in `src/lib/schemas/{domain}.ts` and exported from that module. No schema is defined inline inside a component file. ### New files (this unit) **`src/lib/schemas/cms.ts`** ```typescript import { z } from 'zod'; export const addCmsInstanceSchema = z.object({ name: z.string().min(1, 'Name is required').max(255), url: z.string().url('Must be a valid URL (include http:// or https://)'), apiKey: z.string().min(1, 'API key is required'), }); export type AddCmsInstanceFormData = z.infer; export const setStatusSchema = z.object({ status: z.enum(['Available', 'NotAvailable', 'Inactive']), disableMessage: z.string().optional(), }).refine( (data) => data.status !== 'NotAvailable' || (data.disableMessage?.trim().length ?? 0) > 0, { message: 'Disable message is required when status is Not Available', path: ['disableMessage'] } ); export type SetStatusFormData = z.infer; ``` ### Migration (existing inline schema) `InviteUserDialog.tsx` currently defines `inviteSchema` inline. As part of this unit, migrate it to **`src/lib/schemas/users.ts`**: ```typescript import { z } from 'zod'; export const inviteUserSchema = z.object({ email: z.string().email(), role: z.enum(['Administrator', 'User']), }); export type InviteUserFormData = z.infer; ``` `InviteUserDialog.tsx` then imports from `@/lib/schemas/users` and removes the inline definition. ### Test files - `src/lib/schemas/cms.test.ts` — tests for `addCmsInstanceSchema` (URL strictness, DisableMessage refinement) and `setStatusSchema` - `src/lib/schemas/users.test.ts` — tests for `inviteUserSchema` (migrated from inline, no behaviour change) --- ## Pattern 2 — Error Handling: Local Server Error State **NFR**: NFR-FE-15 (field errors via `FieldError`), BR-FE-15/16 (FormErrorBanner for server errors) **Decision**: Q2 B — all forms use `useState(null)` for server-side error messages ### Rule Every form component that calls a mutation owns a `const [serverError, setServerError] = useState(null)` local state. The mutation's `onError` callback translates the error type to an i18n string and sets `serverError`. The `` receives `serverError`. This pattern is adopted **project-wide**, including the migration of `InviteUserDialog` (currently using `mutation.error` directly). ### Standard implementation ```typescript const [serverError, setServerError] = useState(null); const addInstance = useAddCmsInstance({ onError: (err) => { if (err instanceof ProblemDetailsError && err.status === 409) { setServerError(t('cms.add.errors.conflict')); } else if (err instanceof NetworkError) { setServerError(t('errors.network')); } else { setServerError(t('errors.generic')); } }, }); // Reset on dialog close useEffect(() => { if (!open) { setServerError(null); addInstance.reset(); reset(); } }, [open]); ``` ### FormErrorBanner usage ```tsx setServerError(null)} /> ``` ### Migration (existing InviteUserDialog) `InviteUserDialog` currently passes `inviteUser.error` directly to `FormErrorBanner`. Migrate to local `useState` + `onError` callback. Error message stays generic (`t('errors.generic')`) since invite errors are not status-code-specific. --- ## Pattern 3 — Form Lifecycle: Reset on Close **NFR**: NFR-FE-01 (consistent patterns) All dialog forms reset their full state (form values, server error, mutation state) when the dialog closes. Implemented via `useEffect` watching the `open` prop: ```typescript useEffect(() => { if (!open) { setServerError(null); mutation.reset(); reset(); // react-hook-form reset } }, [open]); ``` This pattern is already present in `InviteUserDialog` and is carried forward to `AddCmsInstanceDialog` and `SetStatusDialog`. --- ## Pattern 4 — Security: PasswordField for Credentials **NFR**: NFR-FE-07, NFR-FE-08 **Decision**: TSD-03 The `apiKey` field in `AddCmsInstanceDialog` uses the existing `PasswordField` component: ```tsx import { PasswordField } from '@/components/ui/PasswordField'; ``` `PasswordField` already renders `type="password"` with a show/hide toggle button. No new component needed. --- ## Pattern 5 — Cache Invalidation: Pessimistic Refetch **NFR**: NFR-FE-12 All mutations invalidate `['cmsInstances']` in `onSuccess` after the server confirms the operation. No optimistic updates. Consistent with `useUsers` / `useChangeRole` patterns in this project. ```typescript onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }), ```