merge: object form robustness — data router, dirty guard, validation, batch entry (#46)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,372 @@
|
|||||||
|
# Object Form Robustness Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the object create/edit form safe for long daily sessions — no double-submit, an unsaved-changes guard, one consistent partial-failure recovery, code-aware validation messages, and batch-entry ergonomics.
|
||||||
|
|
||||||
|
**Architecture:** Migrate to a React Router data router (enables `useBlocker`) keeping the route tree verbatim. The form is react-hook-form; `isSubmitting` (made real by returning the async `onSubmit` from `handleSubmit`) drives submit-disable, and `useBlocker(isDirty && !isSubmitting)` drives the dirty guard — so save-driven navigation is never falsely blocked and Cancel flows through the same dialog. `onSubmit` returns a success boolean so the form can reset for "Save & create another".
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + TS + pnpm, react-router-dom 7 (data router), react-hook-form, react-i18next, Base UI (alert-dialog), Vitest + RTL + MSW. Test runner: `pnpm test` (single pass).
|
||||||
|
|
||||||
|
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; ui/ no-semicolon, app source double-quote+semicolon; token classes only; guard DOM globals; **run tests exactly once per task.**
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-07-object-form-robustness-design.md`
|
||||||
|
|
||||||
|
**Key facts (from the code):**
|
||||||
|
- `object-form.tsx`: RHF `useForm<FormShape>`; `submit = handleSubmit((data) => { onSubmit(...) })` — **does not return the promise** (so `isSubmitting` never tracks it; fix in T2). Props: `mode/defaults/onSubmit/onCancel/formError/fieldErrorKey`. `coreField` renders `errors.core?.[key] && t("form.required")` always. `number_of_objects` registered via `coreField(..., { type: "number", required: true })`.
|
||||||
|
- `object-new-page.tsx`: `onSubmit` create→setFields; on setFields fail `navigate(\`/objects/${id}/edit\`, { state: { fieldsError, fieldErrorKey } })`; success → `/objects/${id}`.
|
||||||
|
- `object-edit-form.tsx`: split into `ObjectEditFormLoaded`; reads `location.state` (`fieldsError`/`fieldErrorKey`) to seed the banner; `onSubmit` update→setFields; on `FieldRejection` sets `fieldErrorKey` + banner, stays.
|
||||||
|
- `FieldRejection` carries `field` + `code`. `useCreateObject/useUpdateObject/useSetFields` expose `.isPending` (unused today).
|
||||||
|
- Router: `app.tsx` `<BrowserRouter><Routes>` (3 top-level siblings). `renderApp` wraps `ui` in `<MemoryRouter>` with no `<Routes>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 1: Migrate to a data router (foundation)
|
||||||
|
|
||||||
|
**Files:** `web/src/app.tsx`, `web/src/test/render.tsx`. (Possibly `main.tsx` — only if needed; it should NOT need changes since `App` stays the exported component.)
|
||||||
|
|
||||||
|
- [ ] **Step 1: `app.tsx` → data router.** Convert the JSX route tree verbatim using `createRoutesFromElements`. Replace the `import { BrowserRouter, Navigate, Route, Routes }` with `import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom";`. Keep all the `lazy`/`Suspense` wrappers and every `<Route>` exactly as-is. New shape:
|
||||||
|
```tsx
|
||||||
|
const router = createBrowserRouter(
|
||||||
|
createRoutesFromElements(
|
||||||
|
<>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
{/* ...all the existing nested <Route> elements, verbatim... */}
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Do NOT change any path, element, Suspense, or nesting. (The `FormFallback` + lazy imports stay.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: `test/render.tsx` → `createMemoryRouter`.** Replace `MemoryRouter` usage:
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
|
import "../i18n";
|
||||||
|
|
||||||
|
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const router = createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] });
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This is behavior-preserving: `ui` renders at `route`; tests that include their own `<Routes>` still nest under the `*` route; now a data-router context exists (so `useBlocker` works later).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full suite must stay green (the migration gate). Run ONCE:**
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build
|
||||||
|
```
|
||||||
|
Expected: ALL existing tests pass unchanged. If a test fails because it relied on `MemoryRouter`-specific behavior (e.g., asserting a redirect, or a component that rendered without its own `<Routes>` and needs params), investigate and fix the test's setup to the data-router equivalent WITHOUT weakening it. Report any test that needed adjustment and why. If many break, STOP and report (the migration approach may need a tweak) rather than mass-editing.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/app.tsx web/src/test/render.tsx
|
||||||
|
git commit -m "refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 2: Submit-disable + keyboard submit + "Save & create another"
|
||||||
|
|
||||||
|
**Files:** `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, tests.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add to the `form` namespace in BOTH locales (parity):
|
||||||
|
- en: `"saving": "Saving…"`, `"createAnother": "Save & create another"`
|
||||||
|
- sv: `"saving": "Sparar…"`, `"createAnother": "Spara & skapa ny"`
|
||||||
|
|
||||||
|
- [ ] **Step 2: ObjectForm — make `isSubmitting` real + disable + the new button + Cmd/Ctrl+Enter.**
|
||||||
|
- Change the `onSubmit` prop type to: `onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise<boolean> | boolean;`
|
||||||
|
- Destructure `isSubmitting`: `const { register, handleSubmit, formState: { errors, isSubmitting } } = form;`
|
||||||
|
- Add a ref: `const createAnotherRef = useRef(false);`
|
||||||
|
- Rewrite `submit` to RETURN/await the promise (so RHF tracks it) and reset on create-another success:
|
||||||
|
```tsx
|
||||||
|
const submit = handleSubmit(async (data) => {
|
||||||
|
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
|
||||||
|
const values =
|
||||||
|
mode === "create"
|
||||||
|
? { core: data.core, visibility: data.visibility, fields }
|
||||||
|
: { core: data.core, fields };
|
||||||
|
const createAnother = createAnotherRef.current;
|
||||||
|
createAnotherRef.current = false;
|
||||||
|
const ok = await onSubmit(values, { createAnother });
|
||||||
|
if (ok && createAnother) {
|
||||||
|
form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} });
|
||||||
|
document.getElementById("object_number")?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Add a keydown handler on the `<form>` for Cmd/Ctrl+Enter:
|
||||||
|
`onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); void submit(); } }}`
|
||||||
|
- Footer buttons:
|
||||||
|
```tsx
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting} onClick={() => (createAnotherRef.current = false)}>
|
||||||
|
{isSubmitting ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||||
|
</Button>
|
||||||
|
{mode === "create" && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => (createAnotherRef.current = true)}
|
||||||
|
>
|
||||||
|
{t("form.createAnother")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="ghost" disabled={isSubmitting} onClick={onCancel}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
(Add `useRef` to the React import. `variant="secondary"` — confirm it exists in `ui/button.tsx`; if not, use `variant="outline"` or default — check.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update pages' `onSubmit` to return `boolean` + honor `createAnother`.**
|
||||||
|
- `object-new-page.tsx`:
|
||||||
|
```tsx
|
||||||
|
const onSubmit = async (values: ObjectFormValues, opts?: { createAnother?: boolean }): Promise<boolean> => {
|
||||||
|
setError(null);
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const created = await create.mutateAsync({ ...values.core, visibility: values.visibility ?? "draft" });
|
||||||
|
id = created.id;
|
||||||
|
} catch {
|
||||||
|
setError(t("form.rejected"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Object.keys(values.fields).length > 0) {
|
||||||
|
try {
|
||||||
|
await setFields.mutateAsync({ id, fields: values.fields });
|
||||||
|
} catch (e) {
|
||||||
|
const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined;
|
||||||
|
const fieldErrorCode = e instanceof FieldRejection ? e.code : undefined;
|
||||||
|
navigate(`/objects/${id}/edit`, { state: { created: true, fieldErrorKey, fieldErrorCode } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts?.createAnother) return true; // success; ObjectForm resets, stays on /objects/new
|
||||||
|
navigate(`/objects/${id}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- `object-edit-form.tsx` `ObjectEditFormLoaded.onSubmit`: return `false` in the catch, `true` after the success navigate. (Edit mode never passes `createAnother`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tests.** Extend `object-form.test.tsx` / `object-new-page.test.tsx`:
|
||||||
|
- During an in-flight create (MSW delayed handler, or assert the button is `disabled` + shows "Saving…" synchronously after submit), the create mutation is called exactly once on a double-click. (If timing is hard, at minimum assert the button becomes `disabled` while submitting and reads `t("form.saving")`.)
|
||||||
|
- "Save & create another": click it in create mode with a delayed/immediate success handler → after success the form is reset (e.g., `object_number` input is empty) and the location is still `/objects/new` (no navigation to detail). Use the renderApp data-router harness; assert location via a probe or that the form is still present + cleared.
|
||||||
|
- Cmd/Ctrl+Enter triggers submit (fireEvent.keyDown with `{ key: "Enter", metaKey: true }` → the create mutation fires).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify (vitest ONCE).** `cd web && pnpm vitest run src/objects && pnpm typecheck && pnpm lint`. Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/objects/object-form.tsx web/src/objects/object-new-page.tsx web/src/objects/object-edit-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-form.test.tsx web/src/objects/object-new-page.test.tsx
|
||||||
|
git commit -m "feat(web): disable submit while saving + Save & create another + Cmd/Ctrl+Enter (#46)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 3: Validation messages (server code echo, type-specific core errors, min count)
|
||||||
|
|
||||||
|
**Files:** `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, tests.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add to the `form` namespace (both locales, parity):
|
||||||
|
- en: `"minCount": "Must be at least 1"`, and a nested `"fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }`.
|
||||||
|
- sv: `"minCount": "Måste vara minst 1"`, `"fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: ObjectForm — carry the rejection `code` + type-specific messages.**
|
||||||
|
- Add prop `fieldErrorCode?: string | null;` (alongside `fieldErrorKey`).
|
||||||
|
- The highlight effect picks the code-specific message:
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
if (fieldErrorKey) {
|
||||||
|
const codeKey = fieldErrorCode ? `form.fieldError.${fieldErrorCode}` : "";
|
||||||
|
const message =
|
||||||
|
fieldErrorCode && t(codeKey) !== codeKey ? t(codeKey) : t("form.fieldRejected", { field: fieldErrorKey });
|
||||||
|
form.setError(`fields.${fieldErrorKey}` as never, { type: "server", message });
|
||||||
|
}
|
||||||
|
}, [fieldErrorKey, fieldErrorCode, form, t]);
|
||||||
|
```
|
||||||
|
- Core error render → message-aware (so `min` shows minCount, required falls back):
|
||||||
|
```tsx
|
||||||
|
{errors.core?.[key] && (
|
||||||
|
<p role="alert" className="text-xs text-destructive">
|
||||||
|
{errors.core[key]?.message || t("form.required")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
- `number_of_objects` min: in `coreField`, when registering a number with required, also pass `min`. Simplest: special-case the count field by giving `coreField` an optional `min` and rendering. Concretely change the `number_of_objects` registration to include `min: { value: 1, message: t("form.minCount") }`. Implement by extending `coreField`'s `opts` with `min?: number` and, when set, register `{ valueAsNumber: true, required, min: { value: opts.min, message: t("form.minCount") } }`; call `coreField("number_of_objects", "count", { type: "number", required: true, min: 1 })`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Pass the code through the pages.**
|
||||||
|
- `object-edit-form.tsx`: in the `FieldRejection` catch, also `setFieldErrorCode(e.code)` (add a `fieldErrorCode` state) and pass `fieldErrorCode` to `<ObjectForm>`. Also seed it from `location.state.fieldErrorCode` (set by the create teleport). The banner stays `form.fieldRejected` (or upgrade to code-specific too — optional; the field highlight is the key UX).
|
||||||
|
- `<ObjectForm ... fieldErrorKey={fieldErrorKey} fieldErrorCode={fieldErrorCode} />`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tests.** Extend `object-edit-form.test.tsx`:
|
||||||
|
- A `setFields` 422 with `{ field: "...", code: "type_mismatch" }` → the field shows the `form.fieldError.type_mismatch` message (assert the text).
|
||||||
|
- `number_of_objects` set to `0` and submit → the `form.minCount` message shows and NO create/update mutation is called (client-side block). (In `object-form.test.tsx` or a page test.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify (vitest ONCE).** `cd web && pnpm vitest run src/objects && pnpm typecheck && pnpm lint`. PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/objects/object-form.tsx web/src/objects/object-edit-form.tsx web/src/objects/object-new-page.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-edit-form.test.tsx web/src/objects/object-form.test.tsx
|
||||||
|
git commit -m "feat(web): code-aware field errors + min count validation (#46)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 4: Unsaved-changes guard
|
||||||
|
|
||||||
|
**Files:** `web/src/lib/use-unsaved-changes.tsx` (new), `web/src/objects/object-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/lib/use-unsaved-changes.test.tsx` (new) or extend object-form tests.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add a `form.unsaved` namespace (both locales, parity):
|
||||||
|
- en: `"unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" }`
|
||||||
|
- sv: `"unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" }`
|
||||||
|
|
||||||
|
- [ ] **Step 2: The hook + dialog** `web/src/lib/use-unsaved-changes.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useBlocker } from "react-router-dom";
|
||||||
|
|
||||||
|
export function useUnsavedChanges(active: boolean) {
|
||||||
|
const blocker = useBlocker(active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handler);
|
||||||
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return blocker;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
And an `UnsavedChangesDialog` component (same file or a sibling) using `ui/alert-dialog`, driven by the blocker:
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Blocker } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export function UnsavedChangesDialog({ blocker }: { blocker: Blocker }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = blocker.state === "blocked";
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={(o) => { if (!o) blocker.reset?.(); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("form.unsaved.title")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("form.unsaved.body")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => blocker.reset?.()}>{t("form.unsaved.stay")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => blocker.proceed?.()}>{t("form.unsaved.leave")}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialog>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
IMPORTANT: open `web/src/components/ui/alert-dialog.tsx` and match the EXACT exported part names/props (`AlertDialog` may take `open`/`onOpenChange`, or be trigger-driven — adapt to the real API; the delete dialogs in `web/src/objects/delete-object-dialog.tsx` / `components/delete-confirm-dialog.tsx` show the controlled usage to mirror). The dialog must be openable WITHOUT a trigger (controlled by `open`). Validate by running the test.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire into ObjectForm.**
|
||||||
|
- `const isDirty = form.formState.isDirty;`
|
||||||
|
- `const blocker = useUnsavedChanges(isDirty && !isSubmitting);`
|
||||||
|
- Render `<UnsavedChangesDialog blocker={blocker} />` inside the form's container.
|
||||||
|
- **Cancel** now just calls `onCancel` (which navigates) — the blocker intercepts it and shows the dialog automatically when dirty. (No separate confirm needed; confirm this in the test.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tests** `web/src/lib/use-unsaved-changes.test.tsx` (and/or extend object-form):
|
||||||
|
- Render a small component (or the ObjectForm) under the `renderApp` data-router harness with two routes; with a dirty form, click a `<Link>`/Cancel → the dialog appears; "Keep editing" stays (location unchanged), "Discard" proceeds (location changes).
|
||||||
|
- `beforeunload`: with `active=true`, a `beforeunload` event is registered (spy on `window.addEventListener`) and not when inactive.
|
||||||
|
- A clean form navigates without the dialog.
|
||||||
|
- Saving (isSubmitting true) does NOT block — simulate or assert via the `isDirty && !isSubmitting` condition (e.g., the blocker arg is false during submit).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify (vitest ONCE).** `cd web && pnpm vitest run src/objects src/lib && pnpm typecheck && pnpm lint`. PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/lib/use-unsaved-changes.tsx web/src/objects/object-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/lib/use-unsaved-changes.test.tsx
|
||||||
|
git commit -m "feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 5: Partial-failure unification + final gate
|
||||||
|
|
||||||
|
**Files:** `web/src/objects/object-edit-form.tsx`, `web/src/i18n/en.json`, `web/src/i18n/sv.json`, tests.
|
||||||
|
|
||||||
|
(Task 2 already changed the create page to pass `state: { created: true, fieldErrorKey, fieldErrorCode }`. This task handles the edit page's reading of it + messaging.)
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — add (both locales, parity): en `"createdButFieldRejected": "Object created, but a field was rejected — fix it below."`; sv `"createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan."`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Edit page reads `created`.** In `ObjectEditFormLoaded`, broaden the `locationState` type to `{ created?: boolean; fieldsError?: boolean; fieldErrorKey?: string; fieldErrorCode?: string } | null` and seed the banner:
|
||||||
|
```tsx
|
||||||
|
const [error, setError] = useState<string | null>(() => {
|
||||||
|
if (locationState?.created) return t("form.createdButFieldRejected");
|
||||||
|
if (locationState?.fieldErrorKey) return t("form.fieldRejected", { field: locationState.fieldErrorKey });
|
||||||
|
if (locationState?.fieldsError) return t("form.rejected");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(locationState?.fieldErrorKey ?? null);
|
||||||
|
const [fieldErrorCode, setFieldErrorCode] = useState<string | null>(locationState?.fieldErrorCode ?? null);
|
||||||
|
```
|
||||||
|
(Keep backward compatibility: the create page from T2 sends `created`; older `fieldsError` branch can remain or be removed since T2 replaced it — remove `fieldsError` seeding if no longer sent.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Tests.** Update `object-new-page.test.tsx`: the create→setFields-422 path now navigates to `/objects/:id/edit` and the edit form shows the `form.createdButFieldRejected` banner + highlights the field. (The existing partial-failure test asserted the old `fieldsError` flow — update it to the new `created` message, not weakened.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: FULL GATE (run tests EXACTLY ONCE):**
|
||||||
|
```bash
|
||||||
|
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||||
|
```
|
||||||
|
All green. Report test totals, largest chunk, check:colors line.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Codename + status:**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual smoke (recommended).** `pnpm dev`: create with a bad field → lands on edit with "Object created, but a field was rejected"; submit disables + "Saving…"; edit a field then try to leave (sidebar/Cancel/reload) → guard prompts; "Save & create another" resets; count 0 blocked client-side; Cmd+Enter submits.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
```bash
|
||||||
|
git add web/src/objects/object-edit-form.tsx web/src/i18n/en.json web/src/i18n/sv.json web/src/objects/object-new-page.test.tsx
|
||||||
|
git commit -m "feat(web): unify create/edit partial-failure recovery with 'created' banner (#46)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:** data-router migration + harness, full suite green (T1); submit-disable via real `isSubmitting` + Cmd/Ctrl+Enter + Save-&-create-another (T2); code-aware field errors + type-specific core errors + min count (T3); unsaved-changes guard via `useBlocker(isDirty && !isSubmitting)` + beforeunload + dialog, Cancel through the blocker (T4); partial-failure unified to the edit route with a "created" banner (T5, building on T2's create-side state). All acceptance criteria 1–7 mapped. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** the alert-dialog wiring says "match the exact exported parts" with the delete dialogs named as the reference — a concrete adapt-to-real-API step, not a TODO. `variant="secondary"` flagged to verify against button.tsx. No vague steps; all code blocks complete. ✓
|
||||||
|
|
||||||
|
**Type/flow consistency:** `onSubmit` returns `Promise<boolean>|boolean` (T2) — both pages updated to return booleans; `createAnotherRef` gates the reset; `fieldErrorCode` prop added (T3) and threaded from both the edit catch and the create teleport state (T2/T5); the guard condition `isDirty && !isSubmitting` ensures save/teleport navigation (still submitting) is never blocked — consistent across T2/T4/T5. ✓
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The single biggest correctness lever: `handleSubmit` must RETURN/await `onSubmit` so `isSubmitting` is real (T2) — both the submit-disable AND the guard's non-blocking-while-saving depend on it.
|
||||||
|
- `useBlocker(boolean)` (RR v7) blocks all nav when true; Cancel and sidebar links both flow through the one dialog. Save-driven nav happens while `isSubmitting` → condition false → not blocked.
|
||||||
|
- No new dependency. New i18n keys: `form.saving/createAnother/minCount/createdButFieldRejected` + `form.fieldError.*` + `form.unsaved.*` (en+sv parity). No keys removed.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Object Form Robustness — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-07
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
**Issue:** #46.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The object create/edit form has data-integrity gaps that compound in long daily cataloguing sessions
|
||||||
|
(High severity):
|
||||||
|
- **Submit never disabled during save.** `object-form.tsx` submit button has no `disabled`/pending.
|
||||||
|
Both create (`object-new-page.tsx`) and edit (`object-edit-form.tsx`) do **two sequential awaited
|
||||||
|
mutations** (create/update, then `setFields`) with no feedback → a second click can duplicate-create
|
||||||
|
or race the two-step write. `publish-control.tsx` is the model (disables on `isPending`).
|
||||||
|
- **No unsaved-changes guard.** No `isDirty`/`useBlocker`/`beforeunload`; Cancel/nav loses work
|
||||||
|
silently.
|
||||||
|
- **Two-phase write recovers inconsistently.** Create teleports to the edit page on `setFields`
|
||||||
|
failure; edit stays put with a banner. The partial write (core saved, field rejected) reads as
|
||||||
|
nothing happened.
|
||||||
|
- **Thin validation.** Core errors always show `form.required`; the server `code`
|
||||||
|
(`type_mismatch`/`unresolved`/`unknown`) on `FieldRejection` is discarded; no `number_of_objects >= 1`.
|
||||||
|
- No "Save & create another" / keyboard submit for batch entry.
|
||||||
|
|
||||||
|
**Facts established:** the form uses **react-hook-form** (so `formState.isDirty` + `isSubmitting` are
|
||||||
|
available; `isSubmitting` stays true across both awaited mutations — the unified pending signal).
|
||||||
|
`ObjectForm` props: `mode`, `defaults`, `onSubmit`, `onCancel`, `formError`, `fieldErrorKey`; the
|
||||||
|
422→highlight path uses `form.setError`. `FieldRejection` (`queries.ts`) carries `field` + `code`.
|
||||||
|
The router is `<BrowserRouter>` + `<Routes>` (component router) in `app.tsx`; **`useBlocker` requires
|
||||||
|
a data router** (`createBrowserRouter` + `RouterProvider`). `renderApp` wraps `ui` in `<MemoryRouter>`
|
||||||
|
with no `<Routes>` (tests supply their own when they need params).
|
||||||
|
|
||||||
|
### Decisions (from brainstorming)
|
||||||
|
1. **Migrate to a data router** (the long-term-correct foundation; unblocks `useBlocker` and React
|
||||||
|
Router v7 data APIs). Done via `createRoutesFromElements` to keep the exact route tree (minimal
|
||||||
|
diff), isolated as the first task, with the test harness moved to `createMemoryRouter`.
|
||||||
|
2. **Unify create partial-failure** to the edit route with a distinct "Object created, but a field was
|
||||||
|
rejected" banner (single recovery surface).
|
||||||
|
3. **Include batch entry** ("Save & create another" + Cmd/Ctrl+Enter).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 0. Data-router migration (foundation — isolated)
|
||||||
|
- **`app.tsx`:** replace `<BrowserRouter><Routes>…</Routes></BrowserRouter>` with a module-level
|
||||||
|
`const router = createBrowserRouter(createRoutesFromElements(<>…the existing <Route> tree…</>))`
|
||||||
|
and `export function App() { return <RouterProvider router={router} />; }`. The route JSX (login,
|
||||||
|
`RequireAuth`→`AppShell` nest with all nested routes, lazy `Suspense` wrappers, splat) moves
|
||||||
|
**verbatim** into `createRoutesFromElements`. No behavior/path change.
|
||||||
|
- **`main.tsx`:** unchanged provider stack (QueryClient → Config → Toast) now wraps `<App/>` which is
|
||||||
|
`<RouterProvider/>`.
|
||||||
|
- **`test/render.tsx`:** `renderApp(ui, { route })` switches to
|
||||||
|
`createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] })` rendered via
|
||||||
|
`<RouterProvider/>` (inside `QueryClientProvider`). Behavior-preserving: `ui` renders at `route`,
|
||||||
|
and tests that supply their own `<Routes>` still nest correctly; now a data-router context exists so
|
||||||
|
`useBlocker` works. **Acceptance: the entire existing suite stays green** before any feature is built
|
||||||
|
on top.
|
||||||
|
|
||||||
|
### 1. Submit pending + keyboard + batch (`object-form.tsx`)
|
||||||
|
- Submit `<Button type="submit" disabled={form.formState.isSubmitting}>` showing
|
||||||
|
`isSubmitting ? t("form.saving") : (mode === "create" ? t("form.create") : t("form.save"))`. Disable
|
||||||
|
Cancel while submitting. (`isSubmitting` is true across both awaited mutations because RHF awaits the
|
||||||
|
async `onSubmit`.)
|
||||||
|
- **Cmd/Ctrl+Enter** submits: a keydown handler on the form (`(e.metaKey||e.ctrlKey) && e.key==="Enter"`
|
||||||
|
→ `form.handleSubmit(...)`), or rely on the native submit + a small handler. Implementer picks the
|
||||||
|
clean form; must not double-submit.
|
||||||
|
- **"Save & create another"** (create mode only): a secondary submit button. `ObjectForm` gains an
|
||||||
|
optional `onSubmitAndNew?: (values) => Promise<void>` (or a `submitAction` flag passed to `onSubmit`)
|
||||||
|
so the create page knows which button fired. On success it `form.reset(blankDefaults)` and focuses the
|
||||||
|
first field instead of navigating. Edit mode does not show this button.
|
||||||
|
|
||||||
|
### 2. Validation messages
|
||||||
|
- **Server `code` echo:** when a `FieldRejection` is shown, pick the message by `code` —
|
||||||
|
`form.fieldError.type_mismatch` / `form.fieldError.unresolved` / `form.fieldError.unknown` (fallback
|
||||||
|
to the existing `form.fieldRejected`). The create/edit pages already extract `e.field`; also pass
|
||||||
|
`e.code` through to `ObjectForm` (extend `fieldErrorKey` handling to carry a code, e.g. a
|
||||||
|
`fieldError?: { key: string; code?: string }` prop, or a second `fieldErrorCode?` prop). The
|
||||||
|
highlight `form.setError` message uses the code-specific string.
|
||||||
|
- **Core-field messages:** render the RHF error by `type` — `errors.core[key]?.type === "min"` →
|
||||||
|
`form.min` (with the limit), else `form.required`. (Today it's always `form.required`.)
|
||||||
|
- **`number_of_objects >= 1`:** register with `min: { value: 1, message: t("form.minCount") }` (RHF,
|
||||||
|
client-side, before the round-trip). Keep `required`.
|
||||||
|
|
||||||
|
### 3. Unsaved-changes guard (`lib/use-unsaved-changes.tsx`)
|
||||||
|
- `useUnsavedChanges(isDirty: boolean)`:
|
||||||
|
- adds a `beforeunload` listener while `isDirty` (covers reload / tab-close / browser back),
|
||||||
|
- calls `useBlocker(({currentLocation,nextLocation}) => isDirty && currentLocation.pathname !== nextLocation.pathname)` to block in-app navigation while dirty,
|
||||||
|
- returns the `blocker` so the caller renders an `UnsavedChangesDialog` when `blocker.state === "blocked"` (Stay → `blocker.reset()`, Leave → `blocker.proceed()`).
|
||||||
|
- **`UnsavedChangesDialog`** built on `ui/alert-dialog` (title/body/Stay/Leave). New i18n
|
||||||
|
`form.unsaved.{title,body,stay,leave}`.
|
||||||
|
- **Wire into `ObjectForm`:** `const isDirty = form.formState.isDirty;` → `useUnsavedChanges(isDirty)`;
|
||||||
|
render the dialog. The **Cancel** button: if dirty, open the same confirm dialog (Leave → `onCancel`);
|
||||||
|
if clean, `onCancel` directly.
|
||||||
|
- **Suppress on successful save:** after a successful submit the form navigates; `isDirty` must be false
|
||||||
|
by then (RHF `reset` on success, or the blocker condition excludes the programmatic post-save nav).
|
||||||
|
The create→edit partial-failure nav is programmatic and must not be blocked — reset dirty (or set a
|
||||||
|
"saving" ref that bypasses the blocker) around the save so the post-save/teleport navigation isn't
|
||||||
|
caught. Implementer ensures save-driven navigation never triggers the guard.
|
||||||
|
|
||||||
|
### 4. Partial-failure unification
|
||||||
|
- **Create** (`object-new-page.tsx`): on `setFields` failure after the core create succeeds, navigate
|
||||||
|
to `/objects/${id}/edit` with `state: { created: true, fieldErrorKey: e.field, fieldErrorCode: e.code }`
|
||||||
|
(today it passes `fieldsError`/`fieldErrorKey`; add `created` + `code`). The core-create already
|
||||||
|
succeeded, so this is correct (the object exists; editing it is the right URL).
|
||||||
|
- **Edit** (`object-edit-form.tsx`): read `location.state`. If `created`, show the banner
|
||||||
|
`form.createdButFieldRejected` ("Object created, but a field was rejected — fix it below"); otherwise
|
||||||
|
the existing rejection banner. Both highlight the field (via `fieldErrorKey` + code). Edit's own
|
||||||
|
in-place partial-failure (update ok, setFields fails) keeps staying-put with its banner.
|
||||||
|
- Result: a single recovery surface (the edit form) for both flows, with create messaged as "created."
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
RHF manages form state → `isSubmitting` disables submit across both mutations → `isDirty` drives the
|
||||||
|
guard (beforeunload + useBlocker + dialog) → on save success the form is clean and navigates → on
|
||||||
|
create partial-failure it navigates to the edit route with `created` state → the edit form shows the
|
||||||
|
"created" banner + field highlight (code-specific message).
|
||||||
|
|
||||||
|
## Error handling / edges
|
||||||
|
- `isSubmitting` covers the whole two-phase write (RHF awaits `onSubmit`); a second click is a no-op
|
||||||
|
(button disabled).
|
||||||
|
- The guard must not fire on save-driven navigation (post-success or create→edit teleport) — reset
|
||||||
|
dirty / bypass ref around saves.
|
||||||
|
- `beforeunload` only prompts while dirty (don't attach unconditionally).
|
||||||
|
- `useBlocker` now works (data router). Tests render under `createMemoryRouter` (harness change) so the
|
||||||
|
blocker is exercisable.
|
||||||
|
- A `FieldRejection` with an unknown `code` falls back to the generic `form.fieldRejected`.
|
||||||
|
- `number_of_objects` min is client-side UX; the server remains source of truth.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- **Migration:** the full existing suite passes under the new `createMemoryRouter` harness (no
|
||||||
|
behavior change) — this is the gate for Task 1.
|
||||||
|
- **Submit disable:** during an in-flight save the submit button is `disabled` and reads "Saving…";
|
||||||
|
a double-click fires the create mutation once (assert call count).
|
||||||
|
- **Keyboard:** Cmd/Ctrl+Enter submits.
|
||||||
|
- **Save & create another:** create mode → success resets the form (fields cleared, stays on /new),
|
||||||
|
does not navigate to detail.
|
||||||
|
- **Validation:** a `FieldRejection` with `code: "type_mismatch"` shows the code-specific message;
|
||||||
|
`number_of_objects = 0` shows `form.minCount` without hitting the server.
|
||||||
|
- **Dirty guard:** with a dirty form, attempting in-app nav shows the dialog (Stay keeps you, Leave
|
||||||
|
proceeds); Cancel-when-dirty confirms; a clean form navigates freely; `beforeunload` listener added
|
||||||
|
only when dirty (assert via the registered handler / a spy).
|
||||||
|
- **Partial-failure:** create with a `setFields` 422 → lands on `/objects/:id/edit` with the "Object
|
||||||
|
created, but a field was rejected" banner + field highlight; edit's own setFields 422 → stays with
|
||||||
|
its banner. (Extend `object-new-page.test.tsx` / `object-edit-form.test.tsx`.)
|
||||||
|
- Gate: `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors`; en/sv parity for all new keys;
|
||||||
|
no codename.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. The app uses a data router (`createBrowserRouter` + `RouterProvider`) with the route tree unchanged;
|
||||||
|
the test harness uses `createMemoryRouter`; the full suite is green.
|
||||||
|
2. The submit button is disabled and shows "Saving…" while either mutation is pending; no double-submit
|
||||||
|
/ duplicate-create is possible from the UI.
|
||||||
|
3. A dirty form guards navigation: `useBlocker` dialog on in-app nav, `beforeunload` on reload/close,
|
||||||
|
and Cancel confirms when dirty; saving navigates without a false prompt.
|
||||||
|
4. Create and edit share one partial-failure recovery surface (the edit form); the create case is
|
||||||
|
messaged "Object created, but a field was rejected" and highlights the field.
|
||||||
|
5. Field-rejection messages reflect the server `code`; core errors show type-specific messages;
|
||||||
|
`number_of_objects >= 1` is validated client-side.
|
||||||
|
6. Create mode offers "Save & create another" (resets the form) and Cmd/Ctrl+Enter submit.
|
||||||
|
7. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity; no codename; no
|
||||||
|
new npm dependency.
|
||||||
|
|
||||||
|
## Out of scope → follow-ups
|
||||||
|
- Flexible-field grouping/ordering (#45 / detail), per-field server validation rules (#11).
|
||||||
|
- Turning core-field 422s into per-field `FieldRejection`s server-side (the core mutations still throw
|
||||||
|
generic errors; only `setFields` yields field-level codes).
|
||||||
|
- Autosave / draft persistence.
|
||||||
+3
-3
@@ -51,8 +51,8 @@
|
|||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.3",
|
"@types/react-dom": "^19.1.3",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"@vitest/browser": "3.2.6",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "3.2.6",
|
"@vitest/coverage-v8": "4.1.8",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.60.1",
|
"typescript-eslint": "^8.60.1",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vitest": "^3.2.2"
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"msw": {
|
"msw": {
|
||||||
"workerDirectory": [
|
"workerDirectory": [
|
||||||
|
|||||||
Generated
+176
-341
File diff suppressed because it is too large
Load Diff
+10
-8
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { RequireAuth } from "./auth/require-auth";
|
import { RequireAuth } from "./auth/require-auth";
|
||||||
import { LoginPage } from "./auth/login-page";
|
import { LoginPage } from "./auth/login-page";
|
||||||
@@ -29,10 +29,9 @@ function FormFallback() {
|
|||||||
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
return <div role="status" className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
const router = createBrowserRouter(
|
||||||
return (
|
createRoutesFromElements(
|
||||||
<BrowserRouter>
|
<>
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
@@ -77,7 +76,10 @@ export function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/objects" replace />} />
|
<Route path="*" element={<Navigate to="/objects" replace />} />
|
||||||
</Routes>
|
</>,
|
||||||
</BrowserRouter>
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
|
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
|
||||||
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" },
|
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" },
|
||||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
||||||
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" },
|
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "createdButFieldRejected": "Object created, but a field was rejected — fix it below.", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }, "unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } },
|
||||||
"actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." },
|
"actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." },
|
||||||
"labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." },
|
"labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." },
|
||||||
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
|
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
|
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
|
||||||
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" },
|
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" },
|
||||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||||
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" },
|
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan.", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }, "unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" } },
|
||||||
"actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." },
|
"actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." },
|
||||||
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." },
|
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." },
|
||||||
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
|
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Blocker } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export function UnsavedChangesDialog({ blocker }: { blocker: Blocker }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = blocker.state === "blocked";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) blocker.reset?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("form.unsaved.title")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("form.unsaved.body")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => blocker.reset?.()}>
|
||||||
|
{t("form.unsaved.stay")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => blocker.proceed?.()}>
|
||||||
|
{t("form.unsaved.leave")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createMemoryRouter, Link, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
|
import { UnsavedChangesDialog } from "./unsaved-changes-dialog";
|
||||||
|
import { useUnsavedChanges } from "./use-unsaved-changes";
|
||||||
|
import "../i18n";
|
||||||
|
|
||||||
|
function Editor({ active }: { active: boolean }) {
|
||||||
|
const blocker = useUnsavedChanges(active);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Editor</h1>
|
||||||
|
<Link to="/other">go elsewhere</Link>
|
||||||
|
<UnsavedChangesDialog blocker={blocker} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGuard(active: boolean) {
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{ path: "/", element: <Editor active={active} /> },
|
||||||
|
{ path: "/other", element: <h1>Other page</h1> },
|
||||||
|
],
|
||||||
|
{ initialEntries: ["/"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(<RouterProvider router={router} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dirty nav shows the dialog and Keep editing stays", async () => {
|
||||||
|
renderGuard(true);
|
||||||
|
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Discard unsaved changes?")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Keep editing" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "Editor" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Other page")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dirty nav with Discard proceeds to the target route", async () => {
|
||||||
|
renderGuard(true);
|
||||||
|
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Discard" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Other page")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clean form navigates without the dialog", async () => {
|
||||||
|
renderGuard(false);
|
||||||
|
await userEvent.click(screen.getByRole("link", { name: /go elsewhere/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Other page")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Discard unsaved changes?")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers a beforeunload listener only when active", () => {
|
||||||
|
const addSpy = vi.spyOn(window, "addEventListener");
|
||||||
|
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||||
|
|
||||||
|
const { unmount } = renderGuard(true);
|
||||||
|
|
||||||
|
expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(removeSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not register beforeunload when inactive", () => {
|
||||||
|
const addSpy = vi.spyOn(window, "addEventListener");
|
||||||
|
|
||||||
|
renderGuard(false);
|
||||||
|
|
||||||
|
expect(addSpy.mock.calls.some(([type]) => type === "beforeunload")).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useBlocker } from "react-router-dom";
|
||||||
|
|
||||||
|
export function useUnsavedChanges(active: boolean) {
|
||||||
|
const blocker = useBlocker(active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handler);
|
||||||
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return blocker;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ function tree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("edit: fields PUT 422 with field body -> field error message shown and field marked invalid", async () => {
|
test("edit: fields PUT 422 with type_mismatch code -> code-specific field message shown and field marked invalid", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("/api/admin/objects/:id", () =>
|
http.get("/api/admin/objects/:id", () =>
|
||||||
HttpResponse.json({ ...amphora, fields: { inscription: "old" } }),
|
HttpResponse.json({ ...amphora, fields: { inscription: "old" } }),
|
||||||
@@ -33,8 +33,10 @@ test("edit: fields PUT 422 with field body -> field error message shown and fiel
|
|||||||
await screen.findByDisplayValue("Amphora");
|
await screen.findByDisplayValue("Amphora");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||||
|
|
||||||
const alerts = await screen.findAllByText(/inscription.*rejected/i);
|
// Banner still reports the rejected field name; the field highlight uses the code-specific message.
|
||||||
expect(alerts.length).toBeGreaterThanOrEqual(2);
|
await screen.findByText(/inscription.*rejected/i);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Wrong type for this field")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async () => {
|
test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async () => {
|
||||||
|
|||||||
@@ -30,11 +30,15 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
|
|||||||
const update = useUpdateObject();
|
const update = useUpdateObject();
|
||||||
const setFields = useSetFields();
|
const setFields = useSetFields();
|
||||||
|
|
||||||
const locationState = location.state as { fieldsError?: boolean; fieldErrorKey?: string } | null;
|
const locationState = location.state as {
|
||||||
|
created?: boolean;
|
||||||
|
fieldErrorKey?: string;
|
||||||
|
fieldErrorCode?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(() => {
|
const [error, setError] = useState<string | null>(() => {
|
||||||
|
if (locationState?.created) return t("form.createdButFieldRejected");
|
||||||
if (locationState?.fieldErrorKey) return t("form.fieldRejected", { field: locationState.fieldErrorKey });
|
if (locationState?.fieldErrorKey) return t("form.fieldRejected", { field: locationState.fieldErrorKey });
|
||||||
if (locationState?.fieldsError) return t("form.rejected");
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +46,10 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
|
|||||||
locationState?.fieldErrorKey ?? null,
|
locationState?.fieldErrorKey ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [fieldErrorCode, setFieldErrorCode] = useState<string | null>(
|
||||||
|
locationState?.fieldErrorCode ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
useBreadcrumb([
|
useBreadcrumb([
|
||||||
{ label: t("nav.objects"), to: "/objects" },
|
{ label: t("nav.objects"), to: "/objects" },
|
||||||
{ label: object.object_number, to: `/objects/${id}` },
|
{ label: object.object_number, to: `/objects/${id}` },
|
||||||
@@ -61,9 +69,10 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
|
|||||||
|
|
||||||
const defaults = { core, fields: object.fields };
|
const defaults = { core, fields: object.fields };
|
||||||
|
|
||||||
const onSubmit = async (values: ObjectFormValues) => {
|
const onSubmit = async (values: ObjectFormValues): Promise<boolean> => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setFieldErrorKey(null);
|
setFieldErrorKey(null);
|
||||||
|
setFieldErrorCode(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ id, body: values.core });
|
await update.mutateAsync({ id, body: values.core });
|
||||||
@@ -71,15 +80,17 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof FieldRejection) {
|
if (e instanceof FieldRejection) {
|
||||||
setFieldErrorKey(e.field);
|
setFieldErrorKey(e.field);
|
||||||
|
setFieldErrorCode(e.code);
|
||||||
setError(t("form.fieldRejected", { field: e.field }));
|
setError(t("form.fieldRejected", { field: e.field }));
|
||||||
} else {
|
} else {
|
||||||
setError(t("form.rejected"));
|
setError(t("form.rejected"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(`/objects/${id}`);
|
navigate(`/objects/${id}`);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,6 +99,7 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
|
|||||||
defaults={defaults}
|
defaults={defaults}
|
||||||
formError={error}
|
formError={error}
|
||||||
fieldErrorKey={fieldErrorKey}
|
fieldErrorKey={fieldErrorKey}
|
||||||
|
fieldErrorCode={fieldErrorCode}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={() => navigate(`/objects/${id}`)}
|
onCancel={() => navigate(`/objects/${id}`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { ObjectForm } from "./object-form";
|
import { ObjectForm } from "./object-form";
|
||||||
@@ -25,6 +25,20 @@ test("create mode: shows visibility (draft/internal only) and submits assembled
|
|||||||
expect(values.fields.inscription).toBe("To the gods");
|
expect(values.fields.inscription).toBe("To the gods");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Cmd/Ctrl+Enter submits the form", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||||
|
|
||||||
|
const numberInput = screen.getByLabelText(/object number/i);
|
||||||
|
fireEvent.keyDown(numberInput, { key: "Enter", metaKey: true });
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||||
|
});
|
||||||
|
|
||||||
test("required core + required flexible field block submit", async () => {
|
test("required core + required flexible field block submit", async () => {
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
@@ -33,6 +47,24 @@ test("required core + required flexible field block submit", async () => {
|
|||||||
expect(onSubmit).not.toHaveBeenCalled();
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("number_of_objects of 0 is blocked client-side with the minCount message", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||||
|
|
||||||
|
const count = screen.getByLabelText(/number of objects/i);
|
||||||
|
await userEvent.clear(count);
|
||||||
|
await userEvent.type(count, "0");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Must be at least 1")).toBeInTheDocument();
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("edit mode: no visibility control, save button, prefilled values", async () => {
|
test("edit mode: no visibility control, save button, prefilled values", async () => {
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
renderApp(
|
renderApp(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useFieldDefinitions } from "../api/queries";
|
|||||||
import { useConfig } from "../config/config-context";
|
import { useConfig } from "../config/config-context";
|
||||||
import { FieldInput } from "./field-input";
|
import { FieldInput } from "./field-input";
|
||||||
import { pruneFields } from "./prune-fields";
|
import { pruneFields } from "./prune-fields";
|
||||||
|
import { UnsavedChangesDialog } from "../lib/unsaved-changes-dialog";
|
||||||
|
import { useUnsavedChanges } from "../lib/use-unsaved-changes";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -51,13 +53,15 @@ export function ObjectForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
formError,
|
formError,
|
||||||
fieldErrorKey,
|
fieldErrorKey,
|
||||||
|
fieldErrorCode,
|
||||||
}: {
|
}: {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
|
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
|
||||||
onSubmit: (values: ObjectFormValues) => void;
|
onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise<boolean> | boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
formError?: string | null;
|
formError?: string | null;
|
||||||
fieldErrorKey?: string | null;
|
fieldErrorKey?: string | null;
|
||||||
|
fieldErrorCode?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { default_language } = useConfig();
|
const { default_language } = useConfig();
|
||||||
@@ -76,31 +80,41 @@ export function ObjectForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = form;
|
const { register, handleSubmit, formState: { errors, isSubmitting, isDirty } } = form;
|
||||||
|
|
||||||
|
const blocker = useUnsavedChanges(isDirty && !isSubmitting);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fieldErrorKey) {
|
if (fieldErrorKey) {
|
||||||
form.setError(`fields.${fieldErrorKey}` as never, {
|
const codeKey = fieldErrorCode ? `form.fieldError.${fieldErrorCode}` : "";
|
||||||
type: "server",
|
const message =
|
||||||
message: t("form.fieldRejected", { field: fieldErrorKey }),
|
fieldErrorCode && t(codeKey) !== codeKey
|
||||||
});
|
? t(codeKey)
|
||||||
|
: t("form.fieldRejected", { field: fieldErrorKey });
|
||||||
|
form.setError(`fields.${fieldErrorKey}` as never, { type: "server", message });
|
||||||
}
|
}
|
||||||
}, [fieldErrorKey, form, t]);
|
}, [fieldErrorKey, fieldErrorCode, form, t]);
|
||||||
|
|
||||||
const submit = handleSubmit((data) => {
|
const runSubmit = (createAnother: boolean) =>
|
||||||
|
handleSubmit(async (data) => {
|
||||||
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
|
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
|
||||||
|
const values =
|
||||||
onSubmit(
|
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? { core: data.core, visibility: data.visibility, fields }
|
? { core: data.core, visibility: data.visibility, fields }
|
||||||
: { core: data.core, fields },
|
: { core: data.core, fields };
|
||||||
);
|
const ok = await onSubmit(values, { createAnother });
|
||||||
|
if (ok && createAnother) {
|
||||||
|
form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} });
|
||||||
|
document.getElementById("object_number")?.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submit = runSubmit(false);
|
||||||
|
|
||||||
const coreField = (
|
const coreField = (
|
||||||
key: keyof ObjectCore,
|
key: keyof ObjectCore,
|
||||||
labelKey: string,
|
labelKey: string,
|
||||||
opts?: { type?: string; required?: boolean },
|
opts?: { type?: string; required?: boolean; min?: number },
|
||||||
) => (
|
) => (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={key}>{t(`fieldsLabels.${labelKey}`)}</Label>
|
<Label htmlFor={key}>{t(`fieldsLabels.${labelKey}`)}</Label>
|
||||||
@@ -111,21 +125,38 @@ export function ObjectForm({
|
|||||||
{...register(
|
{...register(
|
||||||
`core.${key}` as const,
|
`core.${key}` as const,
|
||||||
opts?.type === "number"
|
opts?.type === "number"
|
||||||
? { valueAsNumber: true, required: opts?.required }
|
? {
|
||||||
|
valueAsNumber: true,
|
||||||
|
required: opts?.required,
|
||||||
|
...(opts?.min !== undefined
|
||||||
|
? { min: { value: opts.min, message: t("form.minCount") } }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
: { required: opts?.required },
|
: { required: opts?.required },
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errors.core?.[key] && (
|
{errors.core?.[key] && (
|
||||||
<p role="alert" className="text-xs text-destructive">
|
<p role="alert" className="text-xs text-destructive">
|
||||||
{t("form.required")}
|
{errors.core[key]?.message || t("form.required")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className="space-y-4 overflow-auto p-4">
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="space-y-4 overflow-auto p-4"
|
||||||
|
>
|
||||||
|
<UnsavedChangesDialog blocker={blocker} />
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
{formError}
|
{formError}
|
||||||
@@ -134,7 +165,7 @@ export function ObjectForm({
|
|||||||
|
|
||||||
{coreField("object_number", "objectNumber", { required: true })}
|
{coreField("object_number", "objectNumber", { required: true })}
|
||||||
{coreField("object_name", "objectName", { required: true })}
|
{coreField("object_name", "objectName", { required: true })}
|
||||||
{coreField("number_of_objects", "count", { type: "number", required: true })}
|
{coreField("number_of_objects", "count", { type: "number", required: true, min: 1 })}
|
||||||
{coreField("brief_description", "briefDescription")}
|
{coreField("brief_description", "briefDescription")}
|
||||||
{coreField("current_location", "currentLocation")}
|
{coreField("current_location", "currentLocation")}
|
||||||
{coreField("current_owner", "currentOwner")}
|
{coreField("current_owner", "currentOwner")}
|
||||||
@@ -177,11 +208,22 @@ export function ObjectForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button type="submit">
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{mode === "create" ? t("form.create") : t("form.save")}
|
{isSubmitting ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
{mode === "create" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => void runSubmit(true)()}
|
||||||
|
>
|
||||||
|
{t("form.createAnother")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="button" variant="ghost" disabled={isSubmitting} onClick={onCancel}>
|
||||||
{t("form.cancel")}
|
{t("form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { screen, waitFor } from "@testing-library/react";
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse } from "msw";
|
import { delay, http, HttpResponse } from "msw";
|
||||||
import { Routes, Route, useLocation } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { server } from "../test/server";
|
import { server } from "../test/server";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { ObjectNewPage } from "./object-new-page";
|
import { ObjectNewPage } from "./object-new-page";
|
||||||
|
import { ObjectEditForm } from "./object-edit-form";
|
||||||
function EditStub() {
|
|
||||||
const location = useLocation();
|
|
||||||
const flagged = (location.state as { fieldsError?: boolean } | null)?.fieldsError === true;
|
|
||||||
return <div>edit page{flagged ? " (fields error)" : ""}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tree() {
|
function tree() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/objects/new" element={<ObjectNewPage />} />
|
<Route path="/objects/new" element={<ObjectNewPage />} />
|
||||||
<Route path="/objects/:id" element={<div>detail view</div>} />
|
<Route path="/objects/:id" element={<div>detail view</div>} />
|
||||||
<Route path="/objects/:id/edit" element={<EditStub />} />
|
<Route path="/objects/:id/edit" element={<ObjectEditForm />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,13 +45,15 @@ test("create: POST then PUT fields, then navigate to the new object's detail", a
|
|||||||
expect((fieldsBody as { inscription: string }).inscription).toBe("To the gods");
|
expect((fieldsBody as { inscription: string }).inscription).toBe("To the gods");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("partial create: fields PUT fails -> navigate to edit with an error banner", async () => {
|
test("partial create: fields PUT fails -> edit page shows the 'created' banner and highlights the field", async () => {
|
||||||
|
// POST returns the amphora id so the default GET /api/admin/objects/:id handler
|
||||||
|
// resolves and the real ObjectEditForm renders at /objects/:id/edit.
|
||||||
server.use(
|
server.use(
|
||||||
http.post("/api/admin/objects", () =>
|
http.post("/api/admin/objects", () =>
|
||||||
HttpResponse.json({ id: "new-id-2" }, { status: 201 }),
|
HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 }),
|
||||||
),
|
),
|
||||||
http.put("/api/admin/objects/:id/fields", () =>
|
http.put("/api/admin/objects/:id/fields", () =>
|
||||||
new HttpResponse(null, { status: 422 }),
|
HttpResponse.json({ field: "inscription", code: "type_mismatch" }, { status: 422 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,5 +64,57 @@ test("partial create: fields PUT fails -> navigate to edit with an error banner"
|
|||||||
await userEvent.type(screen.getByLabelText(/inscription/i), "x");
|
await userEvent.type(screen.getByLabelText(/inscription/i), "x");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText(/edit page \(fields error\)/i)).toBeInTheDocument());
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/object created, but a field was rejected/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/wrong type for this field/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in-flight submit: button disabled + shows Saving…, create fires exactly once on double-click", async () => {
|
||||||
|
let postCount = 0;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects", async () => {
|
||||||
|
postCount += 1;
|
||||||
|
await delay(50);
|
||||||
|
return HttpResponse.json({ id: "new-id-3" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: "/objects/new" });
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: /create object/i });
|
||||||
|
await userEvent.click(button);
|
||||||
|
await userEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/saving…/i)).toBeInTheDocument());
|
||||||
|
expect(screen.getByRole("button", { name: /saving…/i })).toBeDisabled();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument());
|
||||||
|
expect(postCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Save & create another: resets the form and stays on /objects/new", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects", () =>
|
||||||
|
HttpResponse.json({ id: "new-id-4" }, { status: 201 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: "/objects/new" });
|
||||||
|
|
||||||
|
const numberInput = (await screen.findByLabelText(/object number/i)) as HTMLInputElement;
|
||||||
|
await userEvent.type(numberInput, "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /save & create another/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(numberInput.value).toBe(""));
|
||||||
|
expect(screen.queryByText("detail view")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /save & create another/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function ObjectNewPage() {
|
|||||||
useDocumentTitle(t("objects.new"));
|
useDocumentTitle(t("objects.new"));
|
||||||
useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);
|
useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);
|
||||||
|
|
||||||
const onSubmit = async (values: ObjectFormValues) => {
|
const onSubmit = async (
|
||||||
|
values: ObjectFormValues,
|
||||||
|
opts?: { createAnother?: boolean },
|
||||||
|
): Promise<boolean> => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
let id: string;
|
let id: string;
|
||||||
@@ -32,7 +35,7 @@ export function ObjectNewPage() {
|
|||||||
id = created.id;
|
id = created.id;
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("form.rejected"));
|
setError(t("form.rejected"));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(values.fields).length > 0) {
|
if (Object.keys(values.fields).length > 0) {
|
||||||
@@ -40,12 +43,16 @@ export function ObjectNewPage() {
|
|||||||
await setFields.mutateAsync({ id, fields: values.fields });
|
await setFields.mutateAsync({ id, fields: values.fields });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined;
|
const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined;
|
||||||
navigate(`/objects/${id}/edit`, { state: { fieldsError: true, fieldErrorKey } });
|
const fieldErrorCode = e instanceof FieldRejection ? e.code : undefined;
|
||||||
return;
|
navigate(`/objects/${id}/edit`, { state: { created: true, fieldErrorKey, fieldErrorCode } });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts?.createAnother) return true;
|
||||||
|
|
||||||
navigate(`/objects/${id}`);
|
navigate(`/objects/${id}`);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createMemoryRouter, Route, RouterProvider, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { server } from "../test/server";
|
import { server } from "../test/server";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
@@ -10,6 +11,7 @@ import { amphora } from "../test/fixtures";
|
|||||||
import { SearchPage } from "./search-page";
|
import { SearchPage } from "./search-page";
|
||||||
import { SelectSearchPrompt } from "./select-search-prompt";
|
import { SelectSearchPrompt } from "./select-search-prompt";
|
||||||
import { ObjectDetail } from "../objects/object-detail";
|
import { ObjectDetail } from "../objects/object-detail";
|
||||||
|
import "../i18n";
|
||||||
|
|
||||||
function tree() {
|
function tree() {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +24,35 @@ function tree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The search rows are <NavLink>s. Under the shared `renderApp` harness the test
|
||||||
|
// subtree lives in a descendant <Routes> under a catch-all `*` data route, where
|
||||||
|
// the data router does not intercept the link click (it falls through to a real
|
||||||
|
// browser navigation that jsdom rejects). Mounting the search routes as real
|
||||||
|
// data-router routes lets RouterProvider intercept the NavLink, which is the
|
||||||
|
// data-router equivalent of the old MemoryRouter behavior.
|
||||||
|
function renderSearchRouter(route = "/search") {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/search",
|
||||||
|
element: <SearchPage />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <SelectSearchPrompt /> },
|
||||||
|
{ path: ":id", element: <ObjectDetail /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ initialEntries: [route] },
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test("typing searches and renders highlighted rich rows", async () => {
|
test("typing searches and renders highlighted rich rows", async () => {
|
||||||
renderApp(tree(), { route: "/search" });
|
renderApp(tree(), { route: "/search" });
|
||||||
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
||||||
@@ -69,7 +100,7 @@ test("empty query shows the prompt; zero results shows empty", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking a result shows the object in the detail pane", async () => {
|
test("clicking a result shows the object in the detail pane", async () => {
|
||||||
renderApp(tree(), { route: "/search" });
|
renderSearchRouter();
|
||||||
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
||||||
await userEvent.click(await screen.findByText("Bronze figurine"));
|
await userEvent.click(await screen.findByText("Bronze figurine"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import "../i18n";
|
import "../i18n";
|
||||||
|
|
||||||
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
|
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const router = createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] });
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||||
@@ -48,7 +49,7 @@ export default defineConfig({
|
|||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headless: true,
|
headless: true,
|
||||||
provider: 'playwright',
|
provider: playwright(),
|
||||||
instances: [{
|
instances: [{
|
||||||
browser: 'chromium'
|
browser: 'chromium'
|
||||||
}]
|
}]
|
||||||
|
|||||||
Reference in New Issue
Block a user