Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -0,0 +1,76 @@
# Business Logic Model — Unit 3: frontend-cms-page
## Component Orchestration
`CmsPage` is the root orchestrator. It owns all modal state and delegates data operations to hooks.
```
CmsPage
├── useCmsInstances() → fetches instance list
├── CmsInstanceList → renders table, emits onSetStatus
├── AddCmsInstanceDialog → useAddCmsInstance() internally
└── SetStatusDialog → useUpdateCmsInstanceStatus() internally
```
---
## Data Flows
### Flow 1 — Load instances
```
CmsPage mounts
→ useCmsInstances fires GET /api/v1/CmsInstances
→ isPending: show loading indicator
→ isError: show error message
→ data=[]: render EmptyState (centered placeholder + Add button)
→ data=[...]: render CmsInstanceList
```
### Flow 2 — Add CmsInstance
```
User clicks "Add CMS" button
→ addDialogOpen = true → AddCmsInstanceDialog opens
User fills Name, URL, ApiKey and submits
→ useAddCmsInstance fires POST /api/v1/CmsInstances
→ HTTP 201: close dialog, invalidate ['cmsInstances'], toast success
→ HTTP 400: show FormBannerError inside dialog (do not close)
→ Network error: show FormBannerError inside dialog
```
### Flow 3 — Update instance status
```
User clicks "Set Status" in row actions dropdown
→ statusTarget = instance → SetStatusDialog opens
User selects status, optionally fills DisableMessage, submits
→ useUpdateCmsInstanceStatus fires PUT /api/v1/CmsInstances/{id}/status
→ HTTP 200 + slaveContactSuccess=true:
close dialog, invalidate ['cmsInstances'],
toast "Status updated — cliënt confirmed"
→ HTTP 200 + slaveContactSuccess=false:
close dialog, invalidate ['cmsInstances'],
toast "Status saved — cliënt unreachable"
→ HTTP 404: toast error "Instance not found"
→ HTTP 400: toast error (generic)
→ Network error: toast error (generic)
```
### Flow 4 — DisableMessage conditional visibility
```
SetStatusDialog: status field changes
→ status === 'NotAvailable': show DisableMessage field (required)
→ status !== 'NotAvailable': hide DisableMessage field, clear value
```
---
## Cache Strategy
| Hook | Query key | Stale time | Invalidated by |
|------|-----------|------------|----------------|
| `useCmsInstances` | `['cmsInstances']` | 30 000 ms | `useAddCmsInstance.onSuccess`, `useUpdateCmsInstanceStatus.onSuccess` |
| `useAddCmsInstance` | — | — | — |
| `useUpdateCmsInstanceStatus` | — | — | — |
@@ -0,0 +1,49 @@
# Business Rules — Unit 3: frontend-cms-page
## Table Display (Q1: B+C)
- **BR-FE-01**: The `CmsInstanceList` table displays six columns: Name, URL, Status, Last Contact, DisableMessage, Actions.
- **BR-FE-02**: `lastContactedAt` is formatted as a localised date/time string; displays "—" when `null`.
- **BR-FE-03**: `disableMessage` displays "—" when `null` or empty.
## Inactive Row Styling (Q2: A)
- **BR-FE-04**: A `<TableRow>` whose `instance.status === 'Inactive'` receives `className="opacity-50"`. All cells within that row are visually dimmed as a result.
## Status Badge Colors
- **BR-FE-05**: `Available` → green badge (`secondary` or custom green variant).
- **BR-FE-06**: `NotAvailable` → red badge (`destructive` variant).
- **BR-FE-07**: `Inactive` → muted badge (`outline` variant).
## SetStatusDialog — DisableMessage (Q3 note, existing unit-of-work)
- **BR-FE-08**: The DisableMessage field is only rendered when the selected status is `NotAvailable`.
- **BR-FE-09**: DisableMessage is required (non-empty) when status is `NotAvailable`. Form submit is blocked if it is empty.
- **BR-FE-10**: When status changes away from `NotAvailable`, the DisableMessage field is hidden and its value is reset to `""`.
- **BR-FE-11**: The value sent to the API is `null` for all statuses except `NotAvailable`; for `NotAvailable` it is the trimmed string value.
## UpdateStatusResult Toast (Q3: B, "cliënt" terminology)
- **BR-FE-12**: On HTTP 200 with `slaveContactSuccess === true`: show toast "Status updated — cliënt confirmed".
- **BR-FE-13**: On HTTP 200 with `slaveContactSuccess === false`: show toast "Status saved — cliënt unreachable".
- **BR-FE-14**: The word "slave" must not appear in any user-visible text. Use "cliënt" in all UI strings.
## AddCmsInstanceDialog Error Handling (Q4: A)
- **BR-FE-15**: On HTTP 400 from `POST /api/v1/CmsInstances`: display a `FormBannerError` at the top of the dialog. The dialog remains open.
- **BR-FE-16**: On network error: display a `FormBannerError` at the top of the dialog. The dialog remains open.
- **BR-FE-17**: On success (HTTP 201): close the dialog and show a success toast.
## Empty State (Q5: A)
- **BR-FE-18**: When `useCmsInstances` returns an empty array, render a centered placeholder instead of the table. The placeholder contains:
- A `LayoutGrid` icon (consistent with the existing `CmsPage` placeholder).
- A heading: "No CMS instances yet".
- A description: "Add your first CMS instance to get started."
- An "Add CMS" button that opens `AddCmsInstanceDialog`.
- **BR-FE-19**: The "Add CMS" button in the page header is always visible regardless of empty state.
## Access Control
- **BR-FE-20**: The `/cms` route is already guarded by `RoleGuard allowedRoles={['Owner']}`. No additional guard logic is needed inside `CmsPage`.
@@ -0,0 +1,74 @@
# Domain Entities — Unit 3: frontend-cms-page
## TypeScript Types
### CmsInstanceStatus
```typescript
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
```
String union matching `CmsInstanceStatus` enum values serialized by the backend (`Available`, `NotAvailable`, `Inactive`).
---
### CmsInstance
```typescript
export interface CmsInstance {
id: string; // UUID
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null; // ISO 8601, null if never contacted
lastStatusPushedAt: string | null; // ISO 8601
lastIntegrityCheckFailedAt: string | null; // ISO 8601
}
```
---
### CreateCmsInstanceRequest
```typescript
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
```
Sent as body to `POST /api/v1/CmsInstances`.
---
### UpdateCmsInstanceStatusRequest
```typescript
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
```
Sent as body to `PUT /api/v1/CmsInstances/{id}/status`. `disableMessage` is `null` unless `status === 'NotAvailable'`.
---
### UpdateStatusResult
```typescript
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean; // true = cliënt was reachable and confirmed
}
```
Returned by `PUT /api/v1/CmsInstances/{id}/status`. `slaveContactSuccess` drives the differentiated toast message.
---
## File Location
All types added to `src/api/types.ts`.
@@ -0,0 +1,214 @@
# Frontend Components — Unit 3: frontend-cms-page
## Component Hierarchy
```
CmsPage (src/pages/CmsPage.tsx)
├── AddCmsInstanceDialog (src/components/cms/AddCmsInstanceDialog.tsx)
├── SetStatusDialog (src/components/cms/SetStatusDialog.tsx)
└── CmsInstanceList (src/components/cms/CmsInstanceList.tsx)
```
---
## CmsPage
**File**: `src/pages/CmsPage.tsx`
**State**:
| State | Type | Initial | Description |
|-------|------|---------|-------------|
| `addDialogOpen` | `boolean` | `false` | Controls AddCmsInstanceDialog visibility |
| `statusTarget` | `CmsInstance \| null` | `null` | Instance passed to SetStatusDialog; null = closed |
**Hooks**: `useCmsInstances()`
**Render logic**:
```
Header row: title + "Add CMS" button (always visible)
isPending → loading text
isError → error text
data === [] → EmptyState (icon + heading + description + Add button)
data.length > 0 → CmsInstanceList
AddCmsInstanceDialog (controlled by addDialogOpen)
SetStatusDialog (controlled by statusTarget !== null)
```
---
## CmsInstanceList
**File**: `src/components/cms/CmsInstanceList.tsx`
**Props**:
```typescript
interface CmsInstanceListProps {
instances: CmsInstance[];
onSetStatus: (instance: CmsInstance) => void;
}
```
**Columns** (BR-FE-01):
| Column | Source field | Notes |
|--------|-------------|-------|
| Name | `instance.name` | — |
| URL | `instance.url` | — |
| Status | `instance.status` | Rendered as badge (BR-FE-05..07) |
| Last Contact | `instance.lastContactedAt` | Formatted date; "—" when null (BR-FE-02) |
| Disable Message | `instance.disableMessage` | "—" when null (BR-FE-03) |
| Actions | — | Dropdown with "Set Status" item |
**Row styling**: `<TableRow className={instance.status === 'Inactive' ? 'opacity-50' : ''}>` (BR-FE-04)
**Interactions**: Clicking "Set Status" in the actions dropdown calls `onSetStatus(instance)`.
---
## AddCmsInstanceDialog
**File**: `src/components/cms/AddCmsInstanceDialog.tsx`
**Props**:
```typescript
interface AddCmsInstanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Form fields**:
| Field | Type | Validation |
|-------|------|-----------|
| Name | text | Required, non-empty |
| URL | text | Required, valid URL format |
| ApiKey | password | Required, non-empty |
**Hooks**: `useAddCmsInstance()`
**Submit behaviour**:
- Success (HTTP 201): call `onOpenChange(false)`, show success toast
- HTTP 400: show `FormBannerError` inside dialog, keep open (BR-FE-15)
- Network error: show `FormBannerError` inside dialog, keep open (BR-FE-16)
---
## SetStatusDialog
**File**: `src/components/cms/SetStatusDialog.tsx`
**Props**:
```typescript
interface SetStatusDialogProps {
instance: CmsInstance | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Form fields**:
| Field | Type | Condition | Validation |
|-------|------|-----------|-----------|
| Status | select (`Available` \| `NotAvailable` \| `Inactive`) | always | Required |
| DisableMessage | text | only when `status === 'NotAvailable'` | Required when visible |
**Hooks**: `useUpdateCmsInstanceStatus()`
**Submit behaviour**:
- HTTP 200 + `slaveContactSuccess=true`: close dialog, toast "Status updated — cliënt confirmed" (BR-FE-12)
- HTTP 200 + `slaveContactSuccess=false`: close dialog, toast "Status saved — cliënt unreachable" (BR-FE-13)
- HTTP 404: toast error, keep open
- HTTP 400 / network error: toast error, keep open
**DisableMessage field logic** (BR-FE-08..11):
- Rendered only when selected status is `'NotAvailable'`
- On status change away from `'NotAvailable'`: clear field value
- Value sent to API: `null` unless status is `'NotAvailable'`
---
## Hooks
### useCmsInstances
**File**: `src/api/useCmsInstances.ts`
```typescript
useQuery<CmsInstance[], Error>({
queryKey: ['cmsInstances'],
queryFn: () => api.get<CmsInstance[]>('/api/v1/CmsInstances'),
staleTime: 30_000,
})
```
### useAddCmsInstance
**File**: `src/api/useAddCmsInstance.ts`
```typescript
useMutation<CmsInstance, Error, CreateCmsInstanceRequest>({
mutationFn: (data) => api.post<CmsInstance>('/api/v1/CmsInstances', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
})
```
### useUpdateCmsInstanceStatus
**File**: `src/api/useUpdateCmsInstanceStatus.ts`
```typescript
useMutation<UpdateStatusResult, Error, { id: string } & UpdateCmsInstanceStatusRequest>({
mutationFn: ({ id, ...body }) =>
api.put<UpdateStatusResult>(`/api/v1/CmsInstances/${id}/status`, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
})
```
---
## i18n Keys (additions to `cms` namespace)
```json
"cms": {
"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",
"never": "—"
},
"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"
},
"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"
}
}
```