5.2 KiB
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
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<typeof addCmsInstanceSchema>;
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<typeof setStatusSchema>;
Migration (existing inline schema)
InviteUserDialog.tsx currently defines inviteSchema inline. As part of this unit, migrate it to src/lib/schemas/users.ts:
import { z } from 'zod';
export const inviteUserSchema = z.object({
email: z.string().email(),
role: z.enum(['Administrator', 'User']),
});
export type InviteUserFormData = z.infer<typeof inviteUserSchema>;
InviteUserDialog.tsx then imports from @/lib/schemas/users and removes the inline definition.
Test files
src/lib/schemas/cms.test.ts— tests foraddCmsInstanceSchema(URL strictness, DisableMessage refinement) andsetStatusSchemasrc/lib/schemas/users.test.ts— tests forinviteUserSchema(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<string | null>(null) for server-side error messages
Rule
Every form component that calls a mutation owns a const [serverError, setServerError] = useState<string | null>(null) local state. The mutation's onError callback translates the error type to an i18n string and sets serverError. The <FormErrorBanner> receives serverError.
This pattern is adopted project-wide, including the migration of InviteUserDialog (currently using mutation.error directly).
Standard implementation
const [serverError, setServerError] = useState<string | null>(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
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
Migration (existing InviteUserDialog)
InviteUserDialog currently passes inviteUser.error directly to FormErrorBanner. Migrate to local useState<string | null> + 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:
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:
import { PasswordField } from '@/components/ui/PasswordField';
<PasswordField
id="add-cms-apikey"
label={t('cms.add.apiKeyLabel')}
data-testid="add-cms-apikey"
aria-invalid={errors.apiKey !== undefined}
{...register('apiKey')}
/>
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.
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),