diff --git a/aidlc-docs/features/master-cms-module/aidlc-state.md b/aidlc-docs/features/master-cms-module/aidlc-state.md index 9bc9c00..72d1b03 100644 --- a/aidlc-docs/features/master-cms-module/aidlc-state.md +++ b/aidlc-docs/features/master-cms-module/aidlc-state.md @@ -5,7 +5,7 @@ - **Feature Slug**: master-cms-module - **Project Type**: Brownfield - **Start Date**: 2026-06-26T00:00:00Z -- **Current Stage**: CONSTRUCTION - Unit 2 (slave-availability-extension) Code Generation +- **Current Stage**: CONSTRUCTION - Unit 3 (frontend-cms-page) Code Generation Complete - **Branch**: unknown ## Workspace State @@ -51,6 +51,10 @@ - [x] NFR Requirements — Complete (Unit 2) - [x] NFR Design — Complete (Unit 2) - [x] Code Generation — Complete (Unit 2) +- [x] Functional Design — Complete (Unit 3) +- [x] NFR Requirements — Complete (Unit 3) +- [x] NFR Design — Complete (Unit 3) +- [x] Code Generation — Complete (Unit 3) - [ ] Infrastructure Design — Skipped - [ ] Code Generation — Execute (per unit) - [ ] Build and Test — Execute diff --git a/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/logical-components.md b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/logical-components.md new file mode 100644 index 0000000..777fdcd --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/logical-components.md @@ -0,0 +1,116 @@ +# Logical Components — Unit 3: frontend-cms-page + +## Overview + +``` +src/lib/schemas/ +├── auth.ts (existing — unchanged) +├── users.ts (NEW — migrated from InviteUserDialog inline) +└── cms.ts (NEW — addCmsInstanceSchema, setStatusSchema) + +src/api/ +├── types.ts (extended — CmsInstance, CmsInstanceStatus, CreateCmsInstanceRequest, +│ UpdateCmsInstanceStatusRequest, UpdateStatusResult) +├── useCmsInstances.ts (NEW) +├── useAddCmsInstance.ts (NEW) +└── useUpdateCmsInstanceStatus.ts (NEW) + +src/components/cms/ +├── CmsInstanceList.tsx (NEW) +├── AddCmsInstanceDialog.tsx (NEW) +└── SetStatusDialog.tsx (NEW) + +src/components/users/ +└── InviteUserDialog.tsx (MODIFIED — schema import + serverError migration) + +src/pages/ +└── CmsPage.tsx (MODIFIED — replace placeholder with full page) + +src/i18n/locales/ +├── en/translation.json (MODIFIED — cms.* keys added) +└── nl/translation.json (MODIFIED — cms.* keys added) + +src/mocks/ +├── cms/handlers.ts (NEW — GET, POST, PUT MSW handlers) +├── browser.ts (MODIFIED — register cms handlers) +└── server.ts (MODIFIED — register cms handlers) + +Test files: +├── src/lib/schemas/cms.test.ts (NEW) +├── src/lib/schemas/users.test.ts (NEW) +├── src/api/useCmsInstances.test.ts (NEW) +├── src/api/useAddCmsInstance.test.ts (NEW) +├── src/api/useUpdateCmsInstanceStatus.test.ts (NEW) +├── src/components/cms/AddCmsInstanceDialog.test.tsx (NEW) +├── src/components/cms/SetStatusDialog.test.tsx (NEW) +└── src/pages/CmsPage.test.tsx (MODIFIED — replace placeholder assertions) +``` + +--- + +## Component Responsibilities + +### `src/lib/schemas/cms.ts` — Validation Logic + +| Export | Validates | +|--------|-----------| +| `addCmsInstanceSchema` | name (required, max 255), url (z.string().url()), apiKey (required) | +| `AddCmsInstanceFormData` | Inferred type | +| `setStatusSchema` | status (enum), disableMessage (required when NotAvailable via `.refine()`) | +| `SetStatusFormData` | Inferred type | + +### `src/lib/schemas/users.ts` — Migrated Schema + +| Export | Source | +|--------|--------| +| `inviteUserSchema` | Migrated from `InviteUserDialog.tsx` inline — no behaviour change | +| `InviteUserFormData` | Inferred type | + +### `src/api/useCmsInstances.ts` — Query Hook + +| Concern | Implementation | +|---------|---------------| +| Endpoint | `GET /api/v1/CmsInstances` | +| Query key | `['cmsInstances']` | +| Stale time | 30 000 ms | +| Return type | `UseQueryResult` | + +### `src/api/useAddCmsInstance.ts` — Mutation Hook + +| Concern | Implementation | +|---------|---------------| +| Endpoint | `POST /api/v1/CmsInstances` | +| Body | `CreateCmsInstanceRequest` | +| On success | `invalidateQueries(['cmsInstances'])` | +| Accepts | Optional `onError` callback (caller sets `serverError` state) | + +### `src/api/useUpdateCmsInstanceStatus.ts` — Mutation Hook + +| Concern | Implementation | +|---------|---------------| +| Endpoint | `PUT /api/v1/CmsInstances/{id}/status` | +| Variables | `{ id: string } & UpdateCmsInstanceStatusRequest` | +| Return | `UpdateStatusResult` | +| On success | `invalidateQueries(['cmsInstances'])` | +| Accepts | Optional `onSuccess` / `onError` callbacks | + +### `src/mocks/cms/handlers.ts` — MSW Handlers + +| Handler | Behaviour | +|---------|-----------| +| `GET /api/v1/CmsInstances` | Returns in-memory `cmsInstances[]` array | +| `POST /api/v1/CmsInstances` | Pushes new instance to array; returns 201 + created instance | +| `PUT /api/v1/CmsInstances/:id/status` | Updates instance in array; returns `UpdateStatusResult` | + +In-memory state initialised with 1-2 seed instances to support development and test scenarios. + +--- + +## Migration Summary + +| File | Change | Reason | +|------|--------|--------| +| `InviteUserDialog.tsx` | Import schema from `@/lib/schemas/users` instead of inline | Pattern 1 (centralised schemas) | +| `InviteUserDialog.tsx` | Replace `inviteUser.error` with `useState` | Pattern 2 (consistent error handling) | +| `src/lib/schemas/users.ts` | New file — migrated `inviteSchema` | Pattern 1 | +| `src/lib/schemas/users.test.ts` | New file — tests for `inviteUserSchema` | NFR-FE-04 | diff --git a/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/nfr-design-patterns.md b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/nfr-design-patterns.md new file mode 100644 index 0000000..b6d8d27 --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-design/nfr-design-patterns.md @@ -0,0 +1,156 @@ +# 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'] }), +``` diff --git a/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/nfr-requirements.md b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/nfr-requirements.md new file mode 100644 index 0000000..779337f --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/nfr-requirements.md @@ -0,0 +1,41 @@ +# NFR Requirements — Unit 3: frontend-cms-page + +## Maintainability + +| ID | Requirement | Rationale | +|----|-------------|-----------| +| NFR-FE-01 | All new components use `react-hook-form` + `zodResolver` + Zod schemas, consistent with `InviteUserDialog` and `LoginPage` patterns in this project. | Single validation strategy across the entire frontend. | +| NFR-FE-02 | All user-visible strings are externalised in `src/i18n/locales/{lang}/translation.json` under the `cms.*` namespace. No hardcoded UI text in components. | Enables future language additions without code changes. | +| NFR-FE-03 | Components follow the existing Shadcn/UI composition pattern: `Dialog`, `Table`, `Select`, `Badge`, `DropdownMenu` from `@/components/ui/*`. No third-party UI additions. | Keeps the component surface consistent and avoids dependency sprawl. | + +## Testability + +| ID | Requirement | Rationale | +|----|-------------|-----------| +| NFR-FE-04 | Test scope: **hooks + page integration + individual component tests** (Q2: C). Required test files: `useCmsInstances.test.ts`, `useAddCmsInstance.test.ts`, `useUpdateCmsInstanceStatus.test.ts`, `CmsPage.test.tsx`, `AddCmsInstanceDialog.test.tsx`, `SetStatusDialog.test.tsx`. | Matches precedent set by `InviteUserDialog.test.tsx`. Isolated component tests cover error states and conditional logic (DisableMessage) without needing a full page mount. | +| NFR-FE-05 | MSW handlers cover all three API operations (GET list, POST create, PUT status) with at least one happy-path and one error variant per endpoint. | Handlers must be registered in both `src/mocks/browser.ts` (dev) and `src/mocks/server.ts` (tests). | +| NFR-FE-06 | All interactive elements receive `data-testid` attributes following the existing naming convention (`cms-*`, e.g. `cms-add-button`, `cms-instance-row`, `cms-set-status-submit`). | Required for RTL `getByTestId` selectors in tests. | + +## Security + +| ID | Requirement | Rationale | +|----|-------------|-----------| +| NFR-FE-07 | The `apiKey` field in `AddCmsInstanceDialog` is rendered as `type="password"` using the existing `PasswordField` component (which includes a show/hide toggle). | Prevents shoulder-surfing of infrastructure credentials during the add flow (Q3: A). Reuses existing `PasswordField.tsx` component. | +| NFR-FE-08 | The `apiKey` value is sent once to the backend on form submit. It is not stored in React state beyond the form lifecycle and is not logged to the console. | Credentials must not leak into browser DevTools' React state inspector or console logs. | +| NFR-FE-09 | `CmsPage` relies on `RoleGuard allowedRoles={['Owner']}` at the route level. No additional role check is implemented inside the page or its components. | Single point of access control; avoids partial/inconsistent guards. See BR-FE-20. | + +## Reliability + +| ID | Requirement | Rationale | +|----|-------------|-----------| +| NFR-FE-10 | Loading state for `useCmsInstances` is rendered as `

