From e005e76f5be71b76c5215fb48a5c7585c706cbf5 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 21:14:01 +0200 Subject: [PATCH 1/8] =?UTF-8?q?docs(specs):=20object=20form=20robustness?= =?UTF-8?q?=20=E2=80=94=20data=20router,=20dirty=20guard,=20partial-failur?= =?UTF-8?q?e,=20validation=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-07-object-form-robustness-design.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-object-form-robustness-design.md diff --git a/docs/superpowers/specs/2026-06-07-object-form-robustness-design.md b/docs/superpowers/specs/2026-06-07-object-form-robustness-design.md new file mode 100644 index 0000000..7041c2b --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-object-form-robustness-design.md @@ -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 `` + `` (component router) in `app.tsx`; **`useBlocker` requires +a data router** (`createBrowserRouter` + `RouterProvider`). `renderApp` wraps `ui` in `` +with no `` (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 `` with a module-level + `const router = createBrowserRouter(createRoutesFromElements(<>…the existing tree…))` + and `export function App() { return ; }`. 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 `` which is + ``. +- **`test/render.tsx`:** `renderApp(ui, { route })` switches to + `createMemoryRouter([{ path: "*", element: ui }], { initialEntries: [route] })` rendered via + `` (inside `QueryClientProvider`). Behavior-preserving: `ui` renders at `route`, + and tests that supply their own `` 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 ` + {mode === "create" && ( + + )} + + +``` + (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 => { + 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] && ( +

+ {errors.core[key]?.message || t("form.required")} +

+)} +``` + - `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 ``. 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). + - ``. + +- [ ] **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 ( + { if (!o) blocker.reset?.(); }}> + + + {t("form.unsaved.title")} + {t("form.unsaved.body")} + + + blocker.reset?.()}>{t("form.unsaved.stay")} + blocker.proceed?.()}>{t("form.unsaved.leave")} + + + + ); +} +``` +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 `` 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 ``/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(() => { + 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(locationState?.fieldErrorKey ?? null); +const [fieldErrorCode, setFieldErrorCode] = useState(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` (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. From f3881e8c7c099934a7ae1eb0692a92cbd97afc34 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:03:21 +0200 Subject: [PATCH 3/8] =?UTF-8?q?build(web):=20upgrade=20Vitest=203=E2=86=92?= =?UTF-8?q?4=20(browser-playwright=20provider)=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- web/package.json | 6 +- web/pnpm-lock.yaml | 517 +++++++++++++++------------------------------ web/vite.config.ts | 3 +- 3 files changed, 181 insertions(+), 345 deletions(-) diff --git a/web/package.json b/web/package.json index 3ad401b..4380d8c 100644 --- a/web/package.json +++ b/web/package.json @@ -51,8 +51,8 @@ "@types/react": "^19.1.5", "@types/react-dom": "^19.1.3", "@vitejs/plugin-react": "^4.5.2", - "@vitest/browser": "3.2.6", - "@vitest/coverage-v8": "3.2.6", + "@vitest/browser-playwright": "^4.1.8", + "@vitest/coverage-v8": "4.1.8", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -69,7 +69,7 @@ "typescript": "~5.8.3", "typescript-eslint": "^8.60.1", "vite": "^6.3.5", - "vitest": "^3.2.2" + "vitest": "^4.1.8" }, "msw": { "workerDirectory": [ diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9b62fbe..85d344b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -68,10 +68,10 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(esbuild@0.25.12)(rollup@4.61.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) '@storybook/addon-mcp': specifier: ^0.6.0 - version: 0.6.0(@storybook/addon-vitest@10.4.2(@vitest/browser@3.2.6)(@vitest/runner@3.2.6)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@3.2.6))(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.8.3) + version: 0.6.0(@storybook/addon-vitest@10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8))(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.8.3) '@storybook/addon-vitest': specifier: ^10.4.2 - version: 10.4.2(@vitest/browser@3.2.6)(@vitest/runner@3.2.6)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@3.2.6) + version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8) '@storybook/react-vite': specifier: ^10.4.2 version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(esbuild@0.25.12)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.8.3)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -99,12 +99,12 @@ importers: '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.7.0(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) - '@vitest/browser': - specifier: 3.2.6 - version: 3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@3.2.6) + '@vitest/browser-playwright': + specifier: ^4.1.8 + version: 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8) '@vitest/coverage-v8': - specifier: 3.2.6 - version: 3.2.6(@vitest/browser@3.2.6)(vitest@3.2.6) + specifier: 4.1.8 + version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) eslint: specifier: ^10.4.1 version: 10.4.1(jiti@2.7.0) @@ -154,18 +154,14 @@ importers: specifier: ^6.3.5 version: 6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0) vitest: - specifier: ^3.2.2 - version: 3.2.6(@types/node@25.9.1)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@26.1.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -345,6 +341,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@chromatic-com/storybook@5.2.1': resolution: {integrity: sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg==} engines: {node: '>=20.0.0', yarn: '>=1.22.18'} @@ -678,14 +677,6 @@ packages: '@types/node': optional: true - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} - engines: {node: '>=8'} - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} peerDependencies: @@ -1009,10 +1000,6 @@ packages: cpu: [x64] os: [win32] - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1588,26 +1575,22 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/browser@3.2.6': - resolution: {integrity: sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==} + '@vitest/browser-playwright@4.1.8': + resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} peerDependencies: playwright: '*' - safaridriver: '*' - vitest: 3.2.6 - webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 - peerDependenciesMeta: - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true + vitest: 4.1.8 - '@vitest/coverage-v8@3.2.6': - resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==} + '@vitest/browser@4.1.8': + resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} peerDependencies: - '@vitest/browser': 3.2.6 - vitest: 3.2.6 + vitest: 4.1.8 + + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 peerDependenciesMeta: '@vitest/browser': optional: true @@ -1615,14 +1598,14 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@3.2.6': - resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@3.2.6': - resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true @@ -1632,26 +1615,26 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@3.2.6': - resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@3.2.6': - resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@3.2.6': - resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@3.2.6': - resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@3.2.6': - resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -1708,10 +1691,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1730,8 +1709,8 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} axe-core@4.12.0: resolution: {integrity: sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==} @@ -1777,10 +1756,6 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1800,6 +1775,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2014,9 +1993,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -2033,9 +2009,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -2071,8 +2044,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -2269,10 +2242,6 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -2345,11 +2314,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -2572,17 +2536,10 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2600,9 +2557,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2779,8 +2733,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -2837,10 +2791,6 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2922,6 +2872,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2983,9 +2937,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3027,10 +2978,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3080,6 +3027,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -3352,8 +3303,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} @@ -3381,10 +3332,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -3421,9 +3368,6 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -3453,31 +3397,28 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - test-exclude@7.0.2: - resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} - engines: {node: '>=18'} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -3635,11 +3576,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite@6.4.3: resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3680,26 +3616,39 @@ packages: yaml: optional: true - vitest@3.2.6: - resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.6 - '@vitest/ui': 3.2.6 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -3763,10 +3712,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3845,11 +3790,6 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -4081,6 +4021,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@chromatic-com/storybook@5.2.1(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))': dependencies: '@neoconfetti/react': 1.0.0 @@ -4336,17 +4278,6 @@ snapshots: optionalDependencies: '@types/node': 25.9.1 - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@istanbuljs/schema@0.1.6': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.8.3)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: glob: 13.0.6 @@ -4585,9 +4516,6 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true - '@pkgjs/parseargs@0.11.0': - optional: true - '@polka/url@1.0.0-next.29': {} '@redocly/ajv@8.11.2': @@ -4729,7 +4657,7 @@ snapshots: - vite - webpack - '@storybook/addon-mcp@0.6.0(@storybook/addon-vitest@10.4.2(@vitest/browser@3.2.6)(@vitest/runner@3.2.6)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@3.2.6))(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.8.3)': + '@storybook/addon-mcp@0.6.0(@storybook/addon-vitest@10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8))(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.8.3)': dependencies: '@storybook/mcp': 0.7.0(typescript@5.8.3) '@tmcp/adapter-valibot': 0.1.6(tmcp@1.19.4(typescript@5.8.3))(valibot@1.2.0(typescript@5.8.3)) @@ -4739,20 +4667,21 @@ snapshots: tmcp: 1.19.4(typescript@5.8.3) valibot: 1.2.0(typescript@5.8.3) optionalDependencies: - '@storybook/addon-vitest': 10.4.2(@vitest/browser@3.2.6)(@vitest/runner@3.2.6)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@3.2.6) + '@storybook/addon-vitest': 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8) transitivePeerDependencies: - '@tmcp/auth' - typescript - '@storybook/addon-vitest@10.4.2(@vitest/browser@3.2.6)(@vitest/runner@3.2.6)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@3.2.6)': + '@storybook/addon-vitest@10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8)(@vitest/runner@4.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vitest@4.1.8)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) storybook: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) optionalDependencies: - '@vitest/browser': 3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@3.2.6) - '@vitest/runner': 3.2.6 - vitest: 3.2.6(@types/node@25.9.1)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3)) + '@vitest/browser': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8) + '@vitest/runner': 4.1.8 + vitest: 4.1.8(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@26.1.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) transitivePeerDependencies: - react - react-dom @@ -5149,45 +5078,51 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser@3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@3.2.6)': + '@vitest/browser-playwright@4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8)': dependencies: - '@testing-library/dom': 10.4.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) - '@vitest/utils': 3.2.6 - magic-string: 0.30.21 - sirv: 3.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/node@25.9.1)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3)) - ws: 8.21.0 - optionalDependencies: + '@vitest/browser': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8) + '@vitest/mocker': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) playwright: 1.60.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@26.1.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/coverage-v8@3.2.6(@vitest/browser@3.2.6)(vitest@3.2.6)': + '@vitest/browser@4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@26.1.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)': dependencies: - '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3(supports-color@10.2.2) + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/node@25.9.1)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3)) + magicast: 0.5.3 + obug: 2.1.2 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@26.1.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) optionalDependencies: - '@vitest/browser': 3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@3.2.6) - transitivePeerDependencies: - - supports-color + '@vitest/browser': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8) '@vitest/expect@3.2.4': dependencies: @@ -5197,17 +5132,18 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@3.2.6': + '@vitest/expect@4.1.8': dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.6 - '@vitest/utils': 3.2.6 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))': + '@vitest/mocker@4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: - '@vitest/spy': 3.2.6 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -5218,19 +5154,19 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@3.2.6': + '@vitest/pretty-format@4.1.8': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 - '@vitest/runner@3.2.6': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 3.2.6 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.6': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 3.2.6 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 @@ -5238,9 +5174,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@3.2.6': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.1.8': {} '@vitest/utils@3.2.4': dependencies: @@ -5248,11 +5182,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@3.2.6': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 3.2.6 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@webcontainer/env@1.1.1': {} @@ -5299,8 +5233,6 @@ snapshots: ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - argparse@2.0.1: {} aria-query@5.3.0: @@ -5315,7 +5247,7 @@ snapshots: dependencies: tslib: 2.8.1 - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.3: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -5369,8 +5301,6 @@ snapshots: bytes@3.1.2: {} - cac@6.7.14: {} - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5393,6 +5323,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + chalk@5.6.2: {} change-case@5.4.4: {} @@ -5538,8 +5470,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -5555,8 +5485,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - empathic@2.0.1: {} encodeurl@2.0.0: {} @@ -5583,7 +5511,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.2: dependencies: @@ -5873,11 +5801,6 @@ snapshots: flatted@3.4.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -5943,15 +5866,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -6118,25 +6032,11 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3(supports-color@10.2.2) - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jiti@2.7.0: {} jose@6.2.3: {} @@ -6147,8 +6047,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -6301,7 +6199,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.3.5: + magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -6346,10 +6244,6 @@ snapshots: dependencies: brace-expansion: 2.1.1 - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.1 - minimist@1.2.8: {} minipass@7.1.3: {} @@ -6423,6 +6317,8 @@ snapshots: object-treeify@1.1.33: {} + obug@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -6549,8 +6445,6 @@ snapshots: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6586,11 +6480,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-scurry@2.0.2: dependencies: lru-cache: 11.5.1 @@ -6624,6 +6513,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@7.0.0: {} + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -6965,7 +6856,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stdin-discarder@0.2.2: {} @@ -7003,12 +6894,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -7041,10 +6926,6 @@ snapshots: strip-indent@4.1.1: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - supports-color@10.2.2: {} supports-color@7.2.0: @@ -7063,27 +6944,21 @@ snapshots: tapable@2.3.3: {} - test-exclude@7.0.2: - dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 10.5.0 - minimatch: 10.2.5 - tiny-invariant@1.3.3: {} tinybench@2.9.0: {} - tinyexec@0.3.2: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} - tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} tldts-core@6.1.86: {} @@ -7223,27 +7098,6 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3(supports-color@10.2.2) - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 @@ -7258,48 +7112,35 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 - vitest@3.2.6(@types/node@25.9.1)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3)): + vitest@4.1.8(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@26.1.0)(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) - '@vitest/pretty-format': 3.2.6 - '@vitest/runner': 3.2.6 - '@vitest/snapshot': 3.2.6 - '@vitest/spy': 3.2.6 - '@vitest/utils': 3.2.6 - chai: 5.3.3 - debug: 4.4.3(supports-color@10.2.2) + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.2 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.2.4 tinyglobby: 0.2.17 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 vite: 6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 - '@vitest/browser': 3.2.6(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@3.2.6) + '@vitest/browser-playwright': 4.1.8(msw@2.14.6(@types/node@25.9.1)(typescript@5.8.3))(playwright@1.60.0)(vite@6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0))(vitest@4.1.8) + '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) jsdom: 26.1.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml void-elements@3.1.0: {} @@ -7345,12 +7186,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} ws@8.21.0: {} diff --git a/web/vite.config.ts b/web/vite.config.ts index 628630d..b777c42 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { defineConfig } from "vite"; import { fileURLToPath } from 'node:url'; 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)); // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon @@ -48,7 +49,7 @@ export default defineConfig({ browser: { enabled: true, headless: true, - provider: 'playwright', + provider: playwright(), instances: [{ browser: 'chromium' }] From ed0c13907cbd62a5cc0b7a8080d70b554f511a87 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:07:03 +0200 Subject: [PATCH 4/8] refactor(web): migrate to data router (createBrowserRouter) to enable useBlocker (#46) Convert app.tsx route tree verbatim to a module-level data router via createRoutesFromElements + RouterProvider, and the test harness to createMemoryRouter + RouterProvider. The search NavLink-click test now mounts its routes as real data-router routes so RouterProvider intercepts the link (descendant under a catch-all let it fall through to a jsdom navigation). Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app.tsx | 90 +++++++++++++++++----------------- web/src/search/search.test.tsx | 37 ++++++++++++-- web/src/test/render.tsx | 5 +- 3 files changed, 83 insertions(+), 49 deletions(-) diff --git a/web/src/app.tsx b/web/src/app.tsx index a5e58a9..c8bd44d 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -1,5 +1,5 @@ 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 { LoginPage } from "./auth/login-page"; @@ -29,55 +29,57 @@ function FormFallback() { return
Loading…
; } -export function App() { - return ( - - - } /> - }> - }> +const router = createBrowserRouter( + createRoutesFromElements( + <> + } /> + }> + }> + }> + + + } + /> + }> + } /> }> - + } /> - }> - } /> - }> - - - } - /> - - }> - } /> - } /> - - }> - } /> - } /> - - } /> - } /> - }> - - - } - /> - } /> + }> + } /> + } /> + + }> + } /> + } /> + + } /> + } /> + }> + + + } + /> + } /> - } /> - - - ); +
+ } /> + , + ), +); + +export function App() { + return ; } diff --git a/web/src/search/search.test.tsx b/web/src/search/search.test.tsx index 66ba609..f69fea6 100644 --- a/web/src/search/search.test.tsx +++ b/web/src/search/search.test.tsx @@ -1,8 +1,9 @@ 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 { 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 { renderApp } from "../test/render"; @@ -10,6 +11,7 @@ import { amphora } from "../test/fixtures"; import { SearchPage } from "./search-page"; import { SelectSearchPrompt } from "./select-search-prompt"; import { ObjectDetail } from "../objects/object-detail"; +import "../i18n"; function tree() { return ( @@ -22,6 +24,35 @@ function tree() { ); } +// The search rows are s. Under the shared `renderApp` harness the test +// subtree lives in a descendant 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: , + children: [ + { index: true, element: }, + { path: ":id", element: }, + ], + }, + ], + { initialEntries: [route] }, + ); + + return render( + + + , + ); +} + test("typing searches and renders highlighted rich rows", async () => { renderApp(tree(), { route: "/search" }); 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 () => { - renderApp(tree(), { route: "/search" }); + renderSearchRouter(); await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze"); await userEvent.click(await screen.findByText("Bronze figurine")); diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx index b9f5edd..abf83fa 100644 --- a/web/src/test/render.tsx +++ b/web/src/test/render.tsx @@ -1,16 +1,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; import type { ReactElement } from "react"; -import { MemoryRouter } from "react-router-dom"; +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( - {ui} + , ); } From 3900bc362c18761980f837ac6fb19e8a769ad24c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:15:21 +0200 Subject: [PATCH 5/8] feat(web): disable submit while saving + Save & create another + Cmd/Ctrl+Enter (#46) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/object-edit-form.tsx | 5 ++- web/src/objects/object-form.test.tsx | 16 ++++++- web/src/objects/object-form.tsx | 54 ++++++++++++++++++------ web/src/objects/object-new-page.test.tsx | 53 ++++++++++++++++++++++- web/src/objects/object-new-page.tsx | 15 +++++-- 7 files changed, 122 insertions(+), 25 deletions(-) diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 6dd2891..e033f49 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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)" }, "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" }, - "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", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another" }, "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." }, "theme": { "light": "Light", "dark": "Dark", "system": "System" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 247f64d..c25ba9c 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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)" }, "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" }, - "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", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny" }, "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." }, "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx index 39f1006..e341cc9 100644 --- a/web/src/objects/object-edit-form.tsx +++ b/web/src/objects/object-edit-form.tsx @@ -61,7 +61,7 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str const defaults = { core, fields: object.fields }; - const onSubmit = async (values: ObjectFormValues) => { + const onSubmit = async (values: ObjectFormValues): Promise => { setError(null); setFieldErrorKey(null); @@ -76,10 +76,11 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str setError(t("form.rejected")); } - return; + return false; } navigate(`/objects/${id}`); + return true; }; return ( diff --git a/web/src/objects/object-form.test.tsx b/web/src/objects/object-form.test.tsx index 476dbfb..02ab635 100644 --- a/web/src/objects/object-form.test.tsx +++ b/web/src/objects/object-form.test.tsx @@ -1,5 +1,5 @@ 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 { renderApp } from "../test/render"; 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"); }); +test("Cmd/Ctrl+Enter submits the form", async () => { + const onSubmit = vi.fn(); + renderApp( {}} />); + + 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 () => { const onSubmit = vi.fn(); renderApp( {}} />); diff --git a/web/src/objects/object-form.tsx b/web/src/objects/object-form.tsx index 75cd438..632fee9 100644 --- a/web/src/objects/object-form.tsx +++ b/web/src/objects/object-form.tsx @@ -54,7 +54,7 @@ export function ObjectForm({ }: { mode: "create" | "edit"; defaults?: { core: ObjectCore; fields: Record }; - onSubmit: (values: ObjectFormValues) => void; + onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise | boolean; onCancel: () => void; formError?: string | null; fieldErrorKey?: string | null; @@ -76,7 +76,7 @@ export function ObjectForm({ }, }); - const { register, handleSubmit, formState: { errors } } = form; + const { register, handleSubmit, formState: { errors, isSubmitting } } = form; useEffect(() => { if (fieldErrorKey) { @@ -87,15 +87,21 @@ export function ObjectForm({ } }, [fieldErrorKey, form, t]); - const submit = handleSubmit((data) => { - const fields = pruneFields(data.fields, localizedTextKeys, default_language); + const runSubmit = (createAnother: boolean) => + 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 ok = await onSubmit(values, { createAnother }); + if (ok && createAnother) { + form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} }); + document.getElementById("object_number")?.focus(); + } + }); - onSubmit( - mode === "create" - ? { core: data.core, visibility: data.visibility, fields } - : { core: data.core, fields }, - ); - }); + const submit = runSubmit(false); const coreField = ( key: keyof ObjectCore, @@ -125,7 +131,16 @@ export function ObjectForm({ ); return ( - + { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + className="space-y-4 overflow-auto p-4" + > {formError && (

{formError} @@ -177,11 +192,22 @@ export function ObjectForm({ )}

- - + )} + +
diff --git a/web/src/objects/object-new-page.test.tsx b/web/src/objects/object-new-page.test.tsx index 18014dc..09bcd3d 100644 --- a/web/src/objects/object-new-page.test.tsx +++ b/web/src/objects/object-new-page.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { screen, waitFor } from "@testing-library/react"; 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 { server } from "../test/server"; import { renderApp } from "../test/render"; @@ -9,7 +9,7 @@ import { ObjectNewPage } from "./object-new-page"; function EditStub() { const location = useLocation(); - const flagged = (location.state as { fieldsError?: boolean } | null)?.fieldsError === true; + const flagged = (location.state as { created?: boolean } | null)?.created === true; return
edit page{flagged ? " (fields error)" : ""}
; } @@ -69,3 +69,52 @@ test("partial create: fields PUT fails -> navigate to edit with an error banner" await waitFor(() => expect(screen.getByText(/edit page \(fields error\)/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(); +}); diff --git a/web/src/objects/object-new-page.tsx b/web/src/objects/object-new-page.tsx index d541b76..7940935 100644 --- a/web/src/objects/object-new-page.tsx +++ b/web/src/objects/object-new-page.tsx @@ -18,7 +18,10 @@ export function ObjectNewPage() { useDocumentTitle(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 => { setError(null); let id: string; @@ -32,7 +35,7 @@ export function ObjectNewPage() { id = created.id; } catch { setError(t("form.rejected")); - return; + return false; } if (Object.keys(values.fields).length > 0) { @@ -40,12 +43,16 @@ export function ObjectNewPage() { await setFields.mutateAsync({ id, fields: values.fields }); } catch (e) { const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined; - navigate(`/objects/${id}/edit`, { state: { fieldsError: true, fieldErrorKey } }); - return; + const fieldErrorCode = e instanceof FieldRejection ? e.code : undefined; + navigate(`/objects/${id}/edit`, { state: { created: true, fieldErrorKey, fieldErrorCode } }); + return true; } } + if (opts?.createAnother) return true; + navigate(`/objects/${id}`); + return true; }; return ( From 537b847acb080eedf4bf93da6b6153c9c1292878 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:20:30 +0200 Subject: [PATCH 6/8] feat(web): code-aware field errors + min count validation (#46) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/object-edit-form.test.tsx | 8 ++++--- web/src/objects/object-edit-form.tsx | 14 +++++++++++- web/src/objects/object-form.test.tsx | 18 +++++++++++++++ web/src/objects/object-form.tsx | 28 +++++++++++++++-------- 6 files changed, 57 insertions(+), 15 deletions(-) diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index e033f49..7ca92fd 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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)" }, "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" }, - "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", "saving": "Saving…", "createAnother": "Save & create another" }, + "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", "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" } }, "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." }, "theme": { "light": "Light", "dark": "Dark", "system": "System" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index c25ba9c..6984ad8 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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)" }, "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" }, - "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", "saving": "Sparar…", "createAnother": "Spara & skapa ny" }, + "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", "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" } }, "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." }, "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, diff --git a/web/src/objects/object-edit-form.test.tsx b/web/src/objects/object-edit-form.test.tsx index f5cee74..18aed44 100644 --- a/web/src/objects/object-edit-form.test.tsx +++ b/web/src/objects/object-edit-form.test.tsx @@ -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( http.get("/api/admin/objects/:id", () => 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 userEvent.click(screen.getByRole("button", { name: /save/i })); - const alerts = await screen.findAllByText(/inscription.*rejected/i); - expect(alerts.length).toBeGreaterThanOrEqual(2); + // Banner still reports the rejected field name; the field highlight uses the code-specific message. + 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 () => { diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx index e341cc9..da8b6ea 100644 --- a/web/src/objects/object-edit-form.tsx +++ b/web/src/objects/object-edit-form.tsx @@ -30,7 +30,12 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str const update = useUpdateObject(); const setFields = useSetFields(); - const locationState = location.state as { fieldsError?: boolean; fieldErrorKey?: string } | null; + const locationState = location.state as { + created?: boolean; + fieldsError?: boolean; + fieldErrorKey?: string; + fieldErrorCode?: string; + } | null; const [error, setError] = useState(() => { if (locationState?.fieldErrorKey) return t("form.fieldRejected", { field: locationState.fieldErrorKey }); @@ -42,6 +47,10 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str locationState?.fieldErrorKey ?? null, ); + const [fieldErrorCode, setFieldErrorCode] = useState( + locationState?.fieldErrorCode ?? null, + ); + useBreadcrumb([ { label: t("nav.objects"), to: "/objects" }, { label: object.object_number, to: `/objects/${id}` }, @@ -64,6 +73,7 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str const onSubmit = async (values: ObjectFormValues): Promise => { setError(null); setFieldErrorKey(null); + setFieldErrorCode(null); try { await update.mutateAsync({ id, body: values.core }); @@ -71,6 +81,7 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str } catch (e) { if (e instanceof FieldRejection) { setFieldErrorKey(e.field); + setFieldErrorCode(e.code); setError(t("form.fieldRejected", { field: e.field })); } else { setError(t("form.rejected")); @@ -89,6 +100,7 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str defaults={defaults} formError={error} fieldErrorKey={fieldErrorKey} + fieldErrorCode={fieldErrorCode} onSubmit={onSubmit} onCancel={() => navigate(`/objects/${id}`)} /> diff --git a/web/src/objects/object-form.test.tsx b/web/src/objects/object-form.test.tsx index 02ab635..a85efb4 100644 --- a/web/src/objects/object-form.test.tsx +++ b/web/src/objects/object-form.test.tsx @@ -47,6 +47,24 @@ test("required core + required flexible field block submit", async () => { expect(onSubmit).not.toHaveBeenCalled(); }); +test("number_of_objects of 0 is blocked client-side with the minCount message", async () => { + const onSubmit = vi.fn(); + renderApp( {}} />); + + 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 () => { const onSubmit = vi.fn(); renderApp( diff --git a/web/src/objects/object-form.tsx b/web/src/objects/object-form.tsx index 632fee9..fa6a5f6 100644 --- a/web/src/objects/object-form.tsx +++ b/web/src/objects/object-form.tsx @@ -51,6 +51,7 @@ export function ObjectForm({ onCancel, formError, fieldErrorKey, + fieldErrorCode, }: { mode: "create" | "edit"; defaults?: { core: ObjectCore; fields: Record }; @@ -58,6 +59,7 @@ export function ObjectForm({ onCancel: () => void; formError?: string | null; fieldErrorKey?: string | null; + fieldErrorCode?: string | null; }) { const { t } = useTranslation(); const { default_language } = useConfig(); @@ -80,12 +82,14 @@ export function ObjectForm({ useEffect(() => { if (fieldErrorKey) { - form.setError(`fields.${fieldErrorKey}` as never, { - type: "server", - message: t("form.fieldRejected", { field: 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, form, t]); + }, [fieldErrorKey, fieldErrorCode, form, t]); const runSubmit = (createAnother: boolean) => handleSubmit(async (data) => { @@ -106,7 +110,7 @@ export function ObjectForm({ const coreField = ( key: keyof ObjectCore, labelKey: string, - opts?: { type?: string; required?: boolean }, + opts?: { type?: string; required?: boolean; min?: number }, ) => (
@@ -117,14 +121,20 @@ export function ObjectForm({ {...register( `core.${key}` as const, 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 }, )} /> {errors.core?.[key] && (

- {t("form.required")} + {errors.core[key]?.message || t("form.required")}

)}
@@ -149,7 +159,7 @@ export function ObjectForm({ {coreField("object_number", "objectNumber", { 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("current_location", "currentLocation")} {coreField("current_owner", "currentOwner")} From e18cad9c6a5d18e5e26abdf6ba654986b3b4f7e9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:26:15 +0200 Subject: [PATCH 7/8] feat(web): unsaved-changes guard (useBlocker + beforeunload) on the object form (#46) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/lib/unsaved-changes-dialog.tsx | 42 ++++++++++++ web/src/lib/use-unsaved-changes.test.tsx | 85 ++++++++++++++++++++++++ web/src/lib/use-unsaved-changes.ts | 18 +++++ web/src/objects/object-form.tsx | 8 ++- 6 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/unsaved-changes-dialog.tsx create mode 100644 web/src/lib/use-unsaved-changes.test.tsx create mode 100644 web/src/lib/use-unsaved-changes.ts diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 7ca92fd..6abe947 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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)" }, "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" }, - "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", "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" } }, + "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", "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." }, "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" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 6984ad8..c482800 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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)" }, "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" }, - "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", "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" } }, + "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", "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." }, "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" }, diff --git a/web/src/lib/unsaved-changes-dialog.tsx b/web/src/lib/unsaved-changes-dialog.tsx new file mode 100644 index 0000000..9c70271 --- /dev/null +++ b/web/src/lib/unsaved-changes-dialog.tsx @@ -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 ( + { + if (!next) blocker.reset?.(); + }} + > + + + {t("form.unsaved.title")} + {t("form.unsaved.body")} + + + blocker.reset?.()}> + {t("form.unsaved.stay")} + + blocker.proceed?.()}> + {t("form.unsaved.leave")} + + + + + ); +} diff --git a/web/src/lib/use-unsaved-changes.test.tsx b/web/src/lib/use-unsaved-changes.test.tsx new file mode 100644 index 0000000..4b469f4 --- /dev/null +++ b/web/src/lib/use-unsaved-changes.test.tsx @@ -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 ( +
+

Editor

+ go elsewhere + +
+ ); +} + +function renderGuard(active: boolean) { + const router = createMemoryRouter( + [ + { path: "/", element: }, + { path: "/other", element:

Other page

}, + ], + { initialEntries: ["/"] }, + ); + + return render(); +} + +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); +}); diff --git a/web/src/lib/use-unsaved-changes.ts b/web/src/lib/use-unsaved-changes.ts new file mode 100644 index 0000000..37db1c0 --- /dev/null +++ b/web/src/lib/use-unsaved-changes.ts @@ -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; +} diff --git a/web/src/objects/object-form.tsx b/web/src/objects/object-form.tsx index fa6a5f6..09ac7dd 100644 --- a/web/src/objects/object-form.tsx +++ b/web/src/objects/object-form.tsx @@ -6,6 +6,8 @@ import { useFieldDefinitions } from "../api/queries"; import { useConfig } from "../config/config-context"; import { FieldInput } from "./field-input"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -78,7 +80,9 @@ export function ObjectForm({ }, }); - const { register, handleSubmit, formState: { errors, isSubmitting } } = form; + const { register, handleSubmit, formState: { errors, isSubmitting, isDirty } } = form; + + const blocker = useUnsavedChanges(isDirty && !isSubmitting); useEffect(() => { if (fieldErrorKey) { @@ -151,6 +155,8 @@ export function ObjectForm({ }} className="space-y-4 overflow-auto p-4" > + + {formError && (

{formError} From d3ee4365e00c7f0b7bdb2fb0ffa4ac8a94da7685 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 23:31:15 +0200 Subject: [PATCH 8/8] feat(web): unify create/edit partial-failure recovery with 'created' banner (#46) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/object-edit-form.tsx | 3 +-- web/src/objects/object-new-page.test.tsx | 24 ++++++++++++------------ 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 6abe947..5cf3e67 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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)" }, "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" }, - "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", "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" } }, + "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." }, "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" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index c482800..8022aa9 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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)" }, "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" }, - "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", "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" } }, + "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." }, "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" }, diff --git a/web/src/objects/object-edit-form.tsx b/web/src/objects/object-edit-form.tsx index da8b6ea..c1eeecd 100644 --- a/web/src/objects/object-edit-form.tsx +++ b/web/src/objects/object-edit-form.tsx @@ -32,14 +32,13 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str const locationState = location.state as { created?: boolean; - fieldsError?: boolean; fieldErrorKey?: string; fieldErrorCode?: string; } | null; const [error, setError] = useState(() => { + 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; }); diff --git a/web/src/objects/object-new-page.test.tsx b/web/src/objects/object-new-page.test.tsx index 09bcd3d..b682655 100644 --- a/web/src/objects/object-new-page.test.tsx +++ b/web/src/objects/object-new-page.test.tsx @@ -2,23 +2,18 @@ import { expect, test } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; 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 { renderApp } from "../test/render"; import { ObjectNewPage } from "./object-new-page"; - -function EditStub() { - const location = useLocation(); - const flagged = (location.state as { created?: boolean } | null)?.created === true; - return

edit page{flagged ? " (fields error)" : ""}
; -} +import { ObjectEditForm } from "./object-edit-form"; function tree() { return ( } /> detail view} /> - } /> + } /> ); } @@ -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"); }); -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( 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", () => - new HttpResponse(null, { status: 422 }), + HttpResponse.json({ field: "inscription", code: "type_mismatch" }, { status: 422 }), ), ); @@ -67,7 +64,10 @@ test("partial create: fields PUT fails -> navigate to edit with an error banner" await userEvent.type(screen.getByLabelText(/inscription/i), "x"); 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 () => {