diff --git a/docs/superpowers/plans/2026-06-09-bundle-and-tests.md b/docs/superpowers/plans/2026-06-09-bundle-and-tests.md new file mode 100644 index 0000000..938596f --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-bundle-and-tests.md @@ -0,0 +1,338 @@ +# Bundle Vendor-Split + Test-Gap Fills — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split framework deps into cache-stable vendor chunks, and fill the audit's unit-test (prune-fields, delete-confirm-dialog in-use, labels, format-date) + combobox-story gaps. + +**Architecture:** Task 1 adds `build.rollupOptions.output.manualChunks` to `vite.config.ts` (production build only). Task 2 adds 4 jsdom unit tests for pure/critical units. Task 3 adds a Storybook story for the combobox primitive and runs the full gate. All additive/behavior-preserving. + +**Tech Stack:** Vite 6 (Rollup), React 19 + TS + pnpm, Vitest 4 (jsdom + storybook-browser projects), Storybook, RTL + MSW. + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; app/test source double-quote+semicolon; **stories use single-quote + no-semicolon** (mirror existing `ui/*.stories.tsx`). Run a single test pass per task. + +**Spec:** `docs/superpowers/specs/2026-06-09-bundle-and-tests-design.md` + +**Key facts:** +- `scripts/check-bundle-size.mjs` fails if the **largest** `dist/assets/*.js` gzip exceeds 250 KB; today ~216.5 KB (one chunk). +- `vite.config.ts` exports `defineConfig({ plugins, resolve, server, test })` — no `build` block. Deps: `react`, `react-dom`, `react-router-dom` (v7, re-exports `react-router`), `@tanstack/react-query`, `@base-ui/react`, `i18next`, `react-i18next`. +- `objects/prune-fields.ts`: `pruneFields(fields: Record, localizedTextKeys: Set, defaultLang: string): Record`. +- `lib/labels.ts`: `labelText(labels: LabelView[], lang: string): string` (lang match → `"en"` → first → `""`). +- `lib/format-date.ts`: `formatDate(value: unknown, lang: string): string` (non-string null→`"—"`, non-string→`String(value)`; invalid→returns value; valid `YYYY-MM-DD`→`Intl.DateTimeFormat(lang,{dateStyle:"medium"})`, parsed at local midnight). +- `components/delete-confirm-dialog.tsx`: `DeleteConfirmDialog({ description, onConfirm: () => Promise, triggerLabel? })`. Trigger is a `Button` labelled `t("actions.delete")` ("Delete"); the confirm action is also labelled `t("actions.delete")`. On a thrown error it sets a message via `errorMessageKey(err)` and **returns without closing**; on success it closes. `actions.inUse` (en) = "Can't delete — used by {{count}} object(s). Clear those fields first." `InUseError` is exported from `../api/errors` (and re-exported by `../api/queries`). +- `components/ui/combobox.tsx` exports `ComboboxRoot`, `ComboboxInputGroup`, `ComboboxInput`, `ComboboxClear`, `ComboboxTrigger`, `ComboboxPopup`, `ComboboxList`, `ComboboxItem`, `ComboboxItemIndicator`, `ComboboxEmpty`. See `objects/options-combobox.tsx` for the composition (ComboboxRoot props: `items`, `value`, `onValueChange`, `itemToStringLabel`, `isItemEqualToValue`; ComboboxList takes a render-function child). +- Story format (from `components/ui/badge.stories.tsx`): `import type { Meta, StoryObj } from '@storybook/react-vite'`; `import { expect } from 'storybook/test'`; `const meta = { component, tags: ['ai-generated'] } satisfies Meta`; `export default meta`; `type Story = StoryObj`; `export const Default: Story = { play: async ({ canvas }) => {...} }`. + +--- + +# Task 1: Vendor split (`vite.config.ts`) + +**Files:** Modify `web/vite.config.ts`. + +- [ ] **Step 1: Add the `build` block.** In `web/vite.config.ts`, add a top-level `build` key to the `defineConfig({...})` object (sibling of `plugins`/`resolve`/`server`/`test`): +```ts + build: { + rollupOptions: { + output: { + manualChunks: { + react: ["react", "react-dom", "react-router", "react-router-dom"], + "base-ui": ["@base-ui/react"], + query: ["@tanstack/react-query"], + i18n: ["i18next", "react-i18next"], + }, + }, + }, + }, +``` + +- [ ] **Step 2: Build + verify chunk split + budget:** +```bash +cd web && pnpm build && pnpm check:size && ls -1 dist/assets/*.js +``` +Expected: build succeeds; `check:size` prints `largest JS chunk: = KB gz (budget 250 KB)` and passes; `dist/assets/` now contains separate `react-*.js`, `base-ui-*.js`, `query-*.js`, `i18n-*.js` chunks plus the app entry. Report the largest chunk size (should be a vendor chunk, well under 250). If the build warns about React being in multiple chunks or a chunk is oversized, confirm `react`+`react-dom`+`react-router`+`react-router-dom` are all in the one `react` group and adjust. + +- [ ] **Step 3: typecheck + lint** (config change shouldn't affect them, but confirm): +```bash +cd web && pnpm typecheck && pnpm lint +``` + +- [ ] **Step 4: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/vite.config.ts +git commit -m "build(web): split framework deps into cache-stable vendor chunks (#67)" +``` + +--- + +# Task 2: Unit-test gaps (prune-fields, labels, format-date, delete-confirm-dialog) + +**Files:** Create `web/src/objects/prune-fields.test.ts`, `web/src/lib/labels.test.ts`, `web/src/lib/format-date.test.ts`, `web/src/components/delete-confirm-dialog.test.tsx`. + +- [ ] **Step 1: `web/src/objects/prune-fields.test.ts`:** +```ts +import { expect, test } from "vitest"; + +import { pruneFields } from "./prune-fields"; + +test("drops empty/null/undefined scalars, keeps real scalars", () => { + const out = pruneFields( + { a: "x", b: "", c: null, d: undefined, e: 0, f: false }, + new Set(), + "en", + ); + expect(out).toEqual({ a: "x", e: 0, f: false }); +}); + +test("a localized_text key keeps only the default-language entry", () => { + const out = pruneFields( + { title: { en: "Bowl", sv: "Skål" } }, + new Set(["title"]), + "sv", + ); + expect(out).toEqual({ title: { sv: "Skål" } }); +}); + +test("a non-localized object value keeps all non-empty entries", () => { + const out = pruneFields( + { dims: { w: "10", h: "", d: "5" } }, + new Set(), + "en", + ); + expect(out).toEqual({ dims: { w: "10", d: "5" } }); +}); + +test("an object value left with no entries is dropped entirely", () => { + const out = pruneFields( + { title: { en: "Bowl" }, empty: { en: "", sv: "" } }, + new Set(["title", "empty"]), + "sv", + ); + // title has no `sv` entry → empty after filtering → dropped; empty → dropped + expect(out).toEqual({}); +}); +``` +Run: `cd web && pnpm vitest run src/objects/prune-fields.test.ts`. (If a case's expectation mismatches the real semantics, re-read `prune-fields.ts` and correct the EXPECTATION to the true behavior — do not change the source.) + +- [ ] **Step 2: `web/src/lib/labels.test.ts`:** +```ts +import { expect, test } from "vitest"; + +import { labelText } from "./labels"; + +const labels = [ + { lang: "en", label: "Bowl" }, + { lang: "sv", label: "Skål" }, +]; + +test("returns the exact-language label when present", () => { + expect(labelText(labels, "sv")).toBe("Skål"); +}); + +test("falls back to the English label when the requested language is missing", () => { + expect(labelText(labels, "de")).toBe("Bowl"); +}); + +test("falls back to the first label when neither the language nor English is present", () => { + expect(labelText([{ lang: "fr", label: "Bol" }], "de")).toBe("Bol"); +}); + +test("returns an empty string for no labels", () => { + expect(labelText([], "en")).toBe(""); +}); +``` +Run: `cd web && pnpm vitest run src/lib/labels.test.ts`. + +- [ ] **Step 3: `web/src/lib/format-date.test.ts`:** +```ts +import { expect, test } from "vitest"; + +import { formatDate } from "./format-date"; + +test("formats a date-only string in the locale without a timezone day-shift", () => { + // local-midnight parse must keep the calendar day (3), not shift to the 2nd + expect(formatDate("1962-04-03", "en")).toBe("Apr 3, 1962"); +}); + +test("returns the em-dash placeholder for null", () => { + expect(formatDate(null, "en")).toBe("—"); +}); + +test("returns an unparseable string unchanged", () => { + expect(formatDate("not-a-date", "en")).toBe("not-a-date"); +}); + +test("stringifies a non-string, non-null value", () => { + expect(formatDate(42, "en")).toBe("42"); +}); +``` +Run: `cd web && pnpm vitest run src/lib/format-date.test.ts`. (The CI/runtime is full-ICU Node 22, so en `dateStyle:"medium"` → `"Apr 3, 1962"`. If your local ICU formats slightly differently, assert the equivalent string but keep the day as `3` — the day-shift guard is the point.) + +- [ ] **Step 4: `web/src/components/delete-confirm-dialog.test.tsx`:** +```tsx +import { expect, test, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderApp } from "../test/render"; +import { DeleteConfirmDialog } from "./delete-confirm-dialog"; +import { InUseError } from "../api/errors"; + +test("delete-in-use shows the in-use count and keeps the dialog open", async () => { + const onConfirm = vi.fn<() => Promise>().mockRejectedValue(new InUseError(3)); + renderApp(); + + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + + const dialog = within(document.body); + const buttons = await dialog.findAllByRole("button", { name: /delete/i }); + await userEvent.click(buttons[buttons.length - 1]); // the confirm action + + expect(await dialog.findByText(/used by 3/i)).toBeInTheDocument(); + // dialog stays open: its description is still shown + expect(dialog.getByText("Delete this term?")).toBeInTheDocument(); +}); + +test("a clean confirm closes the dialog", async () => { + const onConfirm = vi.fn<() => Promise>().mockResolvedValue(undefined); + renderApp(); + + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + + const dialog = within(document.body); + const buttons = await dialog.findAllByRole("button", { name: /delete/i }); + await userEvent.click(buttons[buttons.length - 1]); + + await waitFor(() => expect(dialog.queryByText("Delete this term?")).toBeNull()); + expect(onConfirm).toHaveBeenCalledTimes(1); +}); +``` +Run: `cd web && pnpm vitest run src/components/delete-confirm-dialog.test.tsx`. (Before opening, only the trigger "Delete" button exists. After opening, the portal adds the action "Delete"; `findAllByRole` returns both, click the last. `actions.inUse` en contains "used by 3". If `vi.fn<() => Promise>()` generic syntax trips the TS/vitest version, use `vi.fn(() => Promise.reject(new InUseError(3)))` / `vi.fn(() => Promise.resolve())`.) + +- [ ] **Step 5: Verify all four (vitest ONCE) + typecheck + lint:** +```bash +cd web && pnpm vitest run src/objects/prune-fields.test.ts src/lib/labels.test.ts src/lib/format-date.test.ts src/components/delete-confirm-dialog.test.tsx && pnpm typecheck && pnpm lint +``` +All green. + +- [ ] **Step 6: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/objects/prune-fields.test.ts web/src/lib/labels.test.ts web/src/lib/format-date.test.ts web/src/components/delete-confirm-dialog.test.tsx +git commit -m "test(web): cover prune-fields, labels, format-date, delete-in-use dialog (#67)" +``` + +--- + +# Task 3: Combobox story + full gate + +**Files:** Create `web/src/components/ui/combobox.stories.tsx`. + +- [ ] **Step 1: Read `web/src/objects/options-combobox.tsx`** to mirror the exact `ComboboxRoot` composition (generic, props, the `ComboboxList` render-function child), then create `web/src/components/ui/combobox.stories.tsx` (single-quote + no-semicolon, matching `badge.stories.tsx`): +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { expect } from 'storybook/test' + +import { + ComboboxRoot, + ComboboxInputGroup, + ComboboxInput, + ComboboxClear, + ComboboxTrigger, + ComboboxPopup, + ComboboxList, + ComboboxItem, + ComboboxItemIndicator, + ComboboxEmpty, +} from './combobox' + +const fruits = ['Apple', 'Apricot', 'Banana', 'Cherry'] + +function ComboboxDemo() { + const [value, setValue] = useState(null) + + return ( + + items={fruits} + value={value} + onValueChange={setValue} + itemToStringLabel={(item) => item ?? ''} + isItemEqualToValue={(a, b) => a === b} + > + + + + + + + No matches + + {(item: string) => ( + + + {item} + + )} + + + + ) +} + +const meta = { + component: ComboboxDemo, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByPlaceholderText('Pick a fruit')).toBeInTheDocument() + }, +} +``` +(Adjust the `ComboboxRoot` generic/props to match `options-combobox.tsx` exactly if they differ — the goal is a rendering `Default` story; keep the `play` minimal to stay stable in the browser project.) + +- [ ] **Step 2: Run the storybook project for this story** (browser test): +```bash +cd web && pnpm vitest run src/components/ui/combobox.stories.tsx +``` +Expected: the `Default` story passes (input renders). If the Base UI combobox API needs a different prop shape, fix the story (mirror `options-combobox.tsx`); do not weaken the assertion below a render check. + +- [ ] **Step 3: FULL FRONTEND 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 total test count (should be prior 268 + 4 unit + 1 story ≈ 273), the largest chunk (gz) from check:size (a vendor chunk, < 250), and the check:colors line. + +- [ ] **Step 4: Codename + status:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?" +git status --short +``` +Expected: no matches (`codename-exit=1`). + +- [ ] **Step 5: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/components/ui/combobox.stories.tsx +git commit -m "test(web): add a Storybook story for the combobox primitive (#67)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** AC1 vendor split + verify (T1); AC2 the 4 unit tests (T2 S1-S4); AC3 combobox story (T3 S1-S2); AC4 full gate + codename (T3 S3-S4). ✓ + +**Placeholder scan:** every test/story is complete code with concrete assertions; manualChunks is exact; the format-date ICU note and the vi.fn generic note give concrete fallbacks, not vague TODOs. ✓ + +**Type/consistency:** `pruneFields(fields, Set, lang)`, `labelText(labels, lang)`, `formatDate(value, lang)`, `DeleteConfirmDialog({description,onConfirm})`, `InUseError(count)` from `../api/errors` — all match the spec's documented signatures; the combobox story imports the exact exports from `./combobox`. ✓ + +## Notes +- No new dependency, no new i18n keys. `build.rollupOptions` only affects `pnpm build`; the test projects are unaffected. +- The combobox story is the one item with a (small) CI cost (a browser test on the slow runner); it's isolated in its own task/commit so it can be dropped cleanly if undesired. +- `check:size` should report a smaller largest chunk after the split (a vendor chunk, not the combined app+vendor chunk). diff --git a/docs/superpowers/specs/2026-06-09-bundle-and-tests-design.md b/docs/superpowers/specs/2026-06-09-bundle-and-tests-design.md new file mode 100644 index 0000000..3c1e62a --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-bundle-and-tests-design.md @@ -0,0 +1,111 @@ +# Bundle Vendor-Split + Test-Gap Fills — Design + +**Date:** 2026-06-09 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issue:** #67 (vendor-split the bundle for cache stability/headroom; fill the audit's unit-test + story gaps). + +## Context + +The production build emits a single ~216 KB-gz entry chunk (the largest, measured by +`scripts/check-bundle-size.mjs`, budget 250 KB) that bundles the framework deps (react-dom, react-router 7, +@tanstack/react-query, @base-ui/react, i18next) together with app code — so every app edit invalidates the +whole chunk's cache. `vite.config.ts` has no `build.rollupOptions`. Separately, four well-isolated units +have no direct tests, and the composed combobox primitive has no story. + +All changes are additive/low-risk; `check:size`/`check:colors`/the existing tests are the guards. + +## Components + +### 1. Vendor split — `vite.config.ts` +Add a top-level `build` block (sibling of `plugins`/`resolve`/`server`/`test`) with `manualChunks`: +```ts +build: { + rollupOptions: { + output: { + manualChunks: { + react: ["react", "react-dom", "react-router", "react-router-dom"], + "base-ui": ["@base-ui/react"], + query: ["@tanstack/react-query"], + i18n: ["i18next", "react-i18next"], + }, + }, + }, +}, +``` +- The app entry chunk then carries only app code; the listed deps land in cache-stable vendor chunks + (`react-*.js`, `base-ui-*.js`, `query-*.js`, `i18n-*.js`). `react-router` **and** `react-router-dom` are + both listed so router code (v7 re-exports `react-router` internally) stays in one chunk and React isn't + duplicated. +- Only affects `pnpm build` (production); the Storybook/vitest browser builder is independent, so the + test suite is unaffected. +- `check:size` measures the largest emitted chunk; after the split the largest is a vendor chunk well under + the 250 KB budget (verified in implementation by running `pnpm build` + `pnpm check:size` and reporting + the chunk sizes). If a chunk is unexpectedly oversized or React duplicates, adjust the groups. + +### 2. Test-gap fills (4 unit tests) + +**`objects/prune-fields.test.ts`** (new) — `pruneFields(fields, localizedTextKeys: Set, defaultLang)`: +- scalars pass through; `undefined`/`null`/`""` top-level values are dropped. +- a non-array object value whose key is in `localizedTextKeys` keeps **only** the `defaultLang` entry; + other-language entries are dropped. +- a non-array object value whose key is **not** in `localizedTextKeys` keeps all (non-empty) language + entries. +- empty inner entries (`""`/`null`/`undefined`) are filtered, and a map left with zero entries is dropped + entirely. + +**`components/delete-confirm-dialog.test.tsx`** (new) — the delete-in-use flow: +- render ``, open it (click the trigger), click the + confirm action. +- when `onConfirm` rejects with `InUseError(3)`, the dialog shows the `actions.inUse` message (containing + the count `3`) and **stays open** (the confirm action is still present / the dialog isn't dismissed). +- when `onConfirm` resolves, the dialog closes. +(`DeleteConfirmDialog` routes its catch through `errorMessageKey`, which maps `InUseError` → +`actions.inUse` with the count — so this also exercises the shared error mapping.) + +**`lib/labels.test.ts`** (new) — `labelText(labels, lang)`: +- exact `lang` match wins; else falls back to the `"en"` label; else the first label; else `""` for an + empty array. + +**`lib/format-date.test.ts`** (new) — `formatDate(value, lang)`: +- a valid `"YYYY-MM-DD"` formats via `Intl.DateTimeFormat(lang, { dateStyle: "medium" })` and is **not** + shifted off its calendar day (assert the rendered string contains the expected day number for a fixed + date + a fixed `lang` like `"en"`). +- `null` → `"—"`; a non-date string (e.g. `"not-a-date"`) is returned unchanged; a non-string non-null + (e.g. a number) is `String()`-ified. + +### 3. Combobox story — `components/ui/combobox.stories.tsx` (new) +A visual/interactive story for the composed combobox primitive, mirroring the existing `ui/*` story format +(`@storybook/react-vite` `Meta`/`StoryObj`, single-quote + no-semicolon, `tags: ['ai-generated']`, a `play` +using `canvas` + `storybook/test`). A small controlled wrapper renders `ComboboxRoot` with a handful of +string options (`ComboboxInput`/`ComboboxClear`/`ComboboxTrigger`/`ComboboxPopup`/`ComboboxList`/ +`ComboboxItem`/`ComboboxEmpty`), matching how `OptionsCombobox` composes them. A `Default` story; the +`play` asserts the input renders and (optionally) typing filters the list. (Runs as a browser test in the +Storybook vitest project — a few seconds in CI.) + +## Error handling / edges +- `manualChunks`: Rollup orders chunks by the import graph, so vendor chunks load before the app chunk that + imports them — no load-order/duplicate-React risk when react+react-dom+router share one named chunk. +- `formatDate` parses `\`${value}T00:00:00\`` (local midnight) to avoid a UTC day-shift; the test pins a + fixed `lang` to keep `Intl` output deterministic (assert a substring like the day number, not the full + locale string, to stay robust across ICU versions). +- `delete-confirm-dialog` test drives the Base UI AlertDialog in its portal (`within(document.body)` for the + confirm action), mirroring existing portal-aware tests. + +## Testing +- The 4 unit tests run in the jsdom project; the story adds one Storybook browser test. +- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; `check:size` reports the + new largest (vendor) chunk under budget; no new dependency; no new i18n keys; no codename. en/sv parity + unaffected. + +## Acceptance criteria +1. `vite.config.ts` has `build.rollupOptions.output.manualChunks` splitting react/base-ui/query/i18n into + their own chunks; `pnpm build` succeeds and `check:size` passes (largest chunk reported, < 250 KB gz, no + React duplication). +2. New tests exist and pass: `prune-fields.test.ts`, `delete-confirm-dialog.test.tsx` (InUseError → + `actions.inUse`, stays open), `labels.test.ts`, `format-date.test.ts`. +3. `components/ui/combobox.stories.tsx` exists with a working `Default` story (browser-test green). +4. Full gate green; no new dependency; no new i18n keys; no codename; existing tests unchanged. + +## Out of scope → follow-ups +- The buildkit/Dockerfile CI migration (the robust fix for the slow native runner; overlaps #25). +- Deeper treeshaking/bundle analysis; route-level code-split changes beyond the existing `lazy()` boundaries. diff --git a/web/src/components/delete-confirm-dialog.test.tsx b/web/src/components/delete-confirm-dialog.test.tsx new file mode 100644 index 0000000..9374ea5 --- /dev/null +++ b/web/src/components/delete-confirm-dialog.test.tsx @@ -0,0 +1,35 @@ +import { expect, test, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderApp } from "../test/render"; +import { DeleteConfirmDialog } from "./delete-confirm-dialog"; +import { InUseError } from "../api/errors"; + +test("delete-in-use shows the in-use count and keeps the dialog open", async () => { + const onConfirm = vi.fn(() => Promise.reject(new InUseError(3))); + renderApp(); + + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + + const dialog = within(document.body); + const buttons = await dialog.findAllByRole("button", { name: /delete/i }); + await userEvent.click(buttons[buttons.length - 1]); + + expect(await dialog.findByText(/used by 3/i)).toBeInTheDocument(); + expect(dialog.getByText("Delete this term?")).toBeInTheDocument(); +}); + +test("a clean confirm closes the dialog", async () => { + const onConfirm = vi.fn(() => Promise.resolve()); + renderApp(); + + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + + const dialog = within(document.body); + const buttons = await dialog.findAllByRole("button", { name: /delete/i }); + await userEvent.click(buttons[buttons.length - 1]); + + await waitFor(() => expect(dialog.queryByText("Delete this term?")).toBeNull()); + expect(onConfirm).toHaveBeenCalledTimes(1); +}); diff --git a/web/src/components/ui/combobox.stories.tsx b/web/src/components/ui/combobox.stories.tsx new file mode 100644 index 0000000..b90a6c4 --- /dev/null +++ b/web/src/components/ui/combobox.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { expect } from 'storybook/test' + +import { + ComboboxRoot, + ComboboxInputGroup, + ComboboxInput, + ComboboxClear, + ComboboxTrigger, + ComboboxPopup, + ComboboxList, + ComboboxItem, + ComboboxItemIndicator, + ComboboxEmpty, +} from './combobox' + +const fruits = ['Apple', 'Apricot', 'Banana', 'Cherry'] + +function ComboboxDemo() { + const [value, setValue] = useState(null) + + return ( + + items={fruits} + value={value} + onValueChange={setValue} + itemToStringLabel={(item) => item ?? ''} + isItemEqualToValue={(a, b) => a === b} + > + + + + + + + No matches + + {(item: string) => ( + + + {item} + + )} + + + + ) +} + +const meta = { + component: ComboboxDemo, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByPlaceholderText('Pick a fruit')).toBeInTheDocument() + }, +} diff --git a/web/src/lib/format-date.test.ts b/web/src/lib/format-date.test.ts new file mode 100644 index 0000000..f065d50 --- /dev/null +++ b/web/src/lib/format-date.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; + +import { formatDate } from "./format-date"; + +test("formats a date-only string in the locale without a timezone day-shift", () => { + expect(formatDate("1962-04-03", "en")).toBe("Apr 3, 1962"); +}); + +test("returns the em-dash placeholder for null", () => { + expect(formatDate(null, "en")).toBe("—"); +}); + +test("returns an unparseable string unchanged", () => { + expect(formatDate("not-a-date", "en")).toBe("not-a-date"); +}); + +test("stringifies a non-string, non-null value", () => { + expect(formatDate(42, "en")).toBe("42"); +}); diff --git a/web/src/lib/labels.test.ts b/web/src/lib/labels.test.ts new file mode 100644 index 0000000..af609f9 --- /dev/null +++ b/web/src/lib/labels.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; + +import { labelText } from "./labels"; + +const labels = [ + { lang: "en", label: "Bowl" }, + { lang: "sv", label: "Skål" }, +]; + +test("returns the exact-language label when present", () => { + expect(labelText(labels, "sv")).toBe("Skål"); +}); + +test("falls back to the English label when the requested language is missing", () => { + expect(labelText(labels, "de")).toBe("Bowl"); +}); + +test("falls back to the first label when neither the language nor English is present", () => { + expect(labelText([{ lang: "fr", label: "Bol" }], "de")).toBe("Bol"); +}); + +test("returns an empty string for no labels", () => { + expect(labelText([], "en")).toBe(""); +}); diff --git a/web/src/objects/prune-fields.test.ts b/web/src/objects/prune-fields.test.ts new file mode 100644 index 0000000..fd9ff3a --- /dev/null +++ b/web/src/objects/prune-fields.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; + +import { pruneFields } from "./prune-fields"; + +test("drops empty/null/undefined scalars, keeps real scalars", () => { + const out = pruneFields( + { a: "x", b: "", c: null, d: undefined, e: 0, f: false }, + new Set(), + "en", + ); + expect(out).toEqual({ a: "x", e: 0, f: false }); +}); + +test("a localized_text key keeps only the default-language entry", () => { + const out = pruneFields( + { title: { en: "Bowl", sv: "Skål" } }, + new Set(["title"]), + "sv", + ); + expect(out).toEqual({ title: { sv: "Skål" } }); +}); + +test("a non-localized object value keeps all non-empty entries", () => { + const out = pruneFields( + { dims: { w: "10", h: "", d: "5" } }, + new Set(), + "en", + ); + expect(out).toEqual({ dims: { w: "10", d: "5" } }); +}); + +test("an object value left with no entries is dropped entirely", () => { + const out = pruneFields( + { title: { en: "Bowl" }, empty: { en: "", sv: "" } }, + new Set(["title", "empty"]), + "sv", + ); + expect(out).toEqual({}); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index 1b66e94..0f50d13 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -16,6 +16,26 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src") } }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (/[\\/]node_modules[\\/]\.pnpm[\\/](react|react-dom|react-router|react-router-dom)@/.test(id)) { + return "react"; + } + if (/[\\/]node_modules[\\/]\.pnpm[\\/]@base-ui\+react@/.test(id)) { + return "base-ui"; + } + if (/[\\/]node_modules[\\/]\.pnpm[\\/]@tanstack\+react-query@/.test(id)) { + return "query"; + } + if (/[\\/]node_modules[\\/]\.pnpm[\\/](i18next|react-i18next)@/.test(id)) { + return "i18n"; + } + } + } + } + }, server: { proxy: { "/api": "http://localhost:8080",