{t('common.loading')}

`, consistent with `UsersPage`. No skeleton component is introduced. | Uniform loading UX without additional dependencies. | +| NFR-FE-11 | Error state for `useCmsInstances` is rendered as `

{t('errors.generic')}

`, consistent with `UsersPage`. | No custom error component needed. | +| NFR-FE-12 | TanStack Query stale time for `useCmsInstances` is 30 000 ms. Cache is invalidated on every successful mutation (add or status update). | Balances freshness with network efficiency; avoids redundant refetches while dialogs are open. | + +## Validation + +| ID | Requirement | Rationale | +|----|-------------|-----------| +| NFR-FE-13 | URL field in `AddCmsInstanceDialog` uses `z.string().url()` (strict Zod URL validation). The user must supply a fully-qualified URL including protocol (e.g. `http://192.168.1.5:8080`). | Q1: A. Backend validates anyway; client-side strict validation prevents protocol-less entries from ever reaching the API. | +| NFR-FE-14 | `react-hook-form` mode is set to `onTouched` for all new dialogs, consistent with `LoginPage`. Validation messages appear after a field is touched, not on initial render. | Avoids red errors appearing before the user has interacted with the form. | +| NFR-FE-15 | Field-level validation errors are rendered using the existing `` component (`src/components/ui/FieldError.tsx`). | Consistent error presentation across all forms. | diff --git a/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/tech-stack-decisions.md b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/tech-stack-decisions.md new file mode 100644 index 0000000..d5be4f1 --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/tech-stack-decisions.md @@ -0,0 +1,50 @@ +# Tech Stack Decisions — Unit 3: frontend-cms-page + +## Existing stack — no changes + +| Layer | Technology | Notes | +|-------|-----------|-------| +| UI components | Shadcn/UI | `Dialog`, `Table`, `Select`, `Badge`, `DropdownMenu` — all already present | +| Styling | Tailwind CSS | `opacity-50` for Inactive rows (BR-FE-04) | +| Data fetching | TanStack Query v5 | `useQuery` + `useMutation` pattern already used by `useUsers` | +| Form state | react-hook-form + zodResolver | Used in `InviteUserDialog`, `LoginPage`, `SetupPage` | +| Schema validation | Zod | `z.string().url()` for URL field (NFR-FE-13) | +| Routing | TanStack Router | `/cms` route already exists; `RoleGuard` already wired | +| i18n | react-i18next | New `cms.*` keys added to EN + NL locales | +| Toasts | Sonner | `toast.success()` / `toast.error()` — already used throughout | +| Testing | Vitest + React Testing Library + MSW | All already configured | +| HTTP client | Custom `api-client` (`NetworkError`, `ProblemDetailsError`) | Same error types used across all existing hooks | + +## Decisions made in this unit + +### TSD-01 — URL validation: `z.string().url()` (strict) + +**Decision**: Use Zod's built-in `z.string().url()` for the URL field. +**Rationale**: Q1: A. Users must enter a fully-qualified URL with protocol. The backend validates anyway, but client-side strict validation stops obviously malformed input early and improves error feedback. +**Implication**: Users entering `192.168.1.5:8080` will see a validation error. They must enter `http://192.168.1.5:8080`. + +### TSD-02 — Test scope: hooks + page + component tests + +**Decision**: Three test layers: +1. Hook unit tests (`useCmsInstances.test.ts`, `useAddCmsInstance.test.ts`, `useUpdateCmsInstanceStatus.test.ts`) using `renderHook` + MSW server +2. Page integration test (`CmsPage.test.tsx`) covering load, empty state, add flow, status flow +3. Component tests (`AddCmsInstanceDialog.test.tsx`, `SetStatusDialog.test.tsx`) for error states and conditional logic + +**Rationale**: Q2: C. Matches `InviteUserDialog.test.tsx` precedent. Dialog component tests are necessary to cover `FormErrorBanner` on HTTP 400 and the `DisableMessage` conditional show/hide (BR-FE-08) without a full page mount. + +### TSD-03 — ApiKey field: `PasswordField` with show/hide toggle + +**Decision**: Reuse the existing `PasswordField` component (`src/components/ui/PasswordField.tsx`) for the `apiKey` input. +**Rationale**: Q3: A. Hides the credential from shoulder-surfing while allowing the user to reveal it for verification. No new component needed — `PasswordField` already implements the show/hide pattern. + +### TSD-04 — Toast content: differentiated on `slaveContactSuccess` + +**Decision**: Two distinct toast messages for `PUT /api/v1/CmsInstances/{id}/status`: +- `slaveContactSuccess === true` → `t('cms.setStatus.successContactedToast')` = "Status updated — cliënt confirmed" +- `slaveContactSuccess === false` → `t('cms.setStatus.successUnreachableToast')` = "Status saved — cliënt unreachable" + +**Rationale**: Q3 FD: B + user terminology preference (no "slave" in UI). Both cases are HTTP 200; the distinction is surfaced via the `UpdateStatusResult.slaveContactSuccess` field from the backend. + +### TSD-05 — `FormErrorBanner` component name + +The project has both `FormBannerError` (`src/components/ui/FormBannerError.tsx`) and `FormErrorBanner` (`src/components/ui/FormErrorBanner.tsx`). The dialogs use `FormErrorBanner` (as used in `InviteUserDialog`). The `AddCmsInstanceDialog` and `SetStatusDialog` will import from `@/components/ui/FormErrorBanner`. diff --git a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-code-generation-plan.md b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-code-generation-plan.md new file mode 100644 index 0000000..ca1b557 --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-code-generation-plan.md @@ -0,0 +1,242 @@ +# Code Generation Plan — Unit 3: frontend-cms-page + +## Unit Context + +**Workspace root**: `K:\Development\Projects\SlpModularCms` +**Frontend root**: `frontend/src/` +**Project type**: Brownfield +**FD artifacts**: `aidlc-docs/features/master-cms-module/construction/frontend-cms-page/functional-design/` +**NFR artifacts**: `aidlc-docs/features/master-cms-module/construction/frontend-cms-page/nfr-requirements/` + `nfr-design/` + +## Dependencies + +- Backend API (`CmsInstanceController`) implemented in Unit 1 (master-backend) ✅ +- Existing frontend: `src/api/types.ts`, `src/lib/schemas/auth.ts`, `src/mocks/index.ts`, `src/pages/CmsPage.tsx` (placeholder) +- MSW: existing `mocks/index.ts` pattern with feature folders + +## Cross-cutting migrations (part of this unit) + +- `InviteUserDialog.tsx`: schema → `@/lib/schemas/users`, error state → `useState` +- New schema files: `src/lib/schemas/users.ts`, `src/lib/schemas/cms.ts` + +--- + +## Steps + +### Step 1 — `src/api/types.ts` (MODIFY) +Add CMS domain types after existing types: +- `CmsInstanceStatus` union type (`'Available' | 'NotAvailable' | 'Inactive'`) +- `CmsInstance` interface (id, name, url, status, disableMessage, lastContactedAt, lastStatusPushedAt, lastIntegrityCheckFailedAt) +- `CreateCmsInstanceRequest` interface (name, url, apiKey) +- `UpdateCmsInstanceStatusRequest` interface (status, disableMessage) +- `UpdateStatusResult` interface (success, slaveContactSuccess) + +### Step 2 — `src/lib/schemas/users.ts` (CREATE) +Migrate `inviteSchema` from `InviteUserDialog.tsx` to `src/lib/schemas/users.ts`: +- Export `inviteUserSchema` (z.object: email z.string().email(), role z.enum(['Administrator','User'])) +- Export `InviteUserFormData` inferred type + +### Step 3 — `src/lib/schemas/cms.ts` (CREATE) +- `addCmsInstanceSchema`: name (min 1, max 255), url (z.string().url()), apiKey (min 1) +- `AddCmsInstanceFormData` inferred type +- `setStatusSchema`: status enum + `.refine()` requiring disableMessage when NotAvailable +- `SetStatusFormData` inferred type + +### Step 4 — `src/lib/schemas/users.test.ts` (CREATE) +Tests for migrated `inviteUserSchema`: +- Valid email + role → passes +- Invalid email → fails +- Missing role → fails + +### Step 5 — `src/lib/schemas/cms.test.ts` (CREATE) +Tests for both schemas: +- `addCmsInstanceSchema`: valid data passes; missing name/apiKey fails; `z.string().url()` — `http://valid.url` passes, `no-protocol` fails +- `setStatusSchema`: Available without disableMessage passes; NotAvailable without disableMessage fails; NotAvailable with disableMessage passes + +### Step 6 — `src/components/users/InviteUserDialog.tsx` (MODIFY) +Two changes (pattern migration): +1. Replace inline `inviteSchema` + `InviteFormData` with import from `@/lib/schemas/users` +2. Replace `inviteUser.error` passed to `` with local `useState(null)` + `onError` callback setting generic error message + +### Step 7 — `src/mocks/cms/handlers.ts` (CREATE) +In-memory MSW handlers: +- `let mockCmsInstances: CmsInstance[]` with 2 seed entries (one Available, one Inactive) +- `resetMockCmsInstances()` export +- `GET /api/v1/CmsInstances` → return array +- `POST /api/v1/CmsInstances` → create instance (status=Available), push to array, return 201 + instance +- `PUT /api/v1/CmsInstances/:id/status` → update instance in array, return `UpdateStatusResult { success: true, slaveContactSuccess: true }` + +### Step 8 — `src/mocks/index.ts` (MODIFY) +- Import `cmsHandlers` from `./cms/handlers` +- Add `...cmsHandlers` to `handlers` array +- Re-export `cmsHandlers` and `resetMockCmsInstances` + +### Step 9 — `src/api/useCmsInstances.ts` (CREATE) +```typescript +export function useCmsInstances() { + return useQuery({ + queryKey: ['cmsInstances'], + queryFn: () => api.get('/api/v1/CmsInstances'), + staleTime: 30_000, + }); +} +``` + +### Step 10 — `src/api/useAddCmsInstance.ts` (CREATE) +```typescript +export function useAddCmsInstance(options?: MutationOptions) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => api.post('/api/v1/CmsInstances', data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }), + ...options, + }); +} +``` + +### Step 11 — `src/api/useUpdateCmsInstanceStatus.ts` (CREATE) +```typescript +export function useUpdateCmsInstanceStatus(options?: MutationOptions) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...body }) => + api.put(`/api/v1/CmsInstances/${id}/status`, body), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }), + ...options, + }); +} +``` + +### Step 12 — `src/i18n/locales/en/translation.json` (MODIFY) +Replace existing `"cms": { "title": ..., "description": ... }` with full `cms.*` key tree (from frontend-components.md i18n section). + +### Step 13 — `src/i18n/locales/nl/translation.json` (MODIFY) +Replace existing `"cms": { "title": ..., "description": ... }` with full `cms.*` key tree in Dutch. + +### Step 14 — `src/components/cms/CmsInstanceList.tsx` (CREATE) +Table component: +- Props: `instances: CmsInstance[]`, `onSetStatus: (instance: CmsInstance) => void` +- Shadcn `Table` with 6 columns (Name, URL, Status, Last Contact, Disable Message, Actions) +- Status badge: green/red/muted per BR-FE-05..07 +- Inactive row: `className={instance.status === 'Inactive' ? 'opacity-50' : ''}` +- Actions: `DropdownMenu` with "Set Status" item +- `data-testid` on row (`cms-instance-row`), badge, and actions trigger + +### Step 15 — `src/components/cms/AddCmsInstanceDialog.tsx` (CREATE) +Dialog with react-hook-form + `addCmsInstanceSchema`: +- Fields: name (text), url (text), apiKey (`PasswordField`) +- `useState(null)` for serverError +- `onError` callback: ProblemDetailsError 409 → specific message, NetworkError → `errors.network`, else → `errors.generic` +- `useEffect` on `open` → reset form + serverError + mutation +- `FormErrorBanner`, `FieldError`, `data-testid` on all fields and submit button + +### Step 16 — `src/components/cms/SetStatusDialog.tsx` (CREATE) +Dialog with react-hook-form + `setStatusSchema`: +- Fields: status (Shadcn `Select`), disableMessage (conditional text, only when NotAvailable) +- `useState(null)` for serverError +- `onSuccess`: close dialog, toast message based on `result.slaveContactSuccess` (BR-FE-12/13) +- `onError`: set serverError from toast (generic) +- `useEffect` on `open` or `instance` change → reset +- `data-testid` on select, disableMessage input, submit button + +### Step 17 — `src/pages/CmsPage.tsx` (MODIFY) +Replace placeholder with full page: +- State: `addDialogOpen`, `statusTarget` +- `useCmsInstances()` hook +- Header row: title + "Add CMS" button (data-testid="cms-add-button") +- Loading/error text per NFR-FE-10/11 +- Empty state (BR-FE-18) vs `CmsInstanceList` +- `AddCmsInstanceDialog` + `SetStatusDialog` +- Remove old `data-testid="cms-placeholder"` (test file updated in step 18) + +### Step 18 — `src/pages/CmsPage.test.tsx` (MODIFY) +Replace existing placeholder-only tests with: +- `renders list of CMS instances` (happy path with seed data) +- `renders empty state when no instances` (MSW returns []) +- `opens AddCmsInstanceDialog on button click` +- `adds a CMS instance successfully` +- `redirects unauthenticated users to login` (existing — keep) + +### Step 19 — `src/api/useCmsInstances.test.ts` (CREATE) +- Returns list on success +- Handles network error + +### Step 20 — `src/api/useAddCmsInstance.test.ts` (CREATE) +- Creates instance + invalidates cache on success (HTTP 201) +- Calls onError on HTTP 400 + +### Step 21 — `src/api/useUpdateCmsInstanceStatus.test.ts` (CREATE) +- Updates status + invalidates cache on success +- Returns UpdateStatusResult with slaveContactSuccess flag + +### Step 22 — `src/components/cms/AddCmsInstanceDialog.test.tsx` (CREATE) +- Renders fields and submit button +- Shows FieldError for missing name +- Shows FieldError for invalid URL (non-URL format) +- Shows FormErrorBanner on HTTP 400 +- Resets form on dialog close + +### Step 23 — `src/components/cms/SetStatusDialog.test.tsx` (CREATE) +- DisableMessage field hidden when status ≠ NotAvailable +- DisableMessage field shown and required when status = NotAvailable +- Form blocks submit when DisableMessage empty + NotAvailable + +--- + +## File Summary + +| # | File | Action | +|---|------|--------| +| 1 | `frontend/src/api/types.ts` | MODIFY — add 5 CMS types | +| 2 | `frontend/src/lib/schemas/users.ts` | CREATE — migrated inviteUserSchema | +| 3 | `frontend/src/lib/schemas/cms.ts` | CREATE — addCmsInstanceSchema, setStatusSchema | +| 4 | `frontend/src/lib/schemas/users.test.ts` | CREATE | +| 5 | `frontend/src/lib/schemas/cms.test.ts` | CREATE | +| 6 | `frontend/src/components/users/InviteUserDialog.tsx` | MODIFY — schema import + serverError migration | +| 7 | `frontend/src/mocks/cms/handlers.ts` | CREATE — MSW handlers | +| 8 | `frontend/src/mocks/index.ts` | MODIFY — register cmsHandlers | +| 9 | `frontend/src/api/useCmsInstances.ts` | CREATE | +| 10 | `frontend/src/api/useAddCmsInstance.ts` | CREATE | +| 11 | `frontend/src/api/useUpdateCmsInstanceStatus.ts` | CREATE | +| 12 | `frontend/src/i18n/locales/en/translation.json` | MODIFY — cms.* keys | +| 13 | `frontend/src/i18n/locales/nl/translation.json` | MODIFY — cms.* keys | +| 14 | `frontend/src/components/cms/CmsInstanceList.tsx` | CREATE | +| 15 | `frontend/src/components/cms/AddCmsInstanceDialog.tsx` | CREATE | +| 16 | `frontend/src/components/cms/SetStatusDialog.tsx` | CREATE | +| 17 | `frontend/src/pages/CmsPage.tsx` | MODIFY — replace placeholder | +| 18 | `frontend/src/pages/CmsPage.test.tsx` | MODIFY — full test suite | +| 19 | `frontend/src/api/useCmsInstances.test.ts` | CREATE | +| 20 | `frontend/src/api/useAddCmsInstance.test.ts` | CREATE | +| 21 | `frontend/src/api/useUpdateCmsInstanceStatus.test.ts` | CREATE | +| 22 | `frontend/src/components/cms/AddCmsInstanceDialog.test.tsx` | CREATE | +| 23 | `frontend/src/components/cms/SetStatusDialog.test.tsx` | CREATE | + +**Total**: 16 new files, 7 modified files + +--- + +## Step Completion Tracking + +- [x] Step 1 — types.ts +- [x] Step 2 — schemas/users.ts +- [x] Step 3 — schemas/cms.ts +- [x] Step 4 — schemas/users.test.ts +- [x] Step 5 — schemas/cms.test.ts +- [x] Step 6 — InviteUserDialog.tsx migration +- [x] Step 7 — mocks/cms/handlers.ts +- [x] Step 8 — mocks/index.ts +- [x] Step 9 — useCmsInstances.ts +- [x] Step 10 — useAddCmsInstance.ts +- [x] Step 11 — useUpdateCmsInstanceStatus.ts +- [x] Step 12 — en/translation.json +- [x] Step 13 — nl/translation.json +- [x] Step 14 — CmsInstanceList.tsx +- [x] Step 15 — AddCmsInstanceDialog.tsx +- [x] Step 16 — SetStatusDialog.tsx +- [x] Step 17 — CmsPage.tsx +- [x] Step 18 — CmsPage.test.tsx +- [x] Step 19 — useCmsInstances.test.ts +- [x] Step 20 — useAddCmsInstance.test.ts +- [x] Step 21 — useUpdateCmsInstanceStatus.test.ts +- [x] Step 22 — AddCmsInstanceDialog.test.tsx +- [x] Step 23 — SetStatusDialog.test.tsx diff --git a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-plan.md b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-plan.md new file mode 100644 index 0000000..77cc8d7 --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-plan.md @@ -0,0 +1,22 @@ +# NFR Design Plan — Unit 3: frontend-cms-page + +## Unit Context + +**Unit**: `frontend-cms-page` +**Inputs**: nfr-requirements.md, tech-stack-decisions.md +**Key NFR design concerns**: Zod schema organisation, error-handling pattern for hooks + +--- + +## Execution Steps + +- [x] **Step 1** — Analyze answers; flag ambiguities +- [x] **Step 2** — Generate `nfr-design-patterns.md` +- [x] **Step 3** — Generate `logical-components.md` +- [x] **Step 4** — Update `aidlc-state.md` +- [ ] **Step 5** — Present completion message for approval + +--- + +*Questions file*: `frontend-cms-page-nfr-design-questions.md` +*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-plan.md` diff --git a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-questions.md b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-questions.md new file mode 100644 index 0000000..6dd1df2 --- /dev/null +++ b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-design-questions.md @@ -0,0 +1,34 @@ +# NFR Design Questions — Unit 3: frontend-cms-page + +Please answer each question by filling in the letter after the `[Answer]:` tag. +If none of the options match, choose the last option (Other) and describe your preference. + +--- + +## Question 1 +Where should the Zod form schemas for `AddCmsInstanceDialog` and `SetStatusDialog` live? + +Context: `src/lib/schemas/auth.ts` holds shared auth schemas (used by LoginPage, SetupPage, InviteCompletePage, and tested in `auth.test.ts`). `InviteUserDialog` defines its schema inline. + +A) **Inline in each component file** — `addInstanceSchema` defined inside `AddCmsInstanceDialog.tsx`, `setStatusSchema` inside `SetStatusDialog.tsx`. Pragmatic: each schema is single-use and lives next to the form that uses it. Consistent with `InviteUserDialog.tsx` pattern. + +B) **Centralised in `src/lib/schemas/cms.ts`** — Both schemas exported from a single module, tested separately in `src/lib/schemas/cms.test.ts`. Consistent with the `auth.ts` pattern; schemas are independently testable. + +C) Other (please describe after [Answer]: tag below) + +[Answer]: C, I want all schemas to live in the src/lib/schemas folder. Do it also for schemas that are inlin or otherwise not in that directory to make it consistent and easy to find. + +--- + +## Question 2 +How should hook-level API errors be surfaced to the component? + +Context: TanStack Query exposes `mutation.error` on the mutation object. Two approaches are used in this project: + +A) **`mutation.error` prop** — component reads `mutation.error` directly and passes `{ message: mutation.error.message }` to `FormErrorBanner`. Pattern used by `InviteUserDialog` (`inviteUser.error`). No extra state. + +B) **Local `useState(null)`** — component owns a `serverError` string, set in the mutation `onError` callback. Pattern used by `LoginPage`. Allows custom i18n error messages per status code (e.g. 409 Conflict → "Name already in use"). + +C) Other (please describe after [Answer]: tag below) + +[Answer]: B, also make it consistent accross the projects if possible. diff --git a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-questions.md b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-questions.md index 6e7fd8a..36492a2 100644 --- a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-questions.md +++ b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-questions.md @@ -18,7 +18,7 @@ C) **Custom** — Must start with `http://` or `https://`, but the rest is not v D) Other (please describe after [Answer]: tag below) -[Answer]: +[Answer]: A --- @@ -33,7 +33,7 @@ C) **Hooks + page + component tests** — In addition to B, dedicated tests for D) Other (please describe after [Answer]: tag below) -[Answer]: +[Answer]: C --- @@ -48,4 +48,4 @@ B) **`type="text"`** — Visible. Easier to verify the pasted value is correct. C) Other (please describe after [Answer]: tag below) -[Answer]: +[Answer]: A diff --git a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-requirements-plan.md b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-requirements-plan.md index 51ffad4..5d9035d 100644 --- a/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-requirements-plan.md +++ b/aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-requirements-plan.md @@ -10,10 +10,10 @@ ## Execution Steps -- [ ] **Step 1** — Analyze answers; flag ambiguities -- [ ] **Step 2** — Generate `nfr-requirements.md` -- [ ] **Step 3** — Generate `tech-stack-decisions.md` -- [ ] **Step 4** — Update `aidlc-state.md` +- [x] **Step 1** — Analyze answers; flag ambiguities +- [x] **Step 2** — Generate `nfr-requirements.md` +- [x] **Step 3** — Generate `tech-stack-decisions.md` +- [x] **Step 4** — Update `aidlc-state.md` - [ ] **Step 5** — Present completion message for approval --- diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3c15200..a1c54b3 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -82,6 +82,35 @@ export interface ChangeRolePayload { export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable'; +export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive'; + +export interface CmsInstance { + id: string; + name: string; + url: string; + status: CmsInstanceStatus; + disableMessage: string | null; + lastContactedAt: string | null; + lastStatusPushedAt: string | null; + lastIntegrityCheckFailedAt: string | null; +} + +export interface CreateCmsInstanceRequest { + name: string; + url: string; + apiKey: string; +} + +export interface UpdateCmsInstanceStatusRequest { + status: CmsInstanceStatus; + disableMessage: string | null; +} + +export interface UpdateStatusResult { + success: boolean; + slaveContactSuccess: boolean; +} + export interface AvailabilityResponse { status: AvailabilityStatus; checkedAt: string; // ISO 8601 diff --git a/frontend/src/api/useAddCmsInstance.test.ts b/frontend/src/api/useAddCmsInstance.test.ts new file mode 100644 index 0000000..50d6f36 --- /dev/null +++ b/frontend/src/api/useAddCmsInstance.test.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { createElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { server } from '@/mocks/server'; +import { API_BASE } from '@/mocks/auth/fixtures'; +import { useAddCmsInstance } from './useAddCmsInstance'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); +} + +const CMS_URL = `${API_BASE}/api/v1/CmsInstances`; + +describe('useAddCmsInstance', () => { + it('returns created instance on success', async () => { + const { result } = renderHook(() => useAddCmsInstance(), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.mutateAsync({ + name: 'Test CMS', + url: 'http://test.example.com', + apiKey: 'secret', + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.name).toBe('Test CMS'); + }); + + it('calls onError callback on HTTP 400', async () => { + server.use(http.post(CMS_URL, () => HttpResponse.json({}, { status: 400 }))); + + const onError = vi.fn(); + const { result } = renderHook(() => useAddCmsInstance({ onError }), { + wrapper: createWrapper(), + }); + + await act(async () => { + try { + await result.current.mutateAsync({ name: 'X', url: 'http://x.com', apiKey: 'k' }); + } catch { + // expected + } + }); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/api/useAddCmsInstance.ts b/frontend/src/api/useAddCmsInstance.ts new file mode 100644 index 0000000..eb53840 --- /dev/null +++ b/frontend/src/api/useAddCmsInstance.ts @@ -0,0 +1,14 @@ +import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import type { CmsInstance, CreateCmsInstanceRequest } from './types'; + +export function useAddCmsInstance( + options?: Pick, 'onError'>, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => api.post('/api/v1/CmsInstances', data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }), + ...options, + }); +} diff --git a/frontend/src/api/useCmsInstances.test.ts b/frontend/src/api/useCmsInstances.test.ts new file mode 100644 index 0000000..5f489d4 --- /dev/null +++ b/frontend/src/api/useCmsInstances.test.ts @@ -0,0 +1,44 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import { server } from '@/mocks/server'; +import { API_BASE } from '@/mocks/auth/fixtures'; +import { getMockCmsInstances } from '@/mocks/cms/handlers'; +import { useCmsInstances } from './useCmsInstances'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); +} + +const CMS_URL = `${API_BASE}/api/v1/CmsInstances`; + +describe('useCmsInstances', () => { + it('starts in loading state', () => { + const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() }); + expect(result.current.isPending).toBe(true); + }); + + it('returns the instances list on success', async () => { + const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + + expect(result.current.isError).toBe(false); + expect(result.current.data).toHaveLength(getMockCmsInstances().length); + expect(result.current.data?.[0].name).toBe(getMockCmsInstances()[0].name); + }); + + it('enters error state on network error', async () => { + server.use(http.get(CMS_URL, () => HttpResponse.json({}, { status: 500 }))); + + const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + + expect(result.current.isError).toBe(true); + }); +}); diff --git a/frontend/src/api/useCmsInstances.ts b/frontend/src/api/useCmsInstances.ts new file mode 100644 index 0000000..21ecbcb --- /dev/null +++ b/frontend/src/api/useCmsInstances.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import type { CmsInstance } from './types'; + +export function useCmsInstances() { + return useQuery({ + queryKey: ['cmsInstances'], + queryFn: () => api.get('/api/v1/CmsInstances'), + staleTime: 30_000, + }); +} diff --git a/frontend/src/api/useUpdateCmsInstanceStatus.test.ts b/frontend/src/api/useUpdateCmsInstanceStatus.test.ts new file mode 100644 index 0000000..6d99a07 --- /dev/null +++ b/frontend/src/api/useUpdateCmsInstanceStatus.test.ts @@ -0,0 +1,64 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { createElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { server } from '@/mocks/server'; +import { API_BASE } from '@/mocks/auth/fixtures'; +import { useUpdateCmsInstanceStatus } from './useUpdateCmsInstanceStatus'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); +} + +const INSTANCE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const STATUS_URL = `${API_BASE}/api/v1/CmsInstances/${INSTANCE_ID}/status`; + +describe('useUpdateCmsInstanceStatus', () => { + it('returns UpdateStatusResult on success', async () => { + const onSuccess = vi.fn(); + const { result } = renderHook(() => useUpdateCmsInstanceStatus({ onSuccess }), { + wrapper: createWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ + id: INSTANCE_ID, + status: 'NotAvailable', + disableMessage: 'Under maintenance', + }); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + const callArg = onSuccess.mock.calls[0][0]; + expect(callArg.success).toBe(true); + expect(callArg.slaveContactSuccess).toBe(true); + }); + + it('calls onError on network failure', async () => { + server.use(http.put(STATUS_URL, () => HttpResponse.json({}, { status: 500 }))); + + const onError = vi.fn(); + const { result } = renderHook(() => useUpdateCmsInstanceStatus({ onError }), { + wrapper: createWrapper(), + }); + + await act(async () => { + try { + await result.current.mutateAsync({ + id: INSTANCE_ID, + status: 'Available', + disableMessage: null, + }); + } catch { + // expected + } + }); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/api/useUpdateCmsInstanceStatus.ts b/frontend/src/api/useUpdateCmsInstanceStatus.ts new file mode 100644 index 0000000..b307762 --- /dev/null +++ b/frontend/src/api/useUpdateCmsInstanceStatus.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import type { UpdateCmsInstanceStatusRequest, UpdateStatusResult } from './types'; + +type UpdateVariables = { id: string } & UpdateCmsInstanceStatusRequest; + +export function useUpdateCmsInstanceStatus( + options?: Pick, 'onSuccess' | 'onError'>, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...body }) => + api.put(`/api/v1/CmsInstances/${id}/status`, body), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }); + options?.onSuccess?.(data, variables, context); + }, + onError: options?.onError, + }); +} diff --git a/frontend/src/api/useUsers.ts b/frontend/src/api/useUsers.ts index 8e45a7e..fcdc48a 100644 --- a/frontend/src/api/useUsers.ts +++ b/frontend/src/api/useUsers.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload, UserRole } from './types'; @@ -10,11 +10,14 @@ export function useUsers() { }); } -export function useInviteUser() { +export function useInviteUser( + options?: Pick, 'onError'>, +) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data) => api.post('/api/v1/Users/invite', data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }), + ...options, }); } diff --git a/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx b/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx new file mode 100644 index 0000000..faa321b --- /dev/null +++ b/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { renderApp, mockAuthenticated } from '@/test/utils'; +import { server } from '@/mocks/server'; +import { API_BASE } from '@/mocks/auth/fixtures'; +import { resetMockCmsInstances } from '@/mocks/cms/handlers'; +import { _resetSetupStatusCache } from '@/router'; + +beforeEach(() => { + _resetSetupStatusCache(); + resetMockCmsInstances(); +}); + +async function openDialog() { + mockAuthenticated(); + renderApp('/cms'); + await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await userEvent.click(screen.getByTestId('cms-add-button')); + expect(screen.getByTestId('add-cms-name')).toBeInTheDocument(); +} + +describe('AddCmsInstanceDialog', () => { + it('shows name, url and apiKey fields on open', async () => { + await openDialog(); + expect(screen.getByTestId('add-cms-name')).toBeInTheDocument(); + expect(screen.getByTestId('add-cms-url')).toBeInTheDocument(); + expect(screen.getByTestId('add-cms-apikey-input')).toBeInTheDocument(); + expect(screen.getByTestId('add-cms-submit')).toBeInTheDocument(); + }); + + it('shows field error when name is empty', async () => { + await openDialog(); + await userEvent.click(screen.getByTestId('add-cms-url')); + await userEvent.click(screen.getByTestId('add-cms-name')); + await userEvent.tab(); + expect(await screen.findByTestId('add-cms-name-error')).toBeInTheDocument(); + }); + + it('shows field error when URL has no protocol', async () => { + await openDialog(); + await userEvent.type(screen.getByTestId('add-cms-url'), 'cms.example.com'); + await userEvent.tab(); + expect(await screen.findByTestId('add-cms-url-error')).toBeInTheDocument(); + }); + + it('shows FormErrorBanner on HTTP 400', async () => { + server.use( + http.post(`${API_BASE}/api/v1/CmsInstances`, () => + HttpResponse.json({ title: 'Bad Request' }, { status: 400 }), + ), + ); + await openDialog(); + await userEvent.type(screen.getByTestId('add-cms-name'), 'CMS'); + await userEvent.type(screen.getByTestId('add-cms-url'), 'http://example.com'); + await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'key'); + await userEvent.click(screen.getByTestId('add-cms-submit')); + + expect(await screen.findByTestId('form-error-banner')).toBeInTheDocument(); + expect(screen.getByTestId('add-cms-name')).toBeInTheDocument(); + }); + + it('resets form when dialog is closed and reopened', async () => { + await openDialog(); + await userEvent.type(screen.getByTestId('add-cms-name'), 'Typed value'); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByTestId('cms-add-button')); + expect((screen.getByTestId('add-cms-name') as HTMLInputElement).value).toBe(''); + }); +}); diff --git a/frontend/src/components/cms/AddCmsInstanceDialog.tsx b/frontend/src/components/cms/AddCmsInstanceDialog.tsx new file mode 100644 index 0000000..962c1c1 --- /dev/null +++ b/frontend/src/components/cms/AddCmsInstanceDialog.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { PasswordField } from '@/components/ui/PasswordField'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { FormErrorBanner } from '@/components/ui/FormErrorBanner'; +import { FieldError } from '@/components/ui/FieldError'; +import { useAddCmsInstance } from '@/api/useAddCmsInstance'; +import { NetworkError, ProblemDetailsError } from '@/lib/api-client'; +import { addCmsInstanceSchema, type AddCmsInstanceFormData } from '@/lib/schemas/cms'; + +interface AddCmsInstanceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddCmsInstanceDialog({ open, onOpenChange }: AddCmsInstanceDialogProps) { + const { t } = useTranslation(); + 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')); + } + }, + }); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(addCmsInstanceSchema), + mode: 'onTouched', + }); + + useEffect(() => { + if (!open) { + setServerError(null); + addInstance.reset(); + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const onSubmit = handleSubmit(async (values) => { + setServerError(null); + try { + await addInstance.mutateAsync(values); + toast.success(t('cms.add.successToast')); + onOpenChange(false); + } catch { + // error shown via serverError state + } + }); + + return ( + + + + {t('cms.add.title')} + + +
+ setServerError(null)} + /> + +
+ + + {errors.name && ( + + )} +
+ +
+ + + {errors.url && ( + + )} +
+ +
+ + + {errors.apiKey && ( + + )} +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/cms/CmsInstanceList.tsx b/frontend/src/components/cms/CmsInstanceList.tsx new file mode 100644 index 0000000..bf5857b --- /dev/null +++ b/frontend/src/components/cms/CmsInstanceList.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next'; +import { MoreHorizontal } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import type { CmsInstance, CmsInstanceStatus } from '@/api/types'; + +function statusBadgeVariant(status: CmsInstanceStatus) { + if (status === 'Available') return 'secondary'; + if (status === 'NotAvailable') return 'destructive'; + return 'outline'; +} + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +interface CmsInstanceListProps { + instances: CmsInstance[]; + onSetStatus: (instance: CmsInstance) => void; +} + +export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps) { + const { t } = useTranslation(); + + return ( + + + + {t('cms.table.name')} + {t('cms.table.url')} + {t('cms.table.status')} + {t('cms.table.lastContact')} + {t('cms.table.disableMessage')} + {t('cms.table.actions')} + + + + {instances.map((instance) => ( + + {instance.name} + {instance.url} + + + {t(`cms.status.${instance.status}`)} + + + {formatDate(instance.lastContactedAt)} + {instance.disableMessage ?? '—'} + + + + + + + onSetStatus(instance)} + > + {t('cms.actions.setStatus')} + + + + + + ))} + +
+ ); +} diff --git a/frontend/src/components/cms/SetStatusDialog.test.tsx b/frontend/src/components/cms/SetStatusDialog.test.tsx new file mode 100644 index 0000000..f819b1b --- /dev/null +++ b/frontend/src/components/cms/SetStatusDialog.test.tsx @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderApp, mockAuthenticated } from '@/test/utils'; +import { resetMockCmsInstances } from '@/mocks/cms/handlers'; +import { _resetSetupStatusCache } from '@/router'; + +beforeEach(() => { + _resetSetupStatusCache(); + resetMockCmsInstances(); +}); + +async function openSetStatusDialog() { + mockAuthenticated(); + renderApp('/cms'); + const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 5000 }); + await userEvent.click(triggers[0]); + await userEvent.click(await screen.findByTestId('cms-instance-set-status')); + expect(screen.getByTestId('set-status-select')).toBeInTheDocument(); +} + +describe('SetStatusDialog', () => { + it('opens with status select and submit button', async () => { + await openSetStatusDialog(); + expect(screen.getByTestId('set-status-select')).toBeInTheDocument(); + expect(screen.getByTestId('set-status-submit')).toBeInTheDocument(); + }); + + it('does not show DisableMessage field when status is Available', async () => { + await openSetStatusDialog(); + expect(screen.queryByTestId('set-status-disable-message')).not.toBeInTheDocument(); + }); + + it('shows DisableMessage field when NotAvailable is selected', async () => { + await openSetStatusDialog(); + await userEvent.click(screen.getByTestId('set-status-select')); + await userEvent.click(await screen.findByRole('option', { name: 'Unavailable' })); + expect(await screen.findByTestId('set-status-disable-message')).toBeInTheDocument(); + }); + + it('blocks submit when NotAvailable is selected but DisableMessage is empty', async () => { + await openSetStatusDialog(); + await userEvent.click(screen.getByTestId('set-status-select')); + await userEvent.click(await screen.findByRole('option', { name: 'Unavailable' })); + await screen.findByTestId('set-status-disable-message'); + await userEvent.click(screen.getByTestId('set-status-submit')); + expect(await screen.findByTestId('set-status-disable-message-error')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/cms/SetStatusDialog.tsx b/frontend/src/components/cms/SetStatusDialog.tsx new file mode 100644 index 0000000..8253b65 --- /dev/null +++ b/frontend/src/components/cms/SetStatusDialog.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { FormErrorBanner } from '@/components/ui/FormErrorBanner'; +import { FieldError } from '@/components/ui/FieldError'; +import { useUpdateCmsInstanceStatus } from '@/api/useUpdateCmsInstanceStatus'; +import { NetworkError } from '@/lib/api-client'; +import { setStatusSchema, type SetStatusFormData } from '@/lib/schemas/cms'; +import type { CmsInstance, CmsInstanceStatus } from '@/api/types'; + +interface SetStatusDialogProps { + instance: CmsInstance | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const statusOptions: CmsInstanceStatus[] = ['Available', 'NotAvailable', 'Inactive']; + +export function SetStatusDialog({ instance, open, onOpenChange }: SetStatusDialogProps) { + const { t } = useTranslation(); + const [serverError, setServerError] = useState(null); + + const updateStatus = useUpdateCmsInstanceStatus({ + onSuccess: (result) => { + if (result.slaveContactSuccess) { + toast.success(t('cms.setStatus.successContactedToast')); + } else { + toast.success(t('cms.setStatus.successUnreachableToast')); + } + onOpenChange(false); + }, + onError: (err) => { + if (err instanceof NetworkError) { + setServerError(t('errors.network')); + } else { + setServerError(t('errors.generic')); + } + }, + }); + + const { + register, + handleSubmit, + control, + watch, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(setStatusSchema), + mode: 'onTouched', + defaultValues: { status: 'Available', disableMessage: '' }, + }); + + const selectedStatus = watch('status'); + + useEffect(() => { + if (!open || !instance) { + setServerError(null); + updateStatus.reset(); + reset({ status: 'Available', disableMessage: '' }); + } else { + reset({ + status: instance.status, + disableMessage: instance.disableMessage ?? '', + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, instance]); + + const onSubmit = handleSubmit(async (values) => { + if (!instance) return; + setServerError(null); + try { + await updateStatus.mutateAsync({ + id: instance.id, + status: values.status, + disableMessage: values.status === 'NotAvailable' ? (values.disableMessage ?? null) : null, + }); + } catch { + // error shown via serverError state + } + }); + + return ( + + + + {t('cms.setStatus.title')} + + +
+ setServerError(null)} + /> + +
+ + ( + + )} + /> + {errors.status && ( + + )} +
+ + {selectedStatus === 'NotAvailable' && ( +
+ + + {errors.disableMessage && ( + + )} +
+ )} + + + +
+
+ ); +} diff --git a/frontend/src/components/users/InviteUserDialog.tsx b/frontend/src/components/users/InviteUserDialog.tsx index 9e42b11..4df85fa 100644 --- a/frontend/src/components/users/InviteUserDialog.tsx +++ b/frontend/src/components/users/InviteUserDialog.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; @@ -23,12 +22,8 @@ import { import { FormErrorBanner } from '@/components/ui/FormErrorBanner'; import { FieldError } from '@/components/ui/FieldError'; import { useInviteUser } from '@/api/useUsers'; - -const inviteSchema = z.object({ - email: z.string().email(), - role: z.enum(['Administrator', 'User']), -}); -type InviteFormData = z.infer; +import { inviteUserSchema, type InviteUserFormData } from '@/lib/schemas/users'; +import { NetworkError } from '@/lib/api-client'; interface InviteUserDialogProps { open: boolean; @@ -39,12 +34,22 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) const { t } = useTranslation(); const [step, setStep] = useState<1 | 2>(1); const [inviteLink, setInviteLink] = useState(null); - const inviteUser = useInviteUser(); + const [serverError, setServerError] = useState(null); + const inviteUser = useInviteUser({ + onError: (err) => { + if (err instanceof NetworkError) { + setServerError(t('errors.network')); + } else { + setServerError(t('errors.generic')); + } + }, + }); useEffect(() => { if (!open) { setStep(1); setInviteLink(null); + setServerError(null); inviteUser.reset(); reset(); } @@ -57,17 +62,18 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) setValue, reset, formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(inviteSchema), + } = useForm({ + resolver: zodResolver(inviteUserSchema), }); const onSubmit = handleSubmit(async (values) => { + setServerError(null); try { const response = await inviteUser.mutateAsync(values); setInviteLink(response.inviteLink); setStep(2); } catch { - // error shown via inviteUser.error + // error shown via serverError state } }); @@ -89,8 +95,8 @@ export function InviteUserDialog({ open, onOpenChange }: InviteUserDialogProps) {step === 1 && (
inviteUser.reset()} + error={serverError !== null ? { message: serverError } : null} + onDismiss={() => setServerError(null)} />
diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 87740d6..093b10e 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -184,8 +184,48 @@ "branding": { "title": "Branding / Theme", "comingSoon": "Coming soon" } }, "cms": { - "title": "Content Management System", - "description": "This is where you will manage your CMS content. This feature is coming soon." + "title": "CMS Instances", + "addButton": "Add CMS", + "emptyState": { + "heading": "No CMS instances yet", + "description": "Add your first CMS instance to get started." + }, + "table": { + "name": "Name", + "url": "URL", + "status": "Status", + "lastContact": "Last Contact", + "disableMessage": "Disable Message", + "actions": "Actions" + }, + "status": { + "Available": "Available", + "NotAvailable": "Unavailable", + "Inactive": "Inactive" + }, + "actions": { + "setStatus": "Set Status" + }, + "add": { + "title": "Add CMS Instance", + "nameLabel": "Name", + "urlLabel": "URL", + "apiKeyLabel": "API Key", + "submitButton": "Add", + "successToast": "CMS instance added successfully", + "errors": { + "conflict": "A CMS instance with this name or URL already exists." + } + }, + "setStatus": { + "title": "Set Status", + "statusLabel": "Status", + "disableMessageLabel": "Disable Message", + "disableMessagePlaceholder": "Reason for disabling…", + "submitButton": "Save", + "successContactedToast": "Status updated — cliënt confirmed", + "successUnreachableToast": "Status saved — cliënt unreachable" + } }, "error": { "403": { diff --git a/frontend/src/i18n/locales/nl/translation.json b/frontend/src/i18n/locales/nl/translation.json index b5f6594..5612554 100644 --- a/frontend/src/i18n/locales/nl/translation.json +++ b/frontend/src/i18n/locales/nl/translation.json @@ -184,8 +184,48 @@ "branding": { "title": "Huisstijl / Thema", "comingSoon": "Binnenkort beschikbaar" } }, "cms": { - "title": "Content Management Systeem", - "description": "Hier beheert u straks uw CMS-inhoud. Deze functie is binnenkort beschikbaar." + "title": "CMS-instanties", + "addButton": "CMS toevoegen", + "emptyState": { + "heading": "Nog geen CMS-instanties", + "description": "Voeg uw eerste CMS-instantie toe om aan de slag te gaan." + }, + "table": { + "name": "Naam", + "url": "URL", + "status": "Status", + "lastContact": "Laatste contact", + "disableMessage": "Uitschakelreden", + "actions": "Acties" + }, + "status": { + "Available": "Beschikbaar", + "NotAvailable": "Niet beschikbaar", + "Inactive": "Inactief" + }, + "actions": { + "setStatus": "Status instellen" + }, + "add": { + "title": "CMS-instantie toevoegen", + "nameLabel": "Naam", + "urlLabel": "URL", + "apiKeyLabel": "API-sleutel", + "submitButton": "Toevoegen", + "successToast": "CMS-instantie succesvol toegevoegd", + "errors": { + "conflict": "Er bestaat al een CMS-instantie met deze naam of URL." + } + }, + "setStatus": { + "title": "Status instellen", + "statusLabel": "Status", + "disableMessageLabel": "Uitschakelreden", + "disableMessagePlaceholder": "Reden voor uitschakelen…", + "submitButton": "Opslaan", + "successContactedToast": "Status bijgewerkt — cliënt bevestigd", + "successUnreachableToast": "Status opgeslagen — cliënt niet bereikbaar" + } }, "error": { "403": { diff --git a/frontend/src/lib/schemas/cms.test.ts b/frontend/src/lib/schemas/cms.test.ts new file mode 100644 index 0000000..86e54d5 --- /dev/null +++ b/frontend/src/lib/schemas/cms.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { addCmsInstanceSchema, setStatusSchema } from './cms'; + +describe('addCmsInstanceSchema', () => { + it('accepts valid data', () => { + const result = addCmsInstanceSchema.safeParse({ + name: 'My CMS', + url: 'http://192.168.1.5:8080', + apiKey: 'secret-key', + }); + expect(result.success).toBe(true); + }); + + it('accepts https URLs', () => { + const result = addCmsInstanceSchema.safeParse({ + name: 'My CMS', + url: 'https://cms.example.com', + apiKey: 'secret-key', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing name', () => { + const result = addCmsInstanceSchema.safeParse({ name: '', url: 'http://example.com', apiKey: 'k' }); + expect(result.success).toBe(false); + }); + + it('rejects URL without protocol', () => { + const result = addCmsInstanceSchema.safeParse({ name: 'CMS', url: 'cms.example.com', apiKey: 'k' }); + expect(result.success).toBe(false); + }); + + it('rejects bare IP without protocol', () => { + const result = addCmsInstanceSchema.safeParse({ name: 'CMS', url: '192.168.1.5:8080', apiKey: 'k' }); + expect(result.success).toBe(false); + }); + + it('rejects missing apiKey', () => { + const result = addCmsInstanceSchema.safeParse({ name: 'CMS', url: 'http://example.com', apiKey: '' }); + expect(result.success).toBe(false); + }); +}); + +describe('setStatusSchema', () => { + it('accepts Available without disableMessage', () => { + const result = setStatusSchema.safeParse({ status: 'Available' }); + expect(result.success).toBe(true); + }); + + it('accepts Inactive without disableMessage', () => { + const result = setStatusSchema.safeParse({ status: 'Inactive' }); + expect(result.success).toBe(true); + }); + + it('accepts NotAvailable with a disableMessage', () => { + const result = setStatusSchema.safeParse({ status: 'NotAvailable', disableMessage: 'System down' }); + expect(result.success).toBe(true); + }); + + it('rejects NotAvailable without disableMessage', () => { + const result = setStatusSchema.safeParse({ status: 'NotAvailable' }); + expect(result.success).toBe(false); + }); + + it('rejects NotAvailable with blank disableMessage', () => { + const result = setStatusSchema.safeParse({ status: 'NotAvailable', disableMessage: ' ' }); + expect(result.success).toBe(false); + }); +}); diff --git a/frontend/src/lib/schemas/cms.ts b/frontend/src/lib/schemas/cms.ts new file mode 100644 index 0000000..a96abbc --- /dev/null +++ b/frontend/src/lib/schemas/cms.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const addCmsInstanceSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or fewer'), + 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; diff --git a/frontend/src/lib/schemas/users.test.ts b/frontend/src/lib/schemas/users.test.ts new file mode 100644 index 0000000..ea1a628 --- /dev/null +++ b/frontend/src/lib/schemas/users.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { inviteUserSchema } from './users'; + +describe('inviteUserSchema', () => { + it('accepts valid email and role', () => { + const result = inviteUserSchema.safeParse({ email: 'user@example.com', role: 'Administrator' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid email', () => { + const result = inviteUserSchema.safeParse({ email: 'not-an-email', role: 'User' }); + expect(result.success).toBe(false); + }); + + it('rejects missing role', () => { + const result = inviteUserSchema.safeParse({ email: 'user@example.com' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid role value', () => { + const result = inviteUserSchema.safeParse({ email: 'user@example.com', role: 'Owner' }); + expect(result.success).toBe(false); + }); +}); diff --git a/frontend/src/lib/schemas/users.ts b/frontend/src/lib/schemas/users.ts new file mode 100644 index 0000000..431a0b2 --- /dev/null +++ b/frontend/src/lib/schemas/users.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const inviteUserSchema = z.object({ + email: z.string().email(), + role: z.enum(['Administrator', 'User']), +}); + +export type InviteUserFormData = z.infer; diff --git a/frontend/src/mocks/cms/handlers.ts b/frontend/src/mocks/cms/handlers.ts new file mode 100644 index 0000000..d837348 --- /dev/null +++ b/frontend/src/mocks/cms/handlers.ts @@ -0,0 +1,66 @@ +import { http, HttpResponse } from 'msw'; +import type { CmsInstance, UpdateStatusResult } from '@/api/types'; +import { API_BASE } from '../auth/fixtures'; + +const seed: CmsInstance[] = [ + { + id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + name: 'Main CMS', + url: 'http://cms.example.com', + status: 'Available', + disableMessage: null, + lastContactedAt: '2026-06-30T10:00:00.000Z', + lastStatusPushedAt: '2026-06-30T10:00:00.000Z', + lastIntegrityCheckFailedAt: null, + }, + { + id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + name: 'Legacy CMS', + url: 'http://legacy.example.com', + status: 'Inactive', + disableMessage: null, + lastContactedAt: null, + lastStatusPushedAt: null, + lastIntegrityCheckFailedAt: null, + }, +]; + +let mockCmsInstances: CmsInstance[] = [...seed]; + +export const resetMockCmsInstances = () => { + mockCmsInstances = [...seed]; +}; + +export const getMockCmsInstances = () => mockCmsInstances; + +export const cmsHandlers = [ + http.get(`${API_BASE}/api/v1/CmsInstances`, () => HttpResponse.json(mockCmsInstances)), + + http.post(`${API_BASE}/api/v1/CmsInstances`, async ({ request }) => { + const body = (await request.json()) as { name: string; url: string; apiKey: string }; + const newInstance: CmsInstance = { + id: crypto.randomUUID(), + name: body.name, + url: body.url, + status: 'Available', + disableMessage: null, + lastContactedAt: null, + lastStatusPushedAt: null, + lastIntegrityCheckFailedAt: null, + }; + mockCmsInstances = [...mockCmsInstances, newInstance]; + return HttpResponse.json(newInstance, { status: 201 }); + }), + + http.put(`${API_BASE}/api/v1/CmsInstances/:id/status`, async ({ params, request }) => { + const { id } = params as { id: string }; + const body = (await request.json()) as { status: CmsInstance['status']; disableMessage: string | null }; + mockCmsInstances = mockCmsInstances.map((instance) => + instance.id === id + ? { ...instance, status: body.status, disableMessage: body.disableMessage } + : instance, + ); + const result: UpdateStatusResult = { success: true, slaveContactSuccess: true }; + return HttpResponse.json(result); + }), +]; diff --git a/frontend/src/mocks/index.ts b/frontend/src/mocks/index.ts index 85e9d10..3e69005 100644 --- a/frontend/src/mocks/index.ts +++ b/frontend/src/mocks/index.ts @@ -3,13 +3,15 @@ import { userHandlers } from './users/handlers'; import { setupHandlers } from './setup/handlers'; import { invitationHandlers } from './invitation/handlers'; import { availabilityHandlers } from './availability/handlers'; +import { cmsHandlers } from './cms/handlers'; /** All default MSW handlers, composed from feature folders (Q3-B). */ -export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers]; +export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers]; export { authHandlers } from './auth/handlers'; export { userHandlers } from './users/handlers'; export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers, setupNetworkErrorHandlers } from './setup/handlers'; export { invitationHandlers } from './invitation/handlers'; export { availabilityHandlers } from './availability/handlers'; +export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from './cms/handlers'; export * from './auth/fixtures'; diff --git a/frontend/src/pages/CmsPage.test.tsx b/frontend/src/pages/CmsPage.test.tsx index 8a24d29..fd98470 100644 --- a/frontend/src/pages/CmsPage.test.tsx +++ b/frontend/src/pages/CmsPage.test.tsx @@ -1,19 +1,70 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils'; +import { server } from '@/mocks/server'; +import { API_BASE } from '@/mocks/auth/fixtures'; +import { resetMockCmsInstances } from '@/mocks/cms/handlers'; import { _resetSetupStatusCache } from '@/router'; beforeEach(() => { _resetSetupStatusCache(); + resetMockCmsInstances(); }); +const CMS_URL = `${API_BASE}/api/v1/CmsInstances`; + describe('CmsPage', () => { - it('renders the CMS page title and placeholder', async () => { + it('renders the page title and Add button', async () => { mockAuthenticated(); renderApp('/cms'); expect(await screen.findByTestId('cms-title', {}, { timeout: 5000 })).toBeInTheDocument(); - expect(screen.getByTestId('cms-placeholder')).toBeInTheDocument(); + expect(screen.getByTestId('cms-add-button')).toBeInTheDocument(); + }); + + it('renders list of CMS instances from seed data', async () => { + mockAuthenticated(); + renderApp('/cms'); + + const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 }); + expect(rows.length).toBe(2); + }); + + it('renders empty state when no instances exist', async () => { + mockAuthenticated(); + server.use(http.get(CMS_URL, () => HttpResponse.json([]))); + renderApp('/cms'); + + expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(screen.getByTestId('cms-empty-add-button')).toBeInTheDocument(); + }); + + it('opens AddCmsInstanceDialog when Add button is clicked', async () => { + mockAuthenticated(); + renderApp('/cms'); + + await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await userEvent.click(screen.getByTestId('cms-add-button')); + + expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument(); + }); + + it('adds a CMS instance successfully', async () => { + mockAuthenticated(); + renderApp('/cms'); + + await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await userEvent.click(screen.getByTestId('cms-add-button')); + + await userEvent.type(screen.getByTestId('add-cms-name'), 'New CMS'); + await userEvent.type(screen.getByTestId('add-cms-url'), 'http://new.example.com'); + await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'secret-key'); + await userEvent.click(screen.getByTestId('add-cms-submit')); + + const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 }); + expect(rows.length).toBe(3); }); it('redirects unauthenticated users to login', async () => { diff --git a/frontend/src/pages/CmsPage.tsx b/frontend/src/pages/CmsPage.tsx index 6798b61..f596f35 100644 --- a/frontend/src/pages/CmsPage.tsx +++ b/frontend/src/pages/CmsPage.tsx @@ -1,17 +1,72 @@ +import { useState } from 'react'; import { LayoutGrid } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { CmsInstanceList } from '@/components/cms/CmsInstanceList'; +import { AddCmsInstanceDialog } from '@/components/cms/AddCmsInstanceDialog'; +import { SetStatusDialog } from '@/components/cms/SetStatusDialog'; +import { useCmsInstances } from '@/api/useCmsInstances'; +import type { CmsInstance } from '@/api/types'; export function CmsPage() { const { t } = useTranslation(); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [statusTarget, setStatusTarget] = useState(null); + const { data: instances, isPending, isError } = useCmsInstances(); + return ( -
- -

- {t('cms.title')} -

-

- {t('cms.description')} -

+
+
+

+ {t('cms.title')} +

+ +
+ + {isPending && ( +

{t('common.loading')}

+ )} + + {isError && ( +

{t('errors.generic')}

+ )} + + {!isPending && !isError && instances?.length === 0 && ( +
+ +

{t('cms.emptyState.heading')}

+

{t('cms.emptyState.description')}

+ +
+ )} + + {!isPending && !isError && instances && instances.length > 0 && ( + setStatusTarget(instance)} + /> + )} + + + + { if (!open) setStatusTarget(null); }} + />
); } diff --git a/src/SlpModularCms.Modules.Master/Controllers/CmsInstanceController.cs b/src/SlpModularCms.Modules.Master/Controllers/CmsInstanceController.cs index 97ffaa8..d1af494 100644 --- a/src/SlpModularCms.Modules.Master/Controllers/CmsInstanceController.cs +++ b/src/SlpModularCms.Modules.Master/Controllers/CmsInstanceController.cs @@ -6,7 +6,7 @@ using SlpModularCms.Modules.Master.Services; namespace SlpModularCms.Modules.Master.Controllers; [ApiController] -[Route("[controller]")] +[Route("CmsInstances")] [Authorize(Policy = "OwnerOnly")] public class CmsInstanceController(ICmsInstanceService service) : ControllerBase { diff --git a/src/SlpModularCms.Modules.Master/Migrations/20260630210103_InitialCreate.Designer.cs b/src/SlpModularCms.Modules.Master/Migrations/20260630210103_InitialCreate.Designer.cs new file mode 100644 index 0000000..7242bcc --- /dev/null +++ b/src/SlpModularCms.Modules.Master/Migrations/20260630210103_InitialCreate.Designer.cs @@ -0,0 +1,72 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SlpModularCms.Modules.Master.Data; + +#nullable disable + +namespace SlpModularCms.Modules.Master.Migrations +{ + [DbContext(typeof(MasterDbContext))] + [Migration("20260630210103_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SlpModularCms.Modules.Master.Data.Entities.CmsInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DisableMessage") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("LastContactedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastIntegrityCheckFailedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastStatusPushedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.ToTable("MasterCmsInstances", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SlpModularCms.Modules.Master/Migrations/20260630210103_InitialCreate.cs b/src/SlpModularCms.Modules.Master/Migrations/20260630210103_InitialCreate.cs new file mode 100644 index 0000000..d339965 --- /dev/null +++ b/src/SlpModularCms.Modules.Master/Migrations/20260630210103_InitialCreate.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SlpModularCms.Modules.Master.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MasterCmsInstances", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Url = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + ApiKey = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + Status = table.Column(type: "int", nullable: false), + DisableMessage = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + LastContactedAt = table.Column(type: "datetimeoffset", nullable: true), + LastStatusPushedAt = table.Column(type: "datetimeoffset", nullable: true), + LastIntegrityCheckFailedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MasterCmsInstances", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MasterCmsInstances"); + } + } +} diff --git a/src/SlpModularCms.Modules.Master/Migrations/MasterDbContextModelSnapshot.cs b/src/SlpModularCms.Modules.Master/Migrations/MasterDbContextModelSnapshot.cs new file mode 100644 index 0000000..7e1c2be --- /dev/null +++ b/src/SlpModularCms.Modules.Master/Migrations/MasterDbContextModelSnapshot.cs @@ -0,0 +1,69 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SlpModularCms.Modules.Master.Data; + +#nullable disable + +namespace SlpModularCms.Modules.Master.Migrations +{ + [DbContext(typeof(MasterDbContext))] + partial class MasterDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SlpModularCms.Modules.Master.Data.Entities.CmsInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DisableMessage") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("LastContactedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastIntegrityCheckFailedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastStatusPushedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.ToTable("MasterCmsInstances", (string)null); + }); +#pragma warning restore 612, 618 + } + } +}