Files
slp-modular-cms/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/nfr-design-patterns.md
T
2026-06-30 23:23:50 +02:00

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 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<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'] }),