diff --git a/CONTEXT.md b/CONTEXT.md index c3e1ce7..f6c03bc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,8 +30,19 @@ time. **Row key** — the value that identifies a row for its whole life, independent of where the row currently sits. Positions move; keys do not. -**Cell kind** — a string naming how one column draws and edits. A **cell -registry** maps every kind to its renderer and its editor. +**Cell type** — the string in a column's `type` naming how that column draws +and edits. The ones this package draws are prefixed `dg:`; glide's own kinds are +named as glide names them. A **cell registry** maps every type to its maker, its +renderer, and its validator; the engine builds one per grid from the built-in +types plus the descriptor's own. + +**Cell options** — one column's settings for its cell type, written on the +column and carried inside every cell that column makes. Only the cell type they +belong to reads them. + +**Custom column** — a column whose `type` names a cell the host registered +rather than one this package ships. It says so with `custom: true`, which is +what lets every other column's `type` and options be checked. ## Rows and where they live @@ -110,3 +121,12 @@ flash. **Appearance watcher** — the host-owned thing that decides: an OS media query, a host page's class, a user toggle. Always outside this package. + +## Column layout + +**Columns state** — the user's own arrangement of the columns: their order, +the widths they dragged, and the ones they hid. Client-owned, and the only part +of the presentation the user changes directly. + +**Columns adapter** — where that arrangement is read from and written to. The +engine's default writes to Web Storage; a descriptor may name its own. diff --git a/README.md b/README.md index fd77c89..a624571 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ column layout, and typed cell editors — all against a server that holds far more rows than the browser ever will. > **Status: 0.x.** The API will break between minor versions. It is pinned to a -> **pre-release** build of glide-data-grid (`6.0.4-alpha24`); that pin loosens -> when glide 6 ships stable. +> **pre-release** build of glide-data-grid (`6.0.4-alpha24`). This is not a +> choice: glide's latest stable release, `6.0.3`, declares a React peer of +> `^16.12.0 || 17.x || 18.x`. React 19 support first appears in the `6.0.4` +> alpha line. The pin loosens when glide ships a stable `6.0.4`. ## What it looks like @@ -66,25 +68,51 @@ import "@matthewhsu1/datagrid/datagrid.css"; // 2. Point the grid at your accent, once, before the first grid renders. configureGridTheme({ accentColor: "grass", grayColor: "slate" }); -// 3. Describe one grid. -const orderGrid = createGridInstance( - { - name: "orders", - rowKey: (row) => row.id, - columns: { defs: COLUMN_DEFS, defaultOrder: DEFAULT_ORDER }, - cells: orderCells, - api: { fetchRows, fetchCount, fetchRow, updateRow }, +// 3. Describe the columns. Each names its cell by type, the way glide does. +const COLUMN_DEFS = { + reference: { + field: "reference", + title: "Ref", + defaultWidth: 160, + editable: false, + type: "dg:text", }, - { startListening: listenerMiddleware.startListening }, -); - -// 4. Mount its reducer under the descriptor's name, then render. + total: { + field: "total", + title: "Total", + defaultWidth: 120, + editable: true, + type: "dg:number", + options: { format: "currency", currency: "USD" }, + }, + customer: { + field: "customer", + title: "Customer", + defaultWidth: 200, + editable: true, + type: "text", + }, // one of glide's own kinds +}; + +// 4. Describe one grid. +const orderGrid = createGridInstance({ + name: "orders", + rowKey: (row) => row.id, + columns: { defs: COLUMN_DEFS, defaultOrder: ["reference", "total", "customer"] }, + api: { fetchRows, fetchCount, fetchRow, updateRow }, +}); + +// 5. Mount its reducer under the descriptor's name, then render. // ``` -The grid needs a Redux store, your listener middleware, and a -`QueryClientProvider` above it. It creates its own `
` if your -page has none — without one, no cell can be edited and nothing says so. +Nothing registers a cell: the five the engine draws are prefixed `dg:`, glide's +own kinds keep glide's own names, and the user's column layout is remembered in +Web Storage on its own. + +The grid needs a Redux store and a `QueryClientProvider` above it. It creates +its own `
` if your page has none — without one, no cell can be +edited and nothing says so. ## Run the demo @@ -114,7 +142,7 @@ changing anything in `src/features/dataGrid/`. ## Develop ```bash -npm test # 615 tests +npm test npm run typecheck npm run check:docs # typechecks every code block in docs/ npm run lint diff --git a/demo/src/grid/testGrid.tsx b/demo/src/grid/testGrid.tsx index 8f4bbc5..5a31e92 100644 --- a/demo/src/grid/testGrid.tsx +++ b/demo/src/grid/testGrid.tsx @@ -1,6 +1,4 @@ -import { startAppListening } from "../listener"; import { createGridInstance } from "@matthewhsu1/datagrid"; -import { localStorageColumnsAdapter } from "@matthewhsu1/datagrid"; import type { ColumnDef, GridDescriptor, GridSliceState } from "@matthewhsu1/datagrid"; import { REGIONS, type TestRow } from "./api/types"; import { @@ -9,42 +7,104 @@ import { fetchTestRows, updateTestRow, } from "./api/testRowQueries"; -import { regionCell, testGridCells } from "./testGridCells"; import { RegionHeader } from "./RegionHeader"; export const GRID_NAME = "testGrid"; +/** + * Every column names its cell by `type` and carries that cell's settings on + * `options`. Nothing is registered and nothing is built: `dg:` names the cells + * the engine draws, and glide's own names are available beside them. + * + * Colors come from RADIX_BADGE_SCALES in lib/grid/radixBadgePalette.ts. That + * list is the whole `RadixColor` union — "blue", "purple", and "gray" are NOT + * in it, and each scale must also be imported in theme/radixStyles.ts. + */ const COLUMN_DEFS: Record = { - id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, - name: { field: "name", title: "Name", defaultWidth: 220, editable: true, type: "text" }, - sector: { field: "sector", title: "Sector", defaultWidth: 130, editable: false, type: "text" }, + id: { + field: "id", + title: "ID", + defaultWidth: 80, + editable: false, + type: "dg:number", + options: { format: "integer", thousandSeparator: true, min: 0 }, + }, + name: { field: "name", title: "Name", defaultWidth: 220, editable: true, type: "dg:text" }, + sector: { field: "sector", title: "Sector", defaultWidth: 130, editable: false, type: "dg:text" }, // Read-only because it is the GROUP field. An edit that changed a row's group // would have to move the row, and only a server-side move bumps the data // generation — the edit path patches a row in place. `active` is the editable // enum this page exercises instead. - region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" }, + region: { + field: "region", + title: "Region", + defaultWidth: 120, + editable: false, + type: "dg:enum", + options: { + choices: [ + { value: REGIONS[0].value, label: REGIONS[0].label, color: "cyan" }, + { value: REGIONS[1].value, label: REGIONS[1].label, color: "amber" }, + { value: REGIONS[2].value, label: REGIONS[2].label, color: "plum" }, + ], + }, + }, quantity: { field: "quantity", title: "Quantity", defaultWidth: 110, editable: true, - type: "int", + type: "dg:number", + options: { format: "integer", thousandSeparator: true, min: 0 }, + }, + price: { + field: "price", + title: "Price", + defaultWidth: 120, + editable: true, + type: "dg:number", + options: { format: "currency", currency: "USD", decimalScale: 2, min: 0 }, + }, + value: { + field: "value", + title: "Value", + defaultWidth: 140, + editable: false, + type: "dg:number", + options: { format: "currency", currency: "USD", decimalScale: 2, min: 0 }, + }, + contact: { + field: "contact", + title: "Contact", + defaultWidth: 160, + editable: true, + type: "dg:phone", + options: { nullable: true, defaultCountry: "US" }, }, - price: { field: "price", title: "Price", defaultWidth: 120, editable: true, type: "currency" }, - value: { field: "value", title: "Value", defaultWidth: 140, editable: false, type: "currency" }, - contact: { field: "contact", title: "Contact", defaultWidth: 160, editable: true, type: "phone" }, updatedAt: { field: "updatedAt", title: "Updated", defaultWidth: 120, editable: true, - type: "date", + type: "dg:date", + options: { nullable: true }, + }, + active: { + field: "active", + title: "Active", + defaultWidth: 110, + editable: true, + type: "dg:enum", + options: { + nullable: true, + choices: [ + { value: 0, label: "Inactive", color: "tomato" }, + { value: 1, label: "Active", color: "green" }, + ], + }, }, - active: { field: "active", title: "Active", defaultWidth: 110, editable: true, type: "active" }, }; -const columnsAdapter = localStorageColumnsAdapter(`${GRID_NAME}:columns`); - /** * The development test grid: 100,000 synthetic rows, grouped by region, with * every editable cell type the app has. @@ -71,6 +131,8 @@ export const testGridDescriptor: GridDescriptor = { "updatedAt", "active", ], + // `persist` and `adapter` are both left out: the layout is remembered in + // Web Storage under `datagrid:testGrid:columns` with no wiring at all. }, grouping: { field: "region", @@ -80,9 +142,10 @@ export const testGridDescriptor: GridDescriptor = { // `GridGrouping.order`. order: (region) => region, label: (region) => REGIONS[region]?.label ?? String(region), - // The header reads its colour from the very cell that draws the column, so - // a group's header text and that group's badges cannot drift apart. - color: (region) => regionCell.colorOf(region), + + // No `color` here. The grid groups by a `dg:enum` column, so the engine + // takes the header's colour from that column's own choices and a group's + // header text cannot drift from its badges. // The whole reason group headers left the canvas. Each region gets a // DIFFERENT set of components, which is what the hook is for. @@ -94,17 +157,10 @@ export const testGridDescriptor: GridDescriptor = { fetchCount: fetchTestRowCount, fetchRow: fetchTestRow, updateRow: updateTestRow, - loadColumns: columnsAdapter.loadColumns, - saveColumns: columnsAdapter.saveColumns, }, - cells: testGridCells, }; -export const testGrid = createGridInstance(testGridDescriptor, { - // The engine ships no listener middleware of its own — column persistence - // runs on the host app's. See `src/listener.ts`. - startListening: startAppListening, -}); +export const testGrid = createGridInstance(testGridDescriptor); /** The shape this grid's slice adds to the demo store, mounted by `store.ts`. */ export type TestGridState = GridSliceState; diff --git a/demo/src/grid/testGridCells.ts b/demo/src/grid/testGridCells.ts deleted file mode 100644 index 4402ee3..0000000 --- a/demo/src/grid/testGridCells.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { CustomRenderer } from "@glideapps/glide-data-grid"; -import { - createCellRegistry, - type CellContext, - type CellRegistry, - type CellTypeDef, -} from "@matthewhsu1/datagrid"; -import { createDateCell } from "@matthewhsu1/datagrid"; -import { createEnumCell } from "@matthewhsu1/datagrid"; -import { createNumberCell } from "@matthewhsu1/datagrid"; -import { createPhoneCell } from "@matthewhsu1/datagrid"; -import { createTextCell } from "@matthewhsu1/datagrid"; -import { REGIONS } from "./api/types"; - -// Shared cell primitives, built once at module scope. Renderer and editor -// identity is tied to the create* call, so building these inside a React render -// would remount the editor on every keystroke. -const textCell = createTextCell({ kind: "test-text" }); - -const intCell = createNumberCell({ - kind: "test-int", - format: "integer", - thousandSeparator: true, - min: 0, -}); - -const currencyCell = createNumberCell({ - kind: "test-currency", - format: "currency", - currency: "USD", - decimalScale: 2, - min: 0, -}); - -const phoneCell = createPhoneCell({ kind: "test-phone", nullable: true, defaultCountry: "US" }); - -const dateCell = createDateCell({ kind: "test-date", nullable: true }); - -// Colors come from RADIX_BADGE_SCALES in lib/grid/radixBadgePalette.ts. That -// list is the whole `RadixColor` union — "blue", "purple", and "gray" are NOT -// in it, and each scale must also be imported in theme/radixStyles.ts. -export const regionCell = createEnumCell({ - kind: "test-region", - nullable: false, - options: [ - { value: REGIONS[0].value, label: REGIONS[0].label, color: "cyan" }, - { value: REGIONS[1].value, label: REGIONS[1].label, color: "amber" }, - { value: REGIONS[2].value, label: REGIONS[2].label, color: "plum" }, - ], -}); - -const activeCell = createEnumCell({ - kind: "test-active", - nullable: true, - options: [ - { value: 0, label: "Inactive", color: "tomato" }, - { value: 1, label: "Active", color: "green" }, - ], -}); - -const asNum = (raw: unknown): number | null => (raw == null ? null : Number(raw)); -const asStr = (raw: unknown): string | null => (raw == null ? null : String(raw)); - -/** - * Left unannotated (not `CellTypeDef[]`) so each `renderer` keeps its precise - * type instead of widening to the registry's unparameterized `CustomRenderer`. - * See the same pattern, and the reason for the cast below, in - * `features/bonds/bondCells.ts`. - */ -const testCellDefs = [ - { - type: "text", - kind: "test-text", - renderer: textCell.renderer, - make: (raw: unknown, ctx: CellContext) => textCell.makeCell(asStr(raw), ctx.editable), - }, - { - type: "int", - kind: "test-int", - renderer: intCell.renderer, - make: (raw: unknown, ctx: CellContext) => intCell.makeCell(asNum(raw), ctx.editable), - }, - { - type: "currency", - kind: "test-currency", - renderer: currencyCell.renderer, - make: (raw: unknown, ctx: CellContext) => currencyCell.makeCell(asNum(raw), ctx.editable), - }, - { - type: "region", - kind: "test-region", - renderer: regionCell.renderer, - make: (raw: unknown, ctx: CellContext) => regionCell.makeCell(asNum(raw), ctx.editable), - }, - { - type: "phone", - kind: "test-phone", - renderer: phoneCell.renderer, - make: (raw: unknown, ctx: CellContext) => phoneCell.makeCell(asStr(raw), ctx.editable), - }, - { - type: "date", - kind: "test-date", - renderer: dateCell.renderer, - make: (raw: unknown, ctx: CellContext) => - dateCell.makeCell(asStr(raw), ctx.withTime, ctx.editable), - }, - { - type: "active", - kind: "test-active", - renderer: activeCell.renderer, - make: (raw: unknown, ctx: CellContext) => - activeCell.makeCell(raw == null ? null : raw ? 1 : 0, ctx.editable), - }, -]; - -/** - * Cell registry for the development test grid. Column `type` keys: "text", - * "int", "currency", "region", "phone", "date", "active". - * - * Unlike `bondCells`, every `make` passes `ctx.editable` through as it comes, so - * a column marked editable in the column def opens its editor. - */ -// The `renderer` cast is narrowed to that one field for the reason spelled out -// at the foot of `features/bonds/bondCells.ts`: `strictFunctionTypes` makes the -// per-cell renderer types and the registry's unparameterized `CustomRenderer` -// incomparable in either direction, and routing the whole def through `unknown` -// would drop the structural check on every other field. Runtime routing is by -// `data.kind` through each renderer's `isMatch`, which is unaffected. -export const testGridCells: CellRegistry = createCellRegistry( - testCellDefs.map((d): CellTypeDef => ({ - ...d, - renderer: d.renderer as unknown as CustomRenderer, - })), -); diff --git a/demo/src/listener.ts b/demo/src/listener.ts deleted file mode 100644 index c001c86..0000000 --- a/demo/src/listener.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createListenerMiddleware } from "@reduxjs/toolkit"; - -/** - * The demo app's one listener middleware. - * - * `@matthewhsu1/datagrid` ships none of its own: a listener already - * discriminates by action type, so a middleware per library would buy nothing - * and force every host to `.concat` another entry. A grid is handed this one - * through `createGridInstance(descriptor, { startListening })`. - */ -export const appListener = createListenerMiddleware(); - -export const startAppListening = appListener.startListening; diff --git a/demo/src/store.ts b/demo/src/store.ts index e95a68c..a57f75d 100644 --- a/demo/src/store.ts +++ b/demo/src/store.ts @@ -1,6 +1,5 @@ import { configureStore } from "@reduxjs/toolkit"; import { appearanceReducer } from "@matthewhsu1/datagrid"; -import { appListener } from "./listener"; import { GRID_NAME, testGrid } from "./grid/testGrid"; /** @@ -12,6 +11,9 @@ import { GRID_NAME, testGrid } from "./grid/testGrid"; * * serializableCheck is off because grid state is not serializable: row windows * hold arbitrary row objects and group keys are caller-defined. + * + * No listener middleware. The engine needs none: column persistence runs from + * the mounted grid. */ export const store = configureStore({ reducer: { @@ -20,8 +22,7 @@ export const store = configureStore({ // reducer map to a string index, which unions every slice's state together. [GRID_NAME]: testGrid.reducer, }, - middleware: (getDefault) => - getDefault({ serializableCheck: false }).concat(appListener.middleware), + middleware: (getDefault) => getDefault({ serializableCheck: false }), }); export type RootState = ReturnType; diff --git a/docs/examples/configuration.md b/docs/examples/configuration.md index ffd4345..744f0e9 100644 --- a/docs/examples/configuration.md +++ b/docs/examples/configuration.md @@ -118,31 +118,50 @@ neither gets a permanently light grid rather than a crash. ## 5. Cells -A **cell kind** names how one column draws and edits. Build each maker **once at -module scope**, never inside a render: the editor component's identity is tied -to the call, so rebuilding it per render remounts the editor on every keystroke. - -Every `kind` must be unique across the grid. The registry routes drawing and -validation by `cell.data.kind`, so two makers sharing a kind behave as one. - -### Text - -```ts -import { createTextCell } from "@matthewhsu1/datagrid"; - -export const referenceCell = createTextCell({ - kind: "reference", // REQUIRED. Unique across the grid. - validation: { +A column names its cell the way glide does: a string in `ColumnDef.type`. There +is nothing to build, nothing to register, and no factory to call at module +scope. Each cell's settings ride on that column's own `options`. + +The five this package draws are prefixed `dg:`. Glide's own kinds are reachable +under glide's own names, unprefixed. The prefix is what keeps the two sets apart +for good — glide may add kinds to its set without ever colliding with ours. + +| `type` | Drawn by | Edits | Options | +| -------------- | -------- | ----- | ------- | +| `dg:text` | engine | yes | `TextCellOptions` | +| `dg:number` | engine | yes | `NumberCellOptions` | +| `dg:date` | engine | yes | `DateCellOptions` | +| `dg:enum` | engine | yes | `EnumCellOptions` (required) | +| `dg:phone` | engine | yes | `PhoneCellOptions` | +| `text` | glide | yes | none | +| `number` | glide | yes | none | +| `boolean` | glide | yes | none | +| `uri` | glide | yes | none | +| `markdown` | glide | yes | none | +| `image` | glide | no | none | +| `bubble` | glide | no | none | +| `drilldown` | glide | no | none | + +`image`, `bubble`, and `drilldown` read a list. Give them a `string[]`, or a +single string that the engine wraps for you — a row field like `avatarUrl` needs +no mapping. All three are read-only whatever the column's `editable` says. + +### `dg:text` + +```ts no-check +{ + type: "dg:text", + options: { required: true, // default false. Empty fails. This cell's "nullable". minLength: 3, // default undefined. Shortest accepted string. maxLength: 32, // default undefined. Longest accepted string. email: false, // default false. Pragmatic check, not full RFC 5322. pattern: { value: /^ORD-/, message: "Must start with ORD-" }, // ^ default undefined. A bare RegExp works too; then the message is generic. - validate: (value) => (value.endsWith("!") ? "No exclamation marks" : null), + validate: (value: string) => (value.endsWith("!") ? "No exclamation marks" : null), // ^ default undefined. Escape hatch. Return a message, or null when valid. }, -}); +} ``` Rules run in one fixed order and the editor shows the **first** failure: @@ -150,84 +169,87 @@ Rules run in one fixed order and the editor shows the **first** failure: empty string is valid unless `required`, and when empty-and-optional the remaining rules are skipped entirely. -### Number - -```ts -import { createNumberCell } from "@matthewhsu1/datagrid"; - -export const totalCell = createNumberCell({ - kind: "total", // REQUIRED. Unique across the grid. - nullable: false, // default false. true lets the user clear the cell. - format: "currency", // default undefined. "integer" | "decimal" | "currency". - decimalScale: 2, // default undefined. Digits after the point. - currency: "USD", // default undefined. ISO code; used by format "currency". - min: 0, // default undefined. Editor blocks entry below this. - max: 1_000_000, // default undefined. Editor blocks entry above this. - thousandSeparator: true, // default undefined. Group digits by locale. - prefix: undefined, // default undefined. Text drawn before the number. - suffix: undefined, // default undefined. Text drawn after the number. -}); +### `dg:number` + +```ts no-check +{ + type: "dg:number", + options: { + nullable: false, // default false. true lets the user clear the cell. + format: "currency", // default undefined. "integer" | "decimal" | "currency". + decimalScale: 2, // default undefined. Digits after the point. + currency: "USD", // default undefined. ISO code; used by format "currency". + min: 0, // default undefined. Editor blocks entry below this. + max: 1_000_000, // default undefined. Editor blocks entry above this. + thousandSeparator: true, // default undefined. Group digits by locale. + prefix: undefined, // default undefined. Text drawn before the number. + suffix: undefined, // default undefined. Text drawn after the number. + }, +} ``` `min` and `max` guard **entry**, not the stored data. A row that already holds an out-of-range value still draws it; the range only stops the user typing a new one. -### Date - -```ts -import { createDateCell } from "@matthewhsu1/datagrid"; +### `dg:date` -export const placedAtCell = createDateCell({ - kind: "placedAt", // REQUIRED. Unique across the grid. - nullable: true, // default false. true lets the user confirm an empty date. -}); +```ts no-check +{ + type: "dg:date", + options: { + nullable: true, // default false. true lets the user confirm an empty date. + withTime: false, // default false. true keeps the time part. + }, +} ``` -Values are stored as canonical UTC ISO strings. Whether a column keeps the time -part is **not** set here — it is `withTime` on the column (section 6), because -one date cell can serve both a date column and a datetime column. +Values are stored as canonical UTC ISO strings. `withTime` is per column, which +is why it lives here rather than on the column itself. The editor holds the edit: a click on a day changes nothing until the user presses OK. Cancel and Escape discard. -### Enum - -```ts -import { createEnumCell } from "@matthewhsu1/datagrid"; - -export const statusCell = createEnumCell({ - kind: "status", // REQUIRED. Unique across the grid. - nullable: false, // default false. true adds a "clear" item to the Select. - options: [ - // REQUIRED. The full set of selectable values. - { value: 0, label: "Draft" }, // color defaults by value. - { value: 1, label: "Paid", color: "green" }, // color: any RADIX_BADGE_SCALES entry. - { value: 2, label: "Void", color: "red" }, - ], -}); +### `dg:enum` + +```ts no-check +{ + type: "dg:enum", + options: { + nullable: false, // default false. true adds a "clear" item to the Select. + choices: [ + // REQUIRED. The full set of selectable values. + { value: 0, label: "Draft" }, // color defaults by value. + { value: 1, label: "Paid", color: "green" }, // color: any RADIX_BADGE_SCALES entry. + { value: 2, label: "Void", color: "red" }, + ], + }, +} ``` `value` must be a **number**. An unlisted value draws as an empty cell. An omitted `color` falls back to `BADGE_SEQUENCE[value % 10]`, so colours stay -stable as you add options at the end. A `color` outside `RADIX_BADGE_SCALES` +stable as you add choices at the end. A `color` outside `RADIX_BADGE_SCALES` resolves to nothing and warns; adding a new scale means adding its token CSS to `src/theme/radixStyles.ts` **and** the scale to `RADIX_BADGE_SCALES`. The usable scales are: `iris`, `tomato`, `amber`, `grass`, `cyan`, `plum`, `orange`, `jade`, `crimson`, `indigo`, `red`, `green`. -### Phone +Grouping by a `dg:enum` column colours the group headers from these choices on +its own — see section 8. -```ts -import { createPhoneCell } from "@matthewhsu1/datagrid"; +### `dg:phone` -export const phoneCell = createPhoneCell({ - kind: "phone", // REQUIRED. Unique across the grid. - nullable: true, // default false. true lets the user clear the cell. - defaultCountry: "US", // default "US". ISO 3166-1 alpha-2, for un-prefixed input. -}); +```ts no-check +{ + type: "dg:phone", + options: { + nullable: true, // default false. true lets the user clear the cell. + defaultCountry: "US", // default "US". ISO 3166-1 alpha-2, for un-prefixed input. + }, +} ``` Values are stored canonically as E.164 (`+14155552671`). Any non-empty value @@ -235,16 +257,31 @@ must parse as a valid number; `nullable` decides only whether empty is allowed. ### A cell kind the package does not ship +Build the renderer with `createCustomCell`, then hand the engine a `CellTypeDef` +on `descriptor.cells` (section 7). + ```tsx -import { createCustomCell, drawEmptyDash, makeCustomCell } from "@matthewhsu1/datagrid"; +import { + createCustomCell, + drawEmptyDash, + makeCustomCell, + type CellContext, + type CellTypeDef, +} from "@matthewhsu1/datagrid"; + +/** This cell's own settings, read off the column's `options`. */ +interface RatingOptions { + max: number; +} interface RatingData { kind: "rating"; // REQUIRED. Literal type; routes glide's isMatch. value: number | null; readOnly?: boolean; + options: RatingOptions; } -export const ratingRenderer = createCustomCell({ +const ratingRenderer = createCustomCell({ kind: "rating", // REQUIRED. Must equal cell.data.kind at runtime. draw: (args, data) => { // REQUIRED. Canvas only — no DOM reaches a cell. @@ -257,6 +294,7 @@ export const ratingRenderer = createCustomCell({ // REQUIRED. Real DOM — use Radix here. onChange({ ...value, value: Number(e.target.value) })} /> @@ -268,84 +306,70 @@ export const ratingRenderer = createCustomCell({ }, }); -export const makeRatingCell = (value: number | null, allowOverlay = true) => - makeCustomCell({ kind: "rating", value }, String(value ?? ""), allowOverlay); -// ^ data ^ copyData (what Ctrl+C yields) ^ default true +export const ratingCellDef: CellTypeDef = { + type: "rating", // REQUIRED. What a column's `type` names. + kind: "rating", // default undefined. Needed only to route `validate`. + renderer: ratingRenderer as unknown as CellTypeDef["renderer"], + // ^ default undefined. Custom cells need it. See the note below on the cast. + make: (raw: unknown, ctx: CellContext) => + makeCustomCell( + { + kind: "rating", + value: raw == null ? null : Number(raw), + options: ctx.options as RatingOptions, + ...(ctx.editable ? {} : { readOnly: true }), + }, + String(raw ?? ""), // copyData — what Ctrl+C yields + ctx.editable, + ), + validate: (cell) => (cell.data as unknown as RatingData).value !== 0, + // ^ default undefined. Gates commit and paste. Omit and the value commits as typed. +}; ``` Cells are painted on a ``, so `draw` gets no DOM components. The **editor** is real DOM, and it should be built from `@radix-ui/themes` like the built-in ones. -### The registry +**The renderer cast is required, and it is safe.** `CellTypeDef.renderer` is +unparameterized, while `createCustomCell` types its renderer to that cell's own +payload. Under `strictFunctionTypes` the two are incomparable in **either** +direction, so no annotation removes the cast. Runtime routing is by +`cell.data.kind` through the renderer's own `isMatch`, which the cast does not +touch. -One place maps a column's `type` to its maker. Build it once. +`type` is what a column names; `kind` is what a rendered cell carries. They are +allowed to differ. A def whose `type` matches a built-in **replaces** that +built-in for that grid, which is how you change a cell the engine draws without +forking the package. + +A column pointing at it must say `custom: true`: ```ts -import { createCellRegistry, type CellContext, type CellTypeDef } from "@matthewhsu1/datagrid"; -import type { CustomRenderer } from "@glideapps/glide-data-grid"; -import { placedAtCell, referenceCell, statusCell, totalCell } from "./shared"; - -export const orderCells = createCellRegistry( - [ - { - type: "text", // REQUIRED. Matches ColumnDef.type. - kind: "reference", // default undefined. Needed only to route `validate`. - make: (raw: unknown, ctx: CellContext) => - referenceCell.makeCell(raw == null ? null : String(raw), ctx.editable), - // ^ REQUIRED. ctx is { editable, withTime } for the column being drawn. - renderer: referenceCell.renderer, // default undefined. Custom cells need it. - validate: referenceCell.validate, // default undefined. Gates commit and paste. - }, - { - type: "currency", - kind: "total", - make: (raw: unknown, ctx: CellContext) => - totalCell.makeCell(raw == null ? null : Number(raw), ctx.editable), - renderer: totalCell.renderer, - validate: totalCell.validate, - }, - { - type: "date", - kind: "placedAt", - // `withTime` comes from the COLUMN, which is why make receives ctx. - make: (raw: unknown, ctx: CellContext) => - placedAtCell.makeCell(raw == null ? null : String(raw), ctx.withTime, ctx.editable), - renderer: placedAtCell.renderer, - validate: placedAtCell.validate, - }, - { - type: "status", - make: (raw: unknown, ctx: CellContext) => - statusCell.makeCell(raw == null ? null : Number(raw), ctx.editable), - renderer: statusCell.renderer, // enum cells expose no `validate`. - }, - ].map( - // The two casts below are load-bearing. See the note under this block. - (d): CellTypeDef => ({ - ...d, - renderer: d.renderer as unknown as CustomRenderer, - validate: d.validate as unknown as CellTypeDef["validate"], - }), - ), -); +import type { ColumnDef } from "@matthewhsu1/datagrid"; + +export const ratingColumn: ColumnDef = { + field: "rating", + title: "Rating", + defaultWidth: 120, + editable: true, + type: "rating", // REQUIRED. Matches your CellTypeDef.type. + custom: true, // REQUIRED for a type this package does not ship. + options: { max: 5 }, // whatever your cell reads; `unknown` to the engine. +}; ``` -`type` is what a column names; `kind` is what a rendered cell carries. They are -allowed to differ, and often should: two columns can share the `date` type while -each holds its own cell kind. - -**The two casts are required, and they are safe.** `CellTypeDef.renderer` and -`CellTypeDef.validate` are unparameterized, while a maker's `renderer` and -`validate` are typed to that cell's own payload. Under `strictFunctionTypes` the -two are incomparable in **either** direction, so no annotation removes the cast. -Casting those two fields only — rather than the whole def through `unknown` — -keeps the structural check on `type`, `kind`, and `make`. Runtime routing is by -`cell.data.kind` through each renderer's `isMatch`, which the cast does not -touch. +**That one word is what makes every other column check.** Without it, `type` +would have to accept any string, and TypeScript would then accept a mistyped +`type`, a mistyped key inside a built-in's `options`, and a `dg:enum` with no +choices — all silently. With `custom` as the marker, each of those is an error: -Omit `validate` and nothing is gated: the value commits as typed. `kind` is only -read to route `validate`, so an entry with neither needs neither. +```ts no-check +{ type: "dg:numbr" } // ✗ not a cell type, and not custom +{ type: "dg:number", options: { currncy } } // ✗ `currncy` is not an option +{ type: "dg:enum" } // ✗ dg:enum must say what it holds +{ type: "dg:text", options: { withTime } } // ✗ withTime belongs to dg:date +``` --- @@ -362,23 +386,46 @@ export const COLUMN_DEFS: Record = { title: "Reference", // REQUIRED. Header text. defaultWidth: 160, // REQUIRED. Pixels, before the user resizes. editable: false, // REQUIRED. Whether a cell here accepts an edit. - type: "text", // REQUIRED. Must match a cell registry entry. + type: "dg:text", // REQUIRED. Names the cell. See section 5. sortable: true, // default true. false hides this column's sort menu. + options: { required: true }, // this cell type's own settings. + }, + total: { + field: "total", + title: "Total", + defaultWidth: 120, + editable: true, + type: "dg:number", + options: { format: "currency", currency: "USD" }, }, - total: { field: "total", title: "Total", defaultWidth: 120, editable: true, type: "currency" }, placedAt: { field: "placedAt", title: "Placed", defaultWidth: 180, editable: true, - type: "date", - withTime: true, // default false. Date cells only: keep the time part. + type: "dg:date", + options: { nullable: true, withTime: true }, + }, + customer: { + field: "customer", + title: "Customer", + defaultWidth: 200, + editable: true, + type: "text", // one of glide's own kinds, drawn by glide }, }; -export const DEFAULT_ORDER = ["reference", "total", "placedAt"]; +export const DEFAULT_ORDER = ["reference", "total", "placedAt", "customer"]; ``` +The five fields above `type` are read by the **grid** — the header, the layout, +the sort menu, the edit path. `options` is read only by the cell the `type` +names, which is why a setting like `withTime` lives there. + +`type` and `options` are checked **together**: pick `dg:enum` and TypeScript +insists on its `choices`; pick `dg:date` and only date options are accepted. A +type this package does not ship needs `custom: true` beside it — see section 5. + Set `sortable: false` for any column the **server** cannot order by — one your client derives, or one with no index behind it. The sort is sent to the server, so an arrow on such a column is an arrow onto an error. @@ -391,35 +438,55 @@ The static description of one grid. Written by the host, never by the engine. ```ts import type { GridDescriptor } from "@matthewhsu1/datagrid"; -import { localStorageColumnsAdapter } from "@matthewhsu1/datagrid"; -import { COLUMN_DEFS, DEFAULT_ORDER, orderCells } from "./shared"; +import { COLUMN_DEFS, DEFAULT_ORDER } from "./shared"; import { fetchCount, fetchRow, fetchRows, subscribe, updateRow, type OrderRow } from "./shared"; -const columns = localStorageColumnsAdapter("orders:columns"); -// ^ (storageKey, storage = localStorage). Swap in a REST adapter of the same shape. - export const orderDescriptor: GridDescriptor = { name: "orders", // REQUIRED. Store namespace + storage-key prefix. Unique, stable. rowKey: (row) => row.id, // REQUIRED. Row identity. NEVER derive it from array position. columns: { defs: COLUMN_DEFS, // REQUIRED. All columns, keyed by field. defaultOrder: DEFAULT_ORDER, // REQUIRED. Left-to-right order before the user reorders. + persist: true, // default true. false forgets the user's layout on reload. + adapter: undefined, // default undefined. Your own storage; see below. }, - cells: orderCells, // REQUIRED. Looked up by ColumnDef.type. + cells: undefined, // default undefined. Cell types BEYOND the built-in ones. pageSize: 100, // default 100. Rows per loaded page. api: { - fetchRows, // REQUIRED. One ordered slice. See the note below. - fetchCount, // REQUIRED. Total under the current view. - fetchRow, // REQUIRED. One row by id, for an id-only push. - updateRow, // REQUIRED. Persist one cell edit. { ok: false } rolls back. - subscribe, // default undefined. Live feed; returns an unsubscribe. - loadColumns: columns.loadColumns, // default undefined. Omit to always start from defaults. - saveColumns: columns.saveColumns, // default undefined. Needs startListening — section 9. + fetchRows, // REQUIRED. One ordered slice. See the note below. + fetchCount, // REQUIRED. Total under the current view. + fetchRow, // REQUIRED. One row by id, for an id-only push. + updateRow, // REQUIRED. Persist one cell edit. { ok: false } rolls back. + subscribe, // default undefined. Live feed; returns an unsubscribe. }, // grouping: omitted here. See section 8. }; ``` +**Column layout is remembered by default.** With `persist` and `adapter` both +left out, the engine writes the user's order, widths, and hidden columns to Web +Storage under `datagrid:${name}:columns`. Nothing to wire, and no middleware. + +Keep them somewhere else with an adapter of your own: + +```ts +import type { ColumnsAdapter, ColumnsState } from "@matthewhsu1/datagrid"; + +export const serverColumns: ColumnsAdapter = { + async loadColumns(): Promise { + const res = await fetch("/api/grid-layout/orders"); + return res.ok ? ((await res.json()) as ColumnsState) : null; + }, + async saveColumns(state: ColumnsState): Promise { + await fetch("/api/grid-layout/orders", { method: "PUT", body: JSON.stringify(state) }); + }, +}; +``` + +Both calls are `async` whatever is behind them, so the engine has one thing to +await. Saves are debounced 500 ms after the user stops, so a column drag is one +write and not one per pixel. + `fetchRows` asks for a **slice of an order**, never for a numbered page. The `sort` it receives is a `SortSpec` (`{ field, direction, nulls }`) or `null`. @@ -450,6 +517,7 @@ export const byRegion: GridGrouping = { order: (g) => REGIONS.indexOf(g), // REQUIRED. MUST agree with `field` ascending. label: (g) => `${g} orders`, // REQUIRED. Header text for a group. color: () => "iris", // default undefined. Header colour, a Radix scale. + // ^ OMIT IT when grouping by a dg:enum column — see below. header: (g) => {g}, // default undefined. Your nodes, right of the name. headerHeight: 40, // default 40. Pixels. Nothing measures what you drew. }; @@ -475,6 +543,11 @@ window, and the client cannot repair it. before React draws into it. A taller header is clipped; a shorter one leaves a gap. Set it to fit the tallest thing `header` can return. +**Group by a `dg:enum` column and the colours are automatic.** The engine holds +that column's `choices`, so it colours each header's name to match the badges +under it and `color` can be left out entirely. Set `color` to override that, or +to colour groups on a column that is not an enum. + `header` is called during render, once per visible group. Keep it pure and cheap; it is not the place to start a fetch. The same node is used twice: in the header above the group, and in the banner naming the group you are scrolled @@ -488,21 +561,14 @@ The runtime built from one descriptor. Create it **once, at module scope**. ```ts import { createGridInstance } from "@matthewhsu1/datagrid"; -import { createListenerMiddleware } from "@reduxjs/toolkit"; import { orderDescriptor } from "./shared"; -export const listenerMiddleware = createListenerMiddleware(); - -export const orderGrid = createGridInstance(orderDescriptor, { - startListening: listenerMiddleware.startListening, - // ^ default undefined. REQUIRED whenever descriptor.api.saveColumns is set. -}); +export const orderGrid = createGridInstance(orderDescriptor); ``` -Column persistence runs as a listener effect. The engine ships no middleware of -its own, so it registers on yours. Set `saveColumns` without passing -`startListening` and `createGridInstance` **throws** at wiring time, rather than -silently never saving. +The descriptor is the only argument. Column persistence runs from the mounted +grid, not from middleware, so there is no listener to hand over and nothing to +`.concat` into your store. **One mounted grid per instance.** The instance holds single slots for the row store, the pending store of a hold, and the collapse carry point. Two grids @@ -513,23 +579,23 @@ the same rows needs a second `createGridInstance`. ## 10. The store -Mount the instance's reducer under the descriptor's `name`, and concat your -listener middleware. +Mount the instance's reducer under the descriptor's `name`. ```ts import { appearanceReducer } from "@matthewhsu1/datagrid"; import { configureStore } from "@reduxjs/toolkit"; -import { listenerMiddleware, orderGrid } from "./shared"; +import { orderGrid } from "./shared"; export const store = configureStore({ reducer: { appearance: appearanceReducer, orders: orderGrid.reducer, // the key MUST equal descriptor.name }, - middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware), }); ``` +No middleware of ours goes here. The engine ships none and needs none. + The reducer holds only client-owned state: group collapse, sort, column order and widths, hidden columns, row selection, and the last rejected edit. **No server row is ever in Redux** — those live in the engine's own row store. @@ -568,7 +634,7 @@ export function AppRoot({ children }: { children: React.ReactNode }) { ## 12. Rendering -`` takes exactly one prop. +`` needs one prop. ```tsx import { DataGrid } from "@matthewhsu1/datagrid"; @@ -583,6 +649,52 @@ Everything else — sort menus, the column picker, group headers, the edit banne — is drawn by the grid from the descriptor. Give it a sized parent; it fills the space it is given. +### Drawing the column header yourself + +Three optional props open the header to glide's own API. What you pass beats +what the engine draws, which beats glide's default. + +```tsx +import { DataGrid, type GridDrawHeader } from "@matthewhsu1/datagrid"; +import type { SpriteMap } from "@glideapps/glide-data-grid"; +import { orderGrid } from "./shared"; + +const drawHeader: GridDrawHeader = (args, drawDefault) => { + // Everything glide passes is here, plus the engine's own sort state. + const sort = args.sort; + + if (sort === null || sort.field !== args.column.id) return drawDefault(); + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.accentColor; + ctx.fillText(`${args.column.title} ${sort.dir === "asc" ? "▲" : "▼"}`, rect.x + 8, rect.y + 20); +}; + +const headerIcons: SpriteMap = { + flag: (props) => ``, +}; + +export function OrdersPage() { + return ( + + ); +} +``` + +`drawHeader` is **total**: the sort chevron and the menu arrow go with it. +`args.sort` and `args.sortable` are there so you can draw them back, and +`drawDefault()` paints the engine's header if you only want to add to it. The +callback is grid-wide, exactly as glide's is — branch on `args.column.id`. + +`headerIcons` is **merged**, because it is a dictionary. Naming one sprite +replaces that entry and leaves the rest, including the `sortAsc` and `sortDesc` +chevrons the sort indicator refers to by name. + --- ## 13. Also exported diff --git a/docs/examples/shared.ts b/docs/examples/shared.ts index 325cbca..066c0ec 100644 --- a/docs/examples/shared.ts +++ b/docs/examples/shared.ts @@ -9,12 +9,7 @@ * * Nothing here ships. `package.json`'s `files` field publishes `dist` only. */ -import type { - ColumnsState, - FetchRowsParams, - RowChange, - UpdateRowParams, -} from "@matthewhsu1/datagrid"; +import type { FetchRowsParams, RowChange, UpdateRowParams } from "@matthewhsu1/datagrid"; /** The row the documentation's example grid draws. */ export interface OrderRow { @@ -55,139 +50,85 @@ export function subscribe(_handler: (change: RowChange) => void): () => return () => {}; } -export async function loadColumns(): Promise { - return null; -} - -export async function saveColumns(_state: ColumnsState): Promise {} - // ─── What the later blocks import ──────────────────────────────────────────── // -// A block in section 7 needs the columns and cells that sections 5 and 6 built; -// a block in section 10 needs the instance from section 9. Rather than make -// every block redeclare its predecessors, each earlier section's result is -// assembled once here. The blocks below are the SAME code as the doc's, so a -// change to either side that breaks the other fails `npm run check:docs`. +// A block in section 7 needs the columns that section 5 built; a block in +// section 10 needs the instance from section 9. Rather than make every block +// redeclare its predecessors, each earlier section's result is assembled once +// here. The blocks below are the SAME code as the doc's, so a change to either +// side that breaks the other fails `npm run check:docs`. import { QueryClient } from "@tanstack/react-query"; -import { configureStore, createListenerMiddleware } from "@reduxjs/toolkit"; -import type { CustomRenderer } from "@glideapps/glide-data-grid"; +import { configureStore } from "@reduxjs/toolkit"; import { appearanceReducer, - createCellRegistry, - createDateCell, - createEnumCell, createGridInstance, - createNumberCell, - createPhoneCell, - createTextCell, - localStorageColumnsAdapter, - type CellContext, - type CellTypeDef, type ColumnDef, type GridDescriptor, } from "@matthewhsu1/datagrid"; -export const referenceCell = createTextCell({ kind: "reference", validation: { required: true } }); -export const totalCell = createNumberCell({ kind: "total", format: "currency", currency: "USD" }); -export const placedAtCell = createDateCell({ kind: "placedAt", nullable: true }); -export const statusCell = createEnumCell({ - kind: "status", - options: [ - { value: 0, label: "Draft" }, - { value: 1, label: "Paid", color: "green" }, - { value: 2, label: "Void", color: "red" }, - ], -}); -export const phoneCell = createPhoneCell({ kind: "phone", nullable: true }); - -export const orderCells = createCellRegistry( - [ - { - type: "text", - kind: "reference", - make: (raw: unknown, ctx: CellContext) => - referenceCell.makeCell(raw == null ? null : String(raw), ctx.editable), - renderer: referenceCell.renderer, - validate: referenceCell.validate, - }, - { - type: "currency", - kind: "total", - make: (raw: unknown, ctx: CellContext) => - totalCell.makeCell(raw == null ? null : Number(raw), ctx.editable), - renderer: totalCell.renderer, - validate: totalCell.validate, - }, - { - type: "date", - kind: "placedAt", - make: (raw: unknown, ctx: CellContext) => - placedAtCell.makeCell(raw == null ? null : String(raw), ctx.withTime, ctx.editable), - renderer: placedAtCell.renderer, - validate: placedAtCell.validate, - }, - { - type: "status", - make: (raw: unknown, ctx: CellContext) => - statusCell.makeCell(raw == null ? null : Number(raw), ctx.editable), - renderer: statusCell.renderer, - }, - ].map((d): CellTypeDef => ({ - ...d, - renderer: d.renderer as unknown as CustomRenderer, - validate: d.validate as unknown as CellTypeDef["validate"], - })), -); - export const COLUMN_DEFS: Record = { reference: { field: "reference", title: "Reference", defaultWidth: 160, editable: false, - type: "text", + type: "dg:text", + options: { required: true }, + }, + total: { + field: "total", + title: "Total", + defaultWidth: 120, + editable: true, + type: "dg:number", + options: { format: "currency", currency: "USD" }, }, - total: { field: "total", title: "Total", defaultWidth: 120, editable: true, type: "currency" }, placedAt: { field: "placedAt", title: "Placed", defaultWidth: 180, editable: true, - type: "date", - withTime: true, + type: "dg:date", + options: { nullable: true, withTime: true }, + }, + status: { + field: "status", + title: "Status", + defaultWidth: 120, + editable: true, + type: "dg:enum", + options: { + choices: [ + { value: 0, label: "Draft" }, + { value: 1, label: "Paid", color: "green" }, + { value: 2, label: "Void", color: "red" }, + ], + }, + }, + phone: { + field: "phone", + title: "Phone", + defaultWidth: 160, + editable: true, + type: "dg:phone", + options: { nullable: true }, }, }; -export const DEFAULT_ORDER = ["reference", "total", "placedAt"]; - -const columnsAdapter = localStorageColumnsAdapter("orders:columns"); +export const DEFAULT_ORDER = ["reference", "total", "placedAt", "status", "phone"]; export const orderDescriptor: GridDescriptor = { name: "orders", rowKey: (row) => row.id, columns: { defs: COLUMN_DEFS, defaultOrder: DEFAULT_ORDER }, - cells: orderCells, - api: { - fetchRows, - fetchCount, - fetchRow, - updateRow, - subscribe, - loadColumns: columnsAdapter.loadColumns, - saveColumns: columnsAdapter.saveColumns, - }, + api: { fetchRows, fetchCount, fetchRow, updateRow, subscribe }, }; -export const listenerMiddleware = createListenerMiddleware(); - -export const orderGrid = createGridInstance(orderDescriptor, { - startListening: listenerMiddleware.startListening, -}); +export const orderGrid = createGridInstance(orderDescriptor); export const store = configureStore({ reducer: { appearance: appearanceReducer, orders: orderGrid.reducer }, - middleware: (getDefault) => getDefault().prepend(listenerMiddleware.middleware), }); export const queryClient = new QueryClient(); diff --git a/package.json b/package.json index 055f66a..d3d1064 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matthewhsu1/datagrid", - "version": "0.2.0", + "version": "0.3.0", "description": "A virtualised, server-paged data grid engine for React — windowed paging, a page cache, optimistic edits, cross-client row sync, grouping, and typed cell editors, over glide-data-grid.", "license": "MIT", "author": "MatthewHsu1 ", @@ -58,12 +58,12 @@ }, "peerDependencies": { "@glideapps/glide-data-grid": "6.0.4-alpha24", - "@radix-ui/themes": "^3.3.0", - "@reduxjs/toolkit": "^2.12.0", - "@tanstack/react-query": "^5.101.2", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-redux": "^9.3.0" + "@radix-ui/themes": "^3.0.0", + "@reduxjs/toolkit": "^2.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-redux": "^9.0.0" }, "dependencies": { "@tanstack/react-pacer": "^0.22.1", diff --git a/src/components/ui/datePicker.test.tsx b/src/components/ui/datePicker.test.tsx index e210adc..0a538c4 100644 --- a/src/components/ui/datePicker.test.tsx +++ b/src/components/ui/datePicker.test.tsx @@ -253,9 +253,7 @@ describe("DatePicker: range mode", () => { describe("DatePicker: range-datetime mode", () => { it("shows a time input for each end", () => { - mount( - , - ); + mount(); expect(screen.getByLabelText("Start time")).toBeInTheDocument(); expect(screen.getByLabelText("End time")).toBeInTheDocument(); diff --git a/src/components/ui/datePicker.tsx b/src/components/ui/datePicker.tsx index ec957f3..d6f00f0 100644 --- a/src/components/ui/datePicker.tsx +++ b/src/components/ui/datePicker.tsx @@ -1,11 +1,7 @@ import { Button, Flex, Text } from "@radix-ui/themes"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; import { useId, useState, type KeyboardEvent } from "react"; -import { - DayPicker, - type ChevronProps, - type DateRange as CalendarRange, -} from "react-day-picker"; +import { DayPicker, type ChevronProps, type DateRange as CalendarRange } from "react-day-picker"; import { PatternFormat } from "react-number-format"; import { dateToMask, maskToDate } from "../../lib/date/dateMask"; import { composeIso, isoToCalendarDate, isoToTimeInput } from "../../lib/date/dateUtils"; @@ -186,10 +182,7 @@ export function DatePicker(props: DatePickerProps) { const components = { Chevron }; return ( -
+
screen.getByRole("button", { name: /date|1\/1\/2000|6\/1\/ describe("DatePickerPopover", () => { it("labels the trigger with the placeholder when there is no value", () => { mount( - , + , ); expect(screen.getByRole("button", { name: "Pick a date" })).toBeInTheDocument(); diff --git a/src/components/ui/dayPickerTheme.ts b/src/components/ui/dayPickerTheme.ts index d920304..5771985 100644 --- a/src/components/ui/dayPickerTheme.ts +++ b/src/components/ui/dayPickerTheme.ts @@ -74,8 +74,7 @@ const BASE: Partial = { dropdowns: "dg:flex dg:items-center dg:gap-1", dropdown_root: - "dg:relative dg:rounded-md dg:px-1.5 dg:py-0.5 " + - "dg:hover:bg-[var(--gray-3,#f0f0f3)]", + "dg:relative dg:rounded-md dg:px-1.5 dg:py-0.5 " + "dg:hover:bg-[var(--gray-3,#f0f0f3)]", dropdown: "dg:absolute dg:inset-0 dg:cursor-pointer dg:opacity-0", month_grid: "dg:border-collapse", @@ -88,8 +87,7 @@ const BASE: Partial = { day_button: DAY_BUTTON, // Property-disjoint from selection: a ring, never a fill or a colour. - today: - "dg:[&>button]:ring-1 dg:[&>button]:ring-inset dg:[&>button]:ring-[var(--gray-8,#c7cbd1)]", + today: "dg:[&>button]:ring-1 dg:[&>button]:ring-inset dg:[&>button]:ring-[var(--gray-8,#c7cbd1)]", outside: "dg:[&>button]:text-[var(--gray-8,#c7cbd1)]", disabled: "dg:opacity-30", hidden: "dg:invisible", diff --git a/src/datagrid.src.css b/src/datagrid.src.css index fe4dc29..7ed7683 100644 --- a/src/datagrid.src.css +++ b/src/datagrid.src.css @@ -47,4 +47,3 @@ * else (the demo's Vite root, for instance) does not. */ @source "./**/*.ts"; - diff --git a/src/features/dataGrid/ColumnHeaderMenu.test.tsx b/src/features/dataGrid/ColumnHeaderMenu.test.tsx index 00fb7dc..fd09130 100644 --- a/src/features/dataGrid/ColumnHeaderMenu.test.tsx +++ b/src/features/dataGrid/ColumnHeaderMenu.test.tsx @@ -13,10 +13,10 @@ function renderMenu(props: Partial[0]> = {}) return render( {}} - onClose={() => {}} + target={TARGET} + sort={null} + onSort={() => {}} + onClose={() => {}} {...props} /> , diff --git a/src/features/dataGrid/ColumnPicker.test.tsx b/src/features/dataGrid/ColumnPicker.test.tsx index 1c99194..1b86e65 100644 --- a/src/features/dataGrid/ColumnPicker.test.tsx +++ b/src/features/dataGrid/ColumnPicker.test.tsx @@ -16,9 +16,15 @@ interface Row { } const DEFS: Record = { - id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, - name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" }, - region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" }, + id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "dg:number" }, + name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "dg:text" }, + region: { + field: "region", + title: "Region", + defaultWidth: 120, + editable: false, + type: "dg:number", + }, }; function harness({ grouped }: { grouped: boolean }) { @@ -40,7 +46,6 @@ function harness({ grouped }: { grouped: boolean }) { } : undefined, api: server.api, - cells: { customRenderers: [], validateCell: () => true, make: () => undefined }, } as unknown as GridDescriptor; const instance = createGridInstance(descriptor); diff --git a/src/features/dataGrid/DataGrid.tsx b/src/features/dataGrid/DataGrid.tsx index feadd0a..eefdfbc 100644 --- a/src/features/dataGrid/DataGrid.tsx +++ b/src/features/dataGrid/DataGrid.tsx @@ -1,7 +1,12 @@ // src/features/dataGrid/DataGrid.tsx -import { DataEditor, type DataEditorRef, type Rectangle } from "@glideapps/glide-data-grid"; +import { + DataEditor, + type DataEditorRef, + type Rectangle, + type SpriteMap, +} from "@glideapps/glide-data-grid"; import "@glideapps/glide-data-grid/dist/index.css"; -import { useCallback, useLayoutEffect, useRef } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; import { useSelector } from "react-redux"; import { useGridTheme } from "../../theme/useGridTheme"; import { specFromGridSort } from "./data/sortSpec"; @@ -11,9 +16,17 @@ import type { DisplayModel } from "./displayModel"; import { GroupHeaderLayer } from "./GroupHeaderLayer"; import { COLUMN_HEADER_HEIGHT } from "./rowHeights"; import { ColumnPicker } from "./ColumnPicker"; +import { groupColumnColor } from "./groupColumnColor"; +import { + mergeHeaderIcons, + withSortArgs, + type GridDrawHeader, + type GridDrawHeaderArgs, +} from "./headerDoors"; +import { sortablePredicate } from "./sortable"; +import { useColumnPersistence } from "./hooks/useColumnPersistence"; import { useColumnSortMenu } from "./hooks/useColumnSortMenu"; import { GridHeaderBar } from "./GridHeaderBar"; -import { sortHeaderIcons } from "./headerIcons"; import { useCellRenderer } from "./hooks/useCellRenderer"; import { useDisplayModel } from "./hooks/useDisplayModel"; import { useGridColumns } from "./hooks/useGridColumns"; @@ -45,11 +58,41 @@ const ROW_MARKER_WIDTH = 40; */ const SCROLLBAR_GUTTER = 18; +export type { GridDrawHeader, GridDrawHeaderArgs }; + +export interface DataGridProps { + /** The grid to draw, from `createGridInstance`. */ + instance: GridInstance; + + /** + * Draws the column header instead of the engine. See `GridDrawHeader`. + */ + drawHeader?: GridDrawHeader; + + /** + * Named header sprites, MERGED over the engine's own. + * + * A dictionary, unlike `drawHeader`: naming one icon replaces that entry and + * leaves the rest — including the engine's `sortAsc` and `sortDesc` chevrons, + * which the sort indicator refers to by name. + */ + headerIcons?: SpriteMap; + + /** + * Height of the column-header row, in pixels. Defaults to glide's 36. + * + * It reaches the group-header layer as well as the canvas: every group header + * is placed by measuring down from this line. + */ + headerHeight?: number; +} + export function DataGrid({ instance, -}: { - instance: GridInstance; -}) { + drawHeader, + headerIcons: hostHeaderIcons, + headerHeight = COLUMN_HEADER_HEIGHT, +}: DataGridProps) { // Before glide can paint, and therefore long before the first editor opens. // A missing portal is silent: the grid renders, and editing simply does // nothing. See `ensurePortal.ts`. @@ -107,6 +150,12 @@ export function DataGrid groupColumnColor(grouping, instance.descriptor.columns.defs), + [grouping, instance], + ); const { banner, @@ -134,6 +196,7 @@ export function DataGrid mergeHeaderIcons(hostHeaderIcons), [hostHeaderIcons]); + + const isSortable = useMemo(() => sortablePredicate(instance.descriptor.columns.defs), [instance]); + + const onDrawHeader = useMemo( + () => withSortArgs(drawHeader, sort, isSortable), + [drawHeader, sort, isSortable], + ); + const dismissError = useCallback( () => dispatch(instance.actions.editErrorCleared()), [dispatch, instance], @@ -207,13 +279,14 @@ export function DataGrid instance.descriptor.cells.validateCell(newValue)} + customRenderers={instance.cells.customRenderers} + headerIcons={headerIcons} + drawHeader={onDrawHeader} + validateCell={(_cell, newValue) => instance.cells.validateCell(newValue)} columns={columns} rows={model.rowCount} rowHeight={rowHeight} - headerHeight={COLUMN_HEADER_HEIGHT} + headerHeight={headerHeight} getCellContent={getCellContent} getCellsForSelection={true} onCellEdited={onCellEdited} diff --git a/src/features/dataGrid/columnDef.types.test.ts b/src/features/dataGrid/columnDef.types.test.ts new file mode 100644 index 0000000..8a55ef3 --- /dev/null +++ b/src/features/dataGrid/columnDef.types.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { ColumnDef } from "./types"; + +/** + * What `ColumnDef` accepts and refuses, checked by the COMPILER. + * + * Every `@ts-expect-error` below IS the assertion: `tsc` fails the build if the + * declaration under it stops being an error, so a branch that quietly goes + * slack shows up here and nowhere else. `npm run typecheck` runs these; the + * `it` block exists so the file reports alongside the rest of the suite. + * + * The directive sits above the whole declaration rather than above the offending + * key, because a union mismatch is reported where the object is assigned. + */ + +const base = { field: "x", title: "X", defaultWidth: 100, editable: true } as const; + +// ─── built-in types are accepted, with their own options ───────────────────── + +export const text: ColumnDef = { ...base, type: "dg:text", options: { maxLength: 5 } }; +export const number: ColumnDef = { ...base, type: "dg:number", options: { currency: "USD" } }; +export const date: ColumnDef = { ...base, type: "dg:date", options: { withTime: true } }; +export const phone: ColumnDef = { ...base, type: "dg:phone", options: { nullable: true } }; +export const enumCol: ColumnDef = { + ...base, + type: "dg:enum", + options: { choices: [{ value: 0, label: "Draft" }] }, +}; + +/** Options are optional on every built-in but `dg:enum`. */ +export const bare: ColumnDef = { ...base, type: "dg:text" }; + +/** Glide's own kinds need no options at all. */ +export const glide: ColumnDef = { ...base, type: "boolean" }; + +/** A host's own cell type is accepted once it says it is one. */ +export const customColumn: ColumnDef = { + ...base, + type: "invoice-status", + custom: true, + options: { anything: "the host's cell knows" }, +}; + +// ─── a mistyped key INSIDE options is an error ─────────────────────────────── + +// @ts-expect-error `currncy` is not a NumberCellOptions key +export const typoInOptions: ColumnDef = { ...base, type: "dg:number", options: { currncy: "USD" } }; + +// ─── a mistyped `type` is an error ─────────────────────────────────────────── + +// @ts-expect-error "dg:numbr" is not a cell type, and is not marked custom +export const typoInType: ColumnDef = { ...base, type: "dg:numbr" }; + +// ─── a required option cannot be left out ──────────────────────────────────── + +// @ts-expect-error `dg:enum` must say what it can hold +export const enumWithoutChoices: ColumnDef = { ...base, type: "dg:enum" }; + +// ─── a setting belonging to another cell type is an error ──────────────────── + +// @ts-expect-error `withTime` belongs to dg:date, not dg:text +export const wrongCellsOption: ColumnDef = { + ...base, + type: "dg:text", + options: { withTime: true }, +}; + +// ─── a key that belongs to no branch at all is an error ────────────────────── + +// @ts-expect-error `withTime` moved into options; it is not a column field +export const strayKey: ColumnDef = { ...base, type: "dg:date", withTime: true }; + +// ─── glide's kinds take no options ─────────────────────────────────────────── + +// @ts-expect-error glide draws these; there is nothing of ours to configure +export const glideWithOptions: ColumnDef = { ...base, type: "text", options: { maxLength: 5 } }; + +// ─── a host type must declare itself ───────────────────────────────────────── + +// @ts-expect-error without `custom: true` the type must be one this package knows +export const customWithoutMarker: ColumnDef = { ...base, type: "invoice-status" }; + +describe("ColumnDef", () => { + it("is checked by tsc, not by assertions here", () => { + // The file compiling at all IS the test. This keeps it in the suite report. + expect(text.type).toBe("dg:text"); + expect(customColumn.custom).toBe(true); + }); +}); diff --git a/src/features/dataGrid/data/sync/useRowSync.test.tsx b/src/features/dataGrid/data/sync/useRowSync.test.tsx index 84cd432..c32973b 100644 --- a/src/features/dataGrid/data/sync/useRowSync.test.tsx +++ b/src/features/dataGrid/data/sync/useRowSync.test.tsx @@ -44,7 +44,6 @@ function harness(store: RowStore = loadedStore()) { rowKey: (r: Row) => r.id, columns: { defs: {}, defaultOrder: [] }, api: server.api, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/src/features/dataGrid/gridComposition.test.tsx b/src/features/dataGrid/gridComposition.test.tsx index d698fa8..7ba40df 100644 --- a/src/features/dataGrid/gridComposition.test.tsx +++ b/src/features/dataGrid/gridComposition.test.tsx @@ -41,12 +41,6 @@ const GROUP_SIZE = 60; */ const VISIBLE_FIELDS = ["name", "g"]; -function textCell(_type: string, raw: unknown): GridCell { - const text = String(raw ?? ""); - - return { kind: GridCellKind.Text, data: text, displayData: text, allowOverlay: true }; -} - function cellText(cell: GridCell): string { if (cell.kind === GridCellKind.Text) { return cell.displayData; @@ -188,7 +182,6 @@ function makeHarness(total: number, { grouped = false }: HarnessOptions = {}) { ? { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String } : undefined, api: { ...server.api, updateRow, fetchRows }, - cells: { makeCell: textCell, customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ diff --git a/src/features/dataGrid/gridStatusWiring.test.tsx b/src/features/dataGrid/gridStatusWiring.test.tsx index 12892dc..0b6444a 100644 --- a/src/features/dataGrid/gridStatusWiring.test.tsx +++ b/src/features/dataGrid/gridStatusWiring.test.tsx @@ -58,7 +58,6 @@ function makeHarness(fetchRows: ReturnType) { updateRow: async () => ({ ok: false }), subscribe: () => () => {}, }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -211,11 +210,6 @@ function makeEditHarness() { fetchRow: async () => null, updateRow, }, - cells: { - makeCell: () => ({}) as never, - customRenderers: [], - validateCell: () => true, - }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/src/features/dataGrid/groupColumnColor.test.ts b/src/features/dataGrid/groupColumnColor.test.ts new file mode 100644 index 0000000..31f8546 --- /dev/null +++ b/src/features/dataGrid/groupColumnColor.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { radixColorByIndex } from "../../lib/grid/radixBadgePalette"; +import { groupColumnColor } from "./groupColumnColor"; +import type { ColumnDef, GridGrouping } from "./types"; + +interface Row { + region: number; +} + +const enumColumn: ColumnDef = { + field: "region", + title: "Region", + defaultWidth: 100, + editable: false, + type: "dg:enum", + options: { + choices: [ + { value: 0, label: "APAC", color: "cyan" }, + { value: 1, label: "EMEA" }, + ], + }, +}; + +const textColumn: ColumnDef = { + field: "sector", + title: "Sector", + defaultWidth: 100, + editable: false, + type: "dg:text", +}; + +const grouping = (over: Partial> = {}): GridGrouping => ({ + field: "region", + of: (r) => r.region, + order: (g) => g, + label: String, + ...over, +}); + +describe("groupColumnColor", () => { + it("colours a group from the enum column it groups by", () => { + const color = groupColumnColor(grouping(), { region: enumColumn }); + + expect(color?.(0)).toBe("cyan"); + }); + + it("falls back to the palette for a choice with no colour of its own", () => { + const color = groupColumnColor(grouping(), { region: enumColumn }); + + expect(color?.(1)).toBe(radixColorByIndex(1)); + }); + + it("lets the descriptor's own color win", () => { + const color = groupColumnColor(grouping({ color: () => "red" }), { region: enumColumn }); + + expect(color?.(0)).toBe("red"); + }); + + it("answers nothing when the grouped column is not an enum", () => { + expect(groupColumnColor(grouping({ field: "sector" }), { sector: textColumn })).toBeUndefined(); + }); + + it("answers nothing when the grouped field is not a column at all", () => { + expect(groupColumnColor(grouping({ field: "ghost" }), { region: enumColumn })).toBeUndefined(); + }); + + it("answers nothing for a flat grid", () => { + expect(groupColumnColor(undefined, { region: enumColumn })).toBeUndefined(); + }); + + it("answers nothing for a group value that cannot index the choices", () => { + const color = groupColumnColor(grouping() as unknown as GridGrouping, { + region: enumColumn, + }); + + expect(color?.("APAC")).toBeUndefined(); + }); +}); diff --git a/src/features/dataGrid/groupColumnColor.ts b/src/features/dataGrid/groupColumnColor.ts new file mode 100644 index 0000000..a9ad19f --- /dev/null +++ b/src/features/dataGrid/groupColumnColor.ts @@ -0,0 +1,47 @@ +import { ENUM_CELL_TYPE } from "../../lib/grid/enumCell"; +import { enumColorOf, type EnumCellOptions } from "../../lib/grid/enumChoices"; +import type { RadixColor } from "../../lib/grid/radixBadgePalette"; +import type { ColumnDef, GridGrouping } from "./types"; + +/** + * How a group's name is coloured, resolved from the descriptor alone. + * + * Grouping by an enum column and colouring its header the same as the badges + * under it is the ordinary case, and the engine already holds both halves: the + * grouped field, and that column's choices. Making the host wire the two + * together was busywork with one right answer, so the engine answers it. + * + * `grouping.color` still wins where it is set. Grouping by anything but a + * `dg:enum` column answers undefined, which falls the header back to the + * theme's header text colour — the right answer for a group with no colour of + * its own, not an error. + * + * A group value that is not a number cannot index an enum's choices, so it + * answers undefined too rather than guessing. + */ +export function groupColumnColor( + grouping: GridGrouping | undefined, + defs: Record, +): ((group: TGroup) => RadixColor | undefined) | undefined { + if (grouping === undefined) { + return undefined; + } + + if (grouping.color !== undefined) { + return grouping.color; + } + + const def = defs[grouping.field]; + + if (def === undefined || def.type !== ENUM_CELL_TYPE) { + return undefined; + } + + const choices = (def.options as EnumCellOptions | undefined)?.choices; + + if (choices === undefined) { + return undefined; + } + + return (group) => (typeof group === "number" ? enumColorOf(choices, group) : undefined); +} diff --git a/src/features/dataGrid/headerDoors.test.ts b/src/features/dataGrid/headerDoors.test.ts new file mode 100644 index 0000000..8462d47 --- /dev/null +++ b/src/features/dataGrid/headerDoors.test.ts @@ -0,0 +1,102 @@ +import type { DrawHeaderCallback } from "@glideapps/glide-data-grid"; +import { describe, expect, it, vi } from "vitest"; +import { mergeHeaderIcons, withSortArgs, type GridDrawHeaderArgs } from "./headerDoors"; +import { SORT_ASC_ICON, SORT_DESC_ICON } from "./headerIcons"; +import type { GridSort } from "./types"; + +const glideArgs = (id: string) => + ({ column: { id, title: id } }) as unknown as Parameters[0]; + +describe("mergeHeaderIcons", () => { + it("keeps the engine's sort chevrons when the host names none", () => { + const merged = mergeHeaderIcons(undefined); + + expect(Object.keys(merged)).toEqual([SORT_ASC_ICON, SORT_DESC_ICON]); + }); + + it("adds the host's icon without taking the chevrons with it", () => { + const mine = () => ""; + const merged = mergeHeaderIcons({ mine }); + + expect(merged.mine).toBe(mine); + expect(merged[SORT_ASC_ICON]).toBeDefined(); + expect(merged[SORT_DESC_ICON]).toBeDefined(); + }); + + it("lets the host replace one entry by name", () => { + const mine = () => ""; + const merged = mergeHeaderIcons({ [SORT_ASC_ICON]: mine }); + + expect(merged[SORT_ASC_ICON]).toBe(mine); + expect(merged[SORT_DESC_ICON]).not.toBe(mine); + }); +}); + +describe("withSortArgs", () => { + const sort: GridSort = { field: "price", dir: "asc" }; + const always = () => true; + + it("hands back nothing when the host draws no header, so glide keeps its own", () => { + expect(withSortArgs(undefined, sort, always)).toBeUndefined(); + }); + + it("puts the grid's sort on the args", () => { + const seen: GridDrawHeaderArgs[] = []; + const wrapped = withSortArgs((args) => void seen.push(args), sort, always)!; + + wrapped(glideArgs("price"), () => {}); + + expect(seen[0].sort).toEqual(sort); + }); + + it("reports the sort even on a column it is not about, so the host can compare", () => { + const seen: GridDrawHeaderArgs[] = []; + const wrapped = withSortArgs((args) => void seen.push(args), sort, always)!; + + wrapped(glideArgs("name"), () => {}); + + expect(seen[0].sort).toEqual(sort); + expect(seen[0].column.id).toBe("name"); + }); + + it("reports natural order as null", () => { + const seen: GridDrawHeaderArgs[] = []; + const wrapped = withSortArgs((args) => void seen.push(args), null, always)!; + + wrapped(glideArgs("price"), () => {}); + + expect(seen[0].sort).toBeNull(); + }); + + it("says per column whether a sort menu is offered", () => { + const seen: GridDrawHeaderArgs[] = []; + const wrapped = withSortArgs( + (args) => void seen.push(args), + null, + (f) => f === "price", + )!; + + wrapped(glideArgs("price"), () => {}); + wrapped(glideArgs("value"), () => {}); + + expect(seen.map((a) => a.sortable)).toEqual([true, false]); + }); + + it("keeps every arg glide passed, so a glide drawHeader works unchanged", () => { + const seen: GridDrawHeaderArgs[] = []; + const wrapped = withSortArgs((args) => void seen.push(args), null, always)!; + + wrapped(glideArgs("price"), () => {}); + + expect(seen[0].column.title).toBe("price"); + }); + + it("passes glide's own drawDefault straight through", () => { + const drawDefault = vi.fn(); + const wrapped = withSortArgs((_args, d) => d(), null, always)!; + + wrapped(glideArgs("price"), drawDefault); + + expect(drawDefault).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/dataGrid/headerDoors.ts b/src/features/dataGrid/headerDoors.ts new file mode 100644 index 0000000..40bc797 --- /dev/null +++ b/src/features/dataGrid/headerDoors.ts @@ -0,0 +1,64 @@ +import type { DrawHeaderCallback, SpriteMap } from "@glideapps/glide-data-grid"; +import { sortHeaderIcons } from "./headerIcons"; +import type { GridSort } from "./types"; + +/** + * Glide's own header draw args, plus the two things only this engine knows. + * + * The shape is glide's, deliberately: a `drawHeader` copied out of glide's own + * docs works here unchanged. The engine's extras ride ON the args object rather + * than arriving as a third parameter, which would have broken that on sight. + */ +export type GridDrawHeaderArgs = Parameters[0] & { + /** + * The grid's current ordering, or null for natural order. Grid-wide, not + * per-column: compare `sort.field` with `args.column.id` to find out whether + * it is THIS header's. + */ + sort: GridSort | null; + + /** Whether this column offers a sort menu at all. */ + sortable: boolean; +}; + +/** + * A replacement for the engine's column header. + * + * The override is TOTAL: nothing the engine draws survives it, sort chevron and + * menu arrow included. Call `drawDefault()` to paint the engine's header first + * and add to it, or ignore it and draw the whole thing. + */ +export type GridDrawHeader = (args: GridDrawHeaderArgs, drawDefault: () => void) => void; + +/** + * The engine's header sprites with the host's laid over them. + * + * MERGED, not replaced. `headerIcons` is a dictionary and the sort indicator + * refers to `sortAsc`/`sortDesc` by NAME, so a host adding one icon of their own + * must not take those with it. `drawHeader` is total for the opposite reason: + * there is only one header, and half of one is not a header. + */ +export function mergeHeaderIcons(host: SpriteMap | undefined): SpriteMap { + return { ...sortHeaderIcons, ...host }; +} + +/** + * Glide's callback, wrapped only to put the sort state the engine owns onto the + * args it hands over. + * + * Without it a total override could never redraw the chevron it just replaced: + * glide knows nothing about sorting, and the engine's indicator is drawn through + * the header it was handed. + */ +export function withSortArgs( + drawHeader: GridDrawHeader | undefined, + sort: GridSort | null, + isSortable: (field: string) => boolean, +): DrawHeaderCallback | undefined { + if (drawHeader === undefined) { + return undefined; + } + + return (args, drawDefault) => + drawHeader({ ...args, sort, sortable: isSortable(args.column.id ?? "") }, drawDefault); +} diff --git a/src/features/dataGrid/hooks/useCellRenderer.test.tsx b/src/features/dataGrid/hooks/useCellRenderer.test.tsx index c483aed..371f6a4 100644 --- a/src/features/dataGrid/hooks/useCellRenderer.test.tsx +++ b/src/features/dataGrid/hooks/useCellRenderer.test.tsx @@ -104,15 +104,18 @@ function makeHarness({ }, api: { updateRow }, ...(grouping ? { grouping } : {}), - cells: { - makeCell: (_type: string, raw: unknown): GridCell => ({ - kind: GridCellKind.Text, - data: String(raw), - displayData: String(raw), - allowOverlay: true, - ...(cellTheme ? { themeOverride: cellTheme } : {}), - }), - }, + }, + // On the INSTANCE, not the descriptor: the registry is resolved once by + // `createGridInstance` (built-ins plus the descriptor's own) and the + // renderer reads it from there. + cells: { + makeCell: (_type: string, raw: unknown): GridCell => ({ + kind: GridCellKind.Text, + data: String(raw), + displayData: String(raw), + allowOverlay: true, + ...(cellTheme ? { themeOverride: cellTheme } : {}), + }), }, actions: edits.actions, } as unknown as GridInstance; diff --git a/src/features/dataGrid/hooks/useCellRenderer.ts b/src/features/dataGrid/hooks/useCellRenderer.ts index 383c839..1296f4c 100644 --- a/src/features/dataGrid/hooks/useCellRenderer.ts +++ b/src/features/dataGrid/hooks/useCellRenderer.ts @@ -244,16 +244,27 @@ export function useCellRenderer)[field]; - const built = descriptor.cells.makeCell(def.type, raw, { + const built = instance.cells.makeCell(def.type, raw, { editable: def.editable, - withTime: def.withTime ?? false, + options: def.options, }); const content = withHighlight(`${cell.dataIndex}:${field}`, built); return isPending(descriptor.rowKey(row)) ? withPendingTint(content) : content; }, - [model, visibleFields, columnCount, theme, rowAt, isPending, withHighlight, descriptor, defs], + [ + model, + visibleFields, + columnCount, + theme, + rowAt, + isPending, + withHighlight, + instance, + descriptor, + defs, + ], ); const onCellEdited = useCallback( diff --git a/src/features/dataGrid/hooks/useColumnPersistence.test.tsx b/src/features/dataGrid/hooks/useColumnPersistence.test.tsx new file mode 100644 index 0000000..98e89d2 --- /dev/null +++ b/src/features/dataGrid/hooks/useColumnPersistence.test.tsx @@ -0,0 +1,184 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Provider } from "react-redux"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createGridInstance } from "../store/createGridInstance"; +import type { ColumnsAdapter, ColumnsState, GridDescriptor } from "../types"; +import { useColumnPersistence } from "./useColumnPersistence"; + +interface Row { + id: number; +} + +const SAVE_DEBOUNCE_MS = 500; + +function descriptorFor( + name: string, + columns: Partial["columns"]> = {}, +): GridDescriptor { + return { + name, + rowKey: (r) => r.id, + columns: { + defs: { + id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "dg:number" }, + name: { field: "name", title: "Name", defaultWidth: 120, editable: true, type: "dg:text" }, + }, + defaultOrder: ["id", "name"], + ...columns, + }, + api: { + fetchRows: async () => [], + fetchCount: async () => 0, + fetchRow: async () => null, + updateRow: async () => ({ ok: true }), + }, + }; +} + +function mount(descriptor: GridDescriptor) { + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { [descriptor.name]: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const view = renderHook(() => useColumnPersistence(instance, instance.columnsAdapter), { + wrapper, + }); + + const columns = () => + (store.getState() as Record)[descriptor.name].columns; + + return { instance, store, view, columns }; +} + +function fakeAdapter(stored: ColumnsState | null = null) { + const saveColumns = vi.fn(async () => {}); + const loadColumns = vi.fn(async () => stored); + + return { + adapter: { loadColumns, saveColumns } satisfies ColumnsAdapter, + loadColumns, + saveColumns, + }; +} + +describe("useColumnPersistence", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("restores a saved layout on mount", async () => { + const { adapter } = fakeAdapter({ order: ["name", "id"], widths: { id: 200 }, hidden: ["id"] }); + const { columns } = mount(descriptorFor("restores", { adapter })); + + await waitFor(() => expect(columns().order).toEqual(["name", "id"])); + expect(columns().widths).toEqual({ id: 200 }); + expect(columns().hidden).toEqual(["id"]); + }); + + it("writes the layout back when the user changes it", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const { adapter, saveColumns } = fakeAdapter(); + const { instance, store } = mount(descriptorFor("writesBack", { adapter })); + + await vi.waitFor(() => expect(adapter.loadColumns).toHaveBeenCalled()); + + store.dispatch(instance.actions.resizeColumn({ field: "id", width: 321 })); + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + + expect(saveColumns).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 321 } })); + }); + + it("does not bounce the layout it just loaded straight back out", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const stored = { order: ["name", "id"], widths: {}, hidden: [] }; + const { adapter, saveColumns } = fakeAdapter(stored); + + mount(descriptorFor("noEcho", { adapter })); + + await vi.waitFor(() => expect(adapter.loadColumns).toHaveBeenCalled()); + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + + expect(saveColumns).not.toHaveBeenCalled(); + }); + + it("waits for the load before writing, so a save cannot overtake it", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + let release: (v: ColumnsState | null) => void = () => {}; + const saveColumns = vi.fn(async () => {}); + const adapter: ColumnsAdapter = { + loadColumns: () => new Promise((r) => (release = r)), + saveColumns, + }; + + const { instance, store } = mount(descriptorFor("ordered", { adapter })); + + store.dispatch(instance.actions.resizeColumn({ field: "id", width: 50 })); + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + + expect(saveColumns).not.toHaveBeenCalled(); + + release(null); + }); + + it("persists to Web Storage with no wiring at all", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const { instance, store } = mount(descriptorFor("byDefault")); + + store.dispatch(instance.actions.resizeColumn({ field: "id", width: 210 })); + + await vi.waitFor(async () => { + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + expect(localStorage.getItem("datagrid:byDefault:columns")).not.toBeNull(); + }); + + const raw = localStorage.getItem("datagrid:byDefault:columns"); + expect(JSON.parse(raw!).widths).toEqual({ id: 210 }); + }); + + it("writes nothing when the descriptor opts out", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const { instance, store } = mount(descriptorFor("optedOut", { persist: false })); + + store.dispatch(instance.actions.resizeColumn({ field: "id", width: 210 })); + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + + expect(localStorage.getItem("datagrid:optedOut:columns")).toBeNull(); + }); + + it("debounces a drag into one write, not one per pixel", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const { adapter, saveColumns } = fakeAdapter(); + const { instance, store } = mount(descriptorFor("debounced", { adapter })); + + await vi.waitFor(() => expect(adapter.loadColumns).toHaveBeenCalled()); + + for (const width of [100, 120, 140, 160]) { + store.dispatch(instance.actions.resizeColumn({ field: "id", width })); + await vi.advanceTimersByTimeAsync(50); + } + + await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 50); + + expect(saveColumns).toHaveBeenCalledTimes(1); + expect(saveColumns).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 160 } })); + }); +}); diff --git a/src/features/dataGrid/hooks/useColumnPersistence.ts b/src/features/dataGrid/hooks/useColumnPersistence.ts new file mode 100644 index 0000000..638a004 --- /dev/null +++ b/src/features/dataGrid/hooks/useColumnPersistence.ts @@ -0,0 +1,107 @@ +// src/features/dataGrid/hooks/useColumnPersistence.ts +import { useEffect, useRef, useState } from "react"; +import { useSelector } from "react-redux"; +import type { ColumnsAdapter, ColumnsState, GridInstance } from "../types"; +import { useGridDispatch } from "../useGridDispatch"; + +/** + * How long the layout must sit still before it is written. + * + * A column drag fires a resize per frame. Without this, a custom adapter + * backed by a server call would send one request per pixel. + */ +const SAVE_DEBOUNCE_MS = 500; + +/** + * Restores the user's column layout on mount, then writes it back whenever they + * change it. + * + * This runs in the COMPONENT rather than on the host's listener middleware, + * which is what lets `createGridInstance` take a descriptor and nothing else. A + * grid instance is built at module scope and has no store to subscribe to; + * a mounted grid has one through React-Redux's context, and it is also the only + * thing that knows the grid is on screen at all. + * + * A null adapter means the grid persists nothing (`columns.persist: false`), and + * every branch below then no-ops rather than being skipped by the caller. + */ +export function useColumnPersistence( + instance: GridInstance, + adapter: ColumnsAdapter | null, +): void { + const dispatch = useGridDispatch(); + + const columns = useSelector((s: unknown) => instance.selectRoot(s).columns); + + /** + * The layout as it currently stands in storage, as far as this mount knows. + * + * Seeded from the load so the hydrate itself does not bounce straight back + * out as a save — which for a server-backed adapter would be a write on every + * single mount, of the bytes it had just read. + */ + const lastWritten = useRef(null); + + /** + * Whether the load has answered. Nothing may be written before it does: a + * save that overtook the load would persist the DEFAULT layout over the + * user's saved one, and the load would then restore what the save had just + * destroyed. + * + * STATE, not a ref. A user who drags a column before the load lands changes + * the layout once and never again, so a ref would skip that save and no later + * render would come back for it. As state it re-runs the save effect the + * moment hydration finishes, and the waiting change is written then. + */ + const [hydrated, setHydrated] = useState(false); + + const adapterRef = useRef(adapter); + adapterRef.current = adapter; + + useEffect(() => { + const current = adapterRef.current; + + if (current === null) { + setHydrated(true); + return; + } + + let cancelled = false; + + void (async () => { + const loaded = await current.loadColumns(); + + if (cancelled) return; + + if (loaded) { + lastWritten.current = JSON.stringify(loaded); + dispatch(instance.actions.setColumns(loaded as unknown as ColumnsState)); + } + + setHydrated(true); + })(); + + return () => { + cancelled = true; + }; + }, [dispatch, instance]); + + useEffect(() => { + if (!hydrated) return; + + const current = adapterRef.current; + + if (current === null) return; + + const next = JSON.stringify(columns); + + if (next === lastWritten.current) return; + + const timer = setTimeout(() => { + lastWritten.current = next; + void current.saveColumns(columns); + }, SAVE_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [columns, hydrated]); +} diff --git a/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx b/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx index 36ae3d7..2acab5b 100644 --- a/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx +++ b/src/features/dataGrid/hooks/useColumnSortMenu.test.tsx @@ -37,7 +37,6 @@ function harness() { defaultOrder: FIELDS, }, api: { updateRow: async () => ({ ok: true }) }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const instance = createGridInstance(descriptor); @@ -77,8 +76,7 @@ function harness() { const openArrow = (field: string) => userEvent.click(screen.getByRole("button", { name: `arrow ${field}` })); -const pick = async (name: RegExp) => - userEvent.click(await screen.findByRole("menuitem", { name })); +const pick = async (name: RegExp) => userEvent.click(await screen.findByRole("menuitem", { name })); describe("useColumnSortMenu", () => { it("opens the menu on the column whose arrow was pressed", async () => { diff --git a/src/features/dataGrid/hooks/useColumnSortMenu.tsx b/src/features/dataGrid/hooks/useColumnSortMenu.tsx index 3c81fd2..0a6a9d0 100644 --- a/src/features/dataGrid/hooks/useColumnSortMenu.tsx +++ b/src/features/dataGrid/hooks/useColumnSortMenu.tsx @@ -34,10 +34,7 @@ export function useColumnSortMenu< const [target, setTarget] = useState(null); - const sortable = useMemo( - () => sortablePredicate(defs), - [defs], - ); + const sortable = useMemo(() => sortablePredicate(defs), [defs]); const onHeaderMenuClick = useCallback( (colIndex: number, bounds: Rectangle) => { @@ -69,9 +66,7 @@ export function useColumnSortMenu< [dispatch, instance, sort], ); - const menu = ( - - ); + const menu = ; return { onHeaderMenuClick, menu }; } diff --git a/src/features/dataGrid/hooks/useDisplayModel.test.tsx b/src/features/dataGrid/hooks/useDisplayModel.test.tsx index a7e806d..b9ac717 100644 --- a/src/features/dataGrid/hooks/useDisplayModel.test.tsx +++ b/src/features/dataGrid/hooks/useDisplayModel.test.tsx @@ -50,7 +50,6 @@ function makeHarness() { fetchRow: async () => null, updateRow: async () => ({ ok: true }), }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const instance = createGridInstance(descriptor); diff --git a/src/features/dataGrid/hooks/useGridColumns.test.tsx b/src/features/dataGrid/hooks/useGridColumns.test.tsx index 04883a5..06a1bf1 100644 --- a/src/features/dataGrid/hooks/useGridColumns.test.tsx +++ b/src/features/dataGrid/hooks/useGridColumns.test.tsx @@ -38,7 +38,6 @@ function makeHarness(overrides: { unsortable?: string } = {}) { api: { updateRow: async () => ({ ok: true }), }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/src/features/dataGrid/hooks/useGridColumns.ts b/src/features/dataGrid/hooks/useGridColumns.ts index b061550..daa0c3d 100644 --- a/src/features/dataGrid/hooks/useGridColumns.ts +++ b/src/features/dataGrid/hooks/useGridColumns.ts @@ -1,6 +1,6 @@ // src/features/dataGrid/hooks/useGridColumns.ts import { type GridColumn, GridColumnMenuIcon } from "@glideapps/glide-data-grid"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useSelector } from "react-redux"; import { SORT_ASC_ICON, SORT_DESC_ICON } from "../headerIcons"; import { sortablePredicate } from "../sortable"; @@ -23,16 +23,9 @@ export function useGridColumns instance.selectRoot(s).groups.sort); - useEffect(() => { - dispatch(instance.thunks.loadColumns()); - }, [dispatch, instance]); - const visibleFields = useMemo(() => order.filter((f) => !hidden.includes(f)), [order, hidden]); - const sortable = useMemo( - () => sortablePredicate(defs), - [defs], - ); + const sortable = useMemo(() => sortablePredicate(defs), [defs]); const columns: GridColumn[] = useMemo( () => diff --git a/src/features/dataGrid/hooks/useGridData.test.tsx b/src/features/dataGrid/hooks/useGridData.test.tsx index 7d14828..40a325c 100644 --- a/src/features/dataGrid/hooks/useGridData.test.tsx +++ b/src/features/dataGrid/hooks/useGridData.test.tsx @@ -89,7 +89,6 @@ function makeHarness(total: number, grouped = false) { ? { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String } : undefined, api: { ...server.api, fetchRows, fetchCount }, - cells: { makeCell: () => ({}), customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ diff --git a/src/features/dataGrid/hooks/useGroupBanner.ts b/src/features/dataGrid/hooks/useGroupBanner.ts index e32e88d..dad1016 100644 --- a/src/features/dataGrid/hooks/useGroupBanner.ts +++ b/src/features/dataGrid/hooks/useGroupBanner.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react"; import { groupBanner, type GroupBanner } from "../groupBanner"; import { groupHeaderTextColor } from "../groupHeaderColor"; +import type { RadixColor } from "../../../lib/grid/radixBadgePalette"; import type { GridGrouping } from "../types"; interface Args { @@ -16,6 +17,13 @@ interface Args { /** Colour for a group that names no scale of its own — the theme's header text. */ fallbackColor: string | undefined; + /** + * The Radix scale a group's name is written in, already resolved from the + * descriptor — `grouping.color` where the host set one, otherwise the grouped + * enum column's own choices. See `groupColumnColor`. + */ + colorOf: ((group: TGroup) => RadixColor | undefined) | undefined; + /** From `useDisplayModel`. THE ONLY dispatcher of a collapse. */ toggleGroup: (group: TGroup) => void; @@ -49,12 +57,13 @@ export function useGroupBanner({ group, collapsedGroups, fallbackColor, + colorOf, toggleGroup, revealAfterCollapse, }: Args): Result { const groupColor = useCallback( - (of: TGroup) => groupHeaderTextColor(grouping?.color?.(of), fallbackColor), - [grouping, fallbackColor], + (of: TGroup) => groupHeaderTextColor(colorOf?.(of), fallbackColor), + [colorOf, fallbackColor], ); const collapseFromBanner = useCallback( diff --git a/src/features/dataGrid/hooks/useGroupHeaders.test.tsx b/src/features/dataGrid/hooks/useGroupHeaders.test.tsx index 44f83fb..09d5d4c 100644 --- a/src/features/dataGrid/hooks/useGroupHeaders.test.tsx +++ b/src/features/dataGrid/hooks/useGroupHeaders.test.tsx @@ -86,7 +86,13 @@ function mount(initialProps: Props, grouping: GridGrouping | undefi const view = renderHook( ({ m, collapsed }: Props) => - useGroupHeaders({ model: m, collapsedGroups: collapsed, grouping, gridRef }), + useGroupHeaders({ + model: m, + collapsedGroups: collapsed, + grouping, + gridRef, + columnHeaderHeight: 36, + }), { initialProps }, ); @@ -112,6 +118,7 @@ function mountFlat(model: DisplayModel) { collapsedGroups: [], grouping: undefined, gridRef, + columnHeaderHeight: 36, }), ); diff --git a/src/features/dataGrid/hooks/useGroupHeaders.ts b/src/features/dataGrid/hooks/useGroupHeaders.ts index 8ee8139..484f320 100644 --- a/src/features/dataGrid/hooks/useGroupHeaders.ts +++ b/src/features/dataGrid/hooks/useGroupHeaders.ts @@ -10,12 +10,7 @@ import { } from "../displayModel"; import { headerPlacements } from "../headerPlacements"; import type { GroupHeaderLayerHandle, HeaderPlacement } from "../GroupHeaderLayer"; -import { - COLUMN_HEADER_HEIGHT, - DEFAULT_GROUP_HEADER_HEIGHT, - DEFAULT_ROW_HEIGHT, - rowHeightFor, -} from "../rowHeights"; +import { DEFAULT_GROUP_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT, rowHeightFor } from "../rowHeights"; import type { GridGrouping } from "../types"; interface Args { @@ -37,6 +32,16 @@ interface Args { /** The grid itself, for putting a folded group's header back under the eye. */ gridRef: React.RefObject; + + /** + * The height of the grid's own column-header row. + * + * Every hole is measured DOWN from this line, so it has to be the same number + * `` was given. A host raising the header through + * `` therefore has to move this too, which is why it + * arrives as an argument rather than being read from the constant. + */ + columnHeaderHeight: number; } interface Result { @@ -92,6 +97,7 @@ export function useGroupHeaders({ collapsedGroups, grouping, gridRef, + columnHeaderHeight, }: Args): Result { const layerRef = useRef(null); @@ -170,13 +176,13 @@ export function useGroupHeaders({ placements.current = headerPlacements(next, { firstRow: rect.y, translateY, - headerHeight: COLUMN_HEADER_HEIGHT, + headerHeight: columnHeaderHeight, heightOf: rowHeight, }); layerRef.current?.place(placements.current); }, - [rowHeight], + [rowHeight, columnHeaderHeight], ); const trackRegion = useCallback( diff --git a/src/features/dataGrid/hooks/useRowPages.test.tsx b/src/features/dataGrid/hooks/useRowPages.test.tsx index 9e17db1..c275eb3 100644 --- a/src/features/dataGrid/hooks/useRowPages.test.tsx +++ b/src/features/dataGrid/hooks/useRowPages.test.tsx @@ -44,7 +44,6 @@ function makeHarness(total: number) { pageSize: PAGE, columns: { defs: {}, defaultOrder: [] }, api: { ...server.api, fetchRows }, - cells: { makeCell: () => ({}), customRenderers: [], validateCell: () => true }, } as unknown as GridDescriptor; const queryClient = new QueryClient({ diff --git a/src/features/dataGrid/pickerColumns.test.ts b/src/features/dataGrid/pickerColumns.test.ts index 8627b90..0987062 100644 --- a/src/features/dataGrid/pickerColumns.test.ts +++ b/src/features/dataGrid/pickerColumns.test.ts @@ -3,9 +3,15 @@ import { pickerColumns } from "./pickerColumns"; import type { ColumnDef } from "./types"; const DEFS: Record = { - id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, - name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" }, - region: { field: "region", title: "Region", defaultWidth: 120, editable: false, type: "region" }, + id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "dg:number" }, + name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "dg:text" }, + region: { + field: "region", + title: "Region", + defaultWidth: 120, + editable: false, + type: "dg:number", + }, }; const ORDER = ["id", "name", "region"]; diff --git a/src/features/dataGrid/sortable.test.ts b/src/features/dataGrid/sortable.test.ts index 26d3ccb..0ffc293 100644 --- a/src/features/dataGrid/sortable.test.ts +++ b/src/features/dataGrid/sortable.test.ts @@ -3,14 +3,14 @@ import { sortablePredicate } from "./sortable"; import type { ColumnDef } from "./types"; const DEFS: Record = { - id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, - name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "text" }, + id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "dg:number" }, + name: { field: "name", title: "Name", defaultWidth: 200, editable: true, type: "dg:text" }, value: { field: "value", title: "Value", defaultWidth: 140, editable: false, - type: "currency", + type: "dg:number", sortable: false, }, }; diff --git a/src/features/dataGrid/sortable.ts b/src/features/dataGrid/sortable.ts index 99e698c..8a2a4b2 100644 --- a/src/features/dataGrid/sortable.ts +++ b/src/features/dataGrid/sortable.ts @@ -13,8 +13,6 @@ import type { ColumnDef } from "./types"; * column that the server genuinely cannot order by says so itself, on its own * `ColumnDef`. */ -export function sortablePredicate( - defs: Record, -): (field: string) => boolean { +export function sortablePredicate(defs: Record): (field: string) => boolean { return (field: string) => defs[field] !== undefined && defs[field].sortable !== false; } diff --git a/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts b/src/features/dataGrid/store/columnsAdapter.test.ts similarity index 95% rename from src/features/dataGrid/store/localStorageColumnsAdapter.test.ts rename to src/features/dataGrid/store/columnsAdapter.test.ts index 0081d7a..64ba437 100644 --- a/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts +++ b/src/features/dataGrid/store/columnsAdapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ColumnsState } from "../types"; -import { localStorageColumnsAdapter } from "./localStorageColumnsAdapter"; +import { localStorageColumnsAdapter } from "./columnsAdapter"; // Fake storage backed by a Map function createFakeStorage(): Pick { diff --git a/src/features/dataGrid/store/columnsAdapter.ts b/src/features/dataGrid/store/columnsAdapter.ts new file mode 100644 index 0000000..7ed53c2 --- /dev/null +++ b/src/features/dataGrid/store/columnsAdapter.ts @@ -0,0 +1,78 @@ +import type { ColumnsAdapter, ColumnsState } from "../types"; + +/** + * Where a grid's layout is kept when the descriptor names nowhere else. + * + * `descriptor.name` is already documented as stable and unique — it is the + * grid's Redux mount point — so it is the one string that can key the storage + * without the host having to invent a second one. + */ +export function defaultColumnsStorageKey(name: string): string { + return `datagrid:${name}:columns`; +} + +/** + * Builds a `ColumnsAdapter` backed by Web Storage. This is the home of + * localStorage knowledge — the slice, the persistence hook, and the engine + * never touch it directly. + * + * It is the engine's default rather than something a host wires up: a column + * layout the user rearranged and then lost on reload is a bug in every app, so + * remembering it is the behaviour a grid should have before anyone asks. + * `descriptor.columns.adapter` replaces it; `persist: false` turns it off. + * + * Both calls are async to match `ColumnsAdapter`, though Web Storage is not: + * one shape means the save path has one thing to await. + */ +export function localStorageColumnsAdapter( + storageKey: string, + storage: Pick | undefined = globalThis.localStorage, +): ColumnsAdapter { + return { + async loadColumns() { + try { + const raw = storage?.getItem(storageKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + // Only hydrate fields that are present & well-formed; missing → caller keeps defaults. + if (!parsed || typeof parsed !== "object") return null; + return { + order: Array.isArray(parsed.order) && parsed.order.length ? parsed.order : [], + widths: parsed.widths ?? {}, + hidden: parsed.hidden ?? [], + }; + } catch { + return null; + } + }, + async saveColumns(state) { + try { + storage?.setItem(storageKey, JSON.stringify(state)); + } catch { + /* ignore quota/unavailable */ + } + }, + }; +} + +/** + * The adapter one grid actually runs on, or null when it persists nothing. + * + * Reading the descriptor in ONE place is what keeps "on by default" honest: + * every caller — the load on mount and the save after a change — asks here, so + * neither can end up persisting to somewhere the other does not. + * + * A `storage` of undefined (server-side rendering, a browser with site data + * blocked) yields an adapter that quietly loads nothing and saves nothing, + * rather than throwing on a grid that only wanted to draw. + */ +export function resolveColumnsAdapter( + name: string, + columns: { persist?: boolean; adapter?: ColumnsAdapter }, +): ColumnsAdapter | null { + if (columns.persist === false) { + return null; + } + + return columns.adapter ?? localStorageColumnsAdapter(defaultColumnsStorageKey(name)); +} diff --git a/src/features/dataGrid/store/columnsSlice.test.ts b/src/features/dataGrid/store/columnsSlice.test.ts index 1bfb6cb..3818d98 100644 --- a/src/features/dataGrid/store/columnsSlice.test.ts +++ b/src/features/dataGrid/store/columnsSlice.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { createColumnsSlice } from "./columnsSlice"; -import type { ColumnsState } from "../types"; const make = () => createColumnsSlice("test", { defaultOrder: ["a", "b", "c"] }); @@ -52,41 +51,6 @@ describe("createColumnsSlice", () => { }); }); - describe("loadColumns thunk", () => { - const drain = async ( - thunk: ReturnType["loadColumns"]>, - ) => { - const dispatched: unknown[] = []; - await thunk((a: unknown) => dispatched.push(a)); - return dispatched; - }; - - it("dispatches setColumns with the loaded layout", async () => { - const loaded: ColumnsState = { order: ["c", "a", "b"], widths: { a: 12 }, hidden: ["b"] }; - const { loadColumns } = createColumnsSlice("test", { - defaultOrder: ["a", "b", "c"], - load: async () => loaded, - }); - const dispatched = await drain(loadColumns()); - expect(dispatched).toEqual([ - expect.objectContaining({ type: "test/columns/setColumns", payload: loaded }), - ]); - }); - - it("no-ops when no load hook is provided", async () => { - const { loadColumns } = make(); - expect(await drain(loadColumns())).toEqual([]); - }); - - it("no-ops when load resolves to null", async () => { - const { loadColumns } = createColumnsSlice("test", { - defaultOrder: ["a"], - load: async () => null, - }); - expect(await drain(loadColumns())).toEqual([]); - }); - }); - it("resetColumns puts every column back the way the descriptor described it", () => { const { reducer, actions } = make(); let s = reducer(undefined, { type: "@@init" }); diff --git a/src/features/dataGrid/store/columnsSlice.ts b/src/features/dataGrid/store/columnsSlice.ts index 7e2f218..6369a76 100644 --- a/src/features/dataGrid/store/columnsSlice.ts +++ b/src/features/dataGrid/store/columnsSlice.ts @@ -1,11 +1,8 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; import type { ColumnsState } from "../types"; -export function createColumnsSlice( - name: string, - opts: { defaultOrder: string[]; load?: () => Promise }, -) { - const { defaultOrder, load } = opts; +export function createColumnsSlice(name: string, opts: { defaultOrder: string[] }) { + const { defaultOrder } = opts; const initialState: ColumnsState = { order: [...defaultOrder], widths: {}, hidden: [] }; const slice = createSlice({ @@ -51,16 +48,5 @@ export function createColumnsSlice( }, }); - // loadColumns thunk — hydrate from descriptor.api.loadColumns (if any) on mount. - // Single-slice: only dispatches this slice's setColumns, so it lives with the slice. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const loadColumns = () => async (dispatch: any) => { - if (!load) return; - - const loaded = await load(); - - if (loaded) dispatch(slice.actions.setColumns(loaded)); - }; - - return { reducer: slice.reducer, actions: slice.actions, loadColumns }; + return { reducer: slice.reducer, actions: slice.actions }; } diff --git a/src/features/dataGrid/store/createGridInstance.test.ts b/src/features/dataGrid/store/createGridInstance.test.ts index 9c59421..ed76e71 100644 --- a/src/features/dataGrid/store/createGridInstance.test.ts +++ b/src/features/dataGrid/store/createGridInstance.test.ts @@ -1,54 +1,91 @@ -// src/features/dataGrid/store/createGridInstance.test.ts import { describe, expect, it } from "vitest"; import { createGridInstance } from "./createGridInstance"; -import type { GridDescriptor } from "../types"; +import { defaultColumnsStorageKey } from "./columnsAdapter"; +import type { ColumnsAdapter, GridDescriptor } from "../types"; interface Row { id: number; } -const descriptor = { - name: "demo", - rowKey: (r: Row) => r.id, - columns: { - defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } }, - defaultOrder: ["id"], - }, - api: { - fetchRows: async () => [], - fetchCount: async () => 0, - fetchRow: async () => null, - updateRow: async () => ({ ok: true }), - }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, -} as unknown as GridDescriptor; - -const makeInstance = () => createGridInstance(descriptor); + +function makeDescriptor( + name: string, + columns: Partial["columns"]> = {}, +): GridDescriptor { + return { + name, + rowKey: (r) => r.id, + columns: { + defs: { + id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "dg:number" }, + }, + defaultOrder: ["id"], + ...columns, + }, + api: { + fetchRows: async () => [], + fetchCount: async () => 0, + fetchRow: async () => null, + updateRow: async () => ({ ok: true }), + }, + }; +} describe("createGridInstance", () => { - it("reduces only client-owned state — rows live in the row store", () => { - const inst = makeInstance(); + it("takes the descriptor and nothing else", () => { + const inst = createGridInstance(makeDescriptor("plain")); + + expect(typeof inst.reducer).toBe("function"); + expect(typeof inst.selectRoot).toBe("function"); + }); + + it("mounts its state under the descriptor name", () => { + const inst = createGridInstance(makeDescriptor("mounted")); const state = inst.reducer(undefined, { type: "@@init" }); - expect(Object.keys(state).sort()).toEqual(["columns", "edits", "groups", "selection"]); + + expect(inst.selectRoot({ mounted: state })).toBe(state); + expect(state.columns.order).toEqual(["id"]); }); - it("selectRoot reads the namespaced slice from a full store state", () => { - const inst = makeInstance(); - const sub = inst.reducer(undefined, { type: "@@init" }); - expect(inst.selectRoot({ demo: sub }).columns.order).toEqual(["id"]); + it("persists columns by default, with no wiring at all", () => { + const inst = createGridInstance(makeDescriptor("byDefault")); + + expect(inst.columnsAdapter).not.toBeNull(); }); - it("exposes an effects teardown and a loadColumns thunk", () => { - const inst = makeInstance(); - expect(typeof inst.stopEffects).toBe("function"); - expect(typeof inst.thunks.loadColumns).toBe("function"); - inst.stopEffects(); + it("persists nothing when the descriptor says so", () => { + const inst = createGridInstance(makeDescriptor("optedOut", { persist: false })); + + expect(inst.columnsAdapter).toBeNull(); }); - it("starts with an empty store cell, so a push before any grid mounts is dropped", () => { - // The instance is created at module scope, long before a grid mounts, and - // `useGridData` is what fills this cell. `data/sync/useRowSync.ts` relies on - // the null: a push that arrives first has nothing on screen to correct. - const inst = makeInstance(); - expect(inst.storeRef.current).toBeNull(); + it("uses the host's adapter over its own", () => { + const adapter: ColumnsAdapter = { + loadColumns: async () => null, + saveColumns: async () => {}, + }; + const inst = createGridInstance(makeDescriptor("hostOwned", { adapter })); + + expect(inst.columnsAdapter).toBe(adapter); + }); + + it("ignores an adapter on a grid that persists nothing", () => { + const adapter: ColumnsAdapter = { + loadColumns: async () => null, + saveColumns: async () => {}, + }; + const inst = createGridInstance(makeDescriptor("off", { persist: false, adapter })); + + expect(inst.columnsAdapter).toBeNull(); + }); + + it("keys its default storage off the grid's name", () => { + expect(defaultColumnsStorageKey("orders")).toBe("datagrid:orders:columns"); + }); + + it("knows every built-in cell without being handed one", () => { + const inst = createGridInstance(makeDescriptor("cells")); + + expect(inst.cells.makeCell("dg:number", 1, { editable: true, options: {} })).toBeDefined(); + expect(inst.cells.makeCell("text", "hi", { editable: true, options: undefined })).toBeDefined(); }); }); diff --git a/src/features/dataGrid/store/createGridInstance.ts b/src/features/dataGrid/store/createGridInstance.ts index 5cce4a5..32d18fb 100644 --- a/src/features/dataGrid/store/createGridInstance.ts +++ b/src/features/dataGrid/store/createGridInstance.ts @@ -1,10 +1,10 @@ // src/features/dataGrid/store/createGridInstance.ts import { combineReducers } from "@reduxjs/toolkit"; import type { RowStore } from "../data/rowStore"; -import type { GridDescriptor, GridInstance, GridInstanceOptions, GridSliceState } from "../types"; +import type { GridDescriptor, GridInstance, GridSliceState } from "../types"; +import { resolveCellRegistry } from "../../../lib/grid/cellRegistry"; +import { resolveColumnsAdapter } from "./columnsAdapter"; import { createColumnsSlice } from "./columnsSlice"; -import { registerEffects } from "./effects"; -import type { GridEffectContext } from "./effects/types"; import { createEditsSlice } from "./editsSlice"; import { createGroupsSlice } from "./groupsSlice"; import { createSelectionSlice } from "./selectionSlice"; @@ -24,29 +24,26 @@ import { createSelectionSlice } from "./selectionSlice"; * long-lived subscriber (data/sync/useRowSync.ts) reach the current store * without re-subscribing. * - * `options.startListening` is the host app's listener middleware registration. - * The engine owns no middleware of its own, so a grid that persists its column - * layout has to be handed one. + * The descriptor is the ONLY argument. Column persistence used to need the host + * app's listener middleware passed in beside it; it now runs from the mounted + * grid instead (`hooks/useColumnPersistence.ts`), which is the only place that + * both reaches a store and knows the grid is on screen. */ export function createGridInstance< TRow extends object, TGroup, TKey extends string | number = number, ->( - descriptor: GridDescriptor, - options: GridInstanceOptions = {}, -): GridInstance { +>(descriptor: GridDescriptor): GridInstance { const name = descriptor.name; - // Fail at wiring time rather than at the first column resize. A missing - // listener means saveColumns is never called, and a layout that silently - // stops persisting is close to impossible to trace back to this line. - if (descriptor.api.saveColumns && !options.startListening) { - throw new Error( - `createGridInstance("${name}"): descriptor.api.saveColumns is set, so the grid needs the ` + - `host app's listener middleware. Pass { startListening: listenerMiddleware.startListening }.`, - ); - } + // Resolved once, here, so the load on mount and every save afterwards cannot + // disagree about where this grid's layout lives. + const columnsAdapter = resolveColumnsAdapter(name, descriptor.columns); + + // The built-in cell types with the descriptor's own laid over them, built + // once. A registry rebuilt per render would hand glide a new renderer array + // every time, and glide re-creates every editor when that array changes. + const cells = resolveCellRegistry(descriptor.cells); // Published by `useGridData` when a grid mounts. It stays null until then, // and a push that arrives first is dropped rather than written to a store no @@ -65,10 +62,7 @@ export function createGridInstance< const groups = createGroupsSlice(name); - const columns = createColumnsSlice(name, { - defaultOrder: descriptor.columns.defaultOrder, - load: descriptor.api.loadColumns, - }); + const columns = createColumnsSlice(name, { defaultOrder: descriptor.columns.defaultOrder }); const selection = createSelectionSlice(name); @@ -83,26 +77,6 @@ export function createGridInstance< const selectRoot = (s: unknown) => (s as Record>)[name]; - // Register every GridEffect for this instance on the shared grid listener. - const ctx: GridEffectContext = { - name, - descriptor, - selectRoot, - actions: { - columns: columns.actions, - groups: groups.actions, - selection: selection.actions, - edits: edits.actions, - }, - }; - - // No listener and no saveColumns (the guard above rules out the other pair): - // there is nothing for an effect to do, so register nothing and hand back a - // stopEffects that is honestly a no-op. - const stopEffects = options.startListening - ? registerEffects(ctx, options.startListening) - : () => {}; - // A push that moves positions cannot patch a page-indexed cache in place, so // it invalidates every loaded page instead. Bumping a number rather than // clearing the store is what lets `useGridData` load the replacement behind @@ -149,10 +123,7 @@ export function createGridInstance< subscribeDataGeneration, getDataGeneration, bumpDataGeneration, - stopEffects, - thunks: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - loadColumns: columns.loadColumns as any, - }, + columnsAdapter, + cells, }; } diff --git a/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts b/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts deleted file mode 100644 index b2bdf0d..0000000 --- a/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { GridEffect } from "./types"; - -const SAVE_DEBOUNCE_MS = 500; - -/** Persist column layout via descriptor.api.saveColumns, debounced. No-ops when - * the grid declares no saveColumns. */ -export const columnsPersistenceEffect: GridEffect = (ctx, startListening) => { - const save = ctx.descriptor.api.saveColumns; - - if (!save) return; - - const { moveColumn, resetColumns, resizeColumn, toggleColumn } = ctx.actions.columns; - - // `resetColumns` is watched for the same reason as the other three: it - // CHANGES the layout. Leaving it out would let a reset look like it worked - // and then hand the thrown-away layout straight back on the next load. - const watched = [moveColumn.type, resetColumns.type, resizeColumn.type, toggleColumn.type]; - - startListening({ - predicate: (action: { type: string }) => watched.includes(action.type), - effect: async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _action: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - api: any, - ) => { - api.cancelActiveListeners(); // debounce: cancel any queued save - await api.delay(SAVE_DEBOUNCE_MS); // resolves only if not superseded - await save(ctx.selectRoot(api.getState()).columns); - }, - }); -}; diff --git a/src/features/dataGrid/store/effects/index.test.ts b/src/features/dataGrid/store/effects/index.test.ts deleted file mode 100644 index 342a5f4..0000000 --- a/src/features/dataGrid/store/effects/index.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { configureStore, createListenerMiddleware, type Reducer } from "@reduxjs/toolkit"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createGridInstance } from "../createGridInstance"; -import type { GridDescriptor, GridSliceState } from "../../types"; - -// Stands in for the host app's listener middleware. The engine ships none of -// its own, so every test that exercises an effect has to supply one — exactly -// as a consuming app does. -const appListener = createListenerMiddleware(); - -interface Row { - id: number; -} - -function makeDescriptor(name: string, saveColumns: (columns: unknown) => Promise) { - return { - name, - rowKey: (r: Row) => r.id, - columns: { - defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } }, - defaultOrder: ["id"], - }, - api: { - updateRow: async () => ({ ok: true }), - saveColumns, - }, - cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, - } as unknown as GridDescriptor; -} - -function makeStore(name: string, reducer: Reducer>) { - return configureStore({ - reducer: { [name]: reducer }, - middleware: (getDefault) => - getDefault({ serializableCheck: false }).concat(appListener.middleware), - }); -} - -describe("grid effects on the host app's listener", () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); - - it("runs a grid's effects through the app listener, with no per-grid middleware", async () => { - const save = vi.fn().mockResolvedValue(undefined); - const inst = createGridInstance(makeDescriptor("sharedDemo", save), { - startListening: appListener.startListening, - }); - const store = makeStore("sharedDemo", inst.reducer); - - store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 })); - await vi.advanceTimersByTimeAsync(600); - - expect(save).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 200 } })); - - inst.stopEffects(); - }); - - it("stopEffects removes the grid's listeners from the app listener", async () => { - const save = vi.fn().mockResolvedValue(undefined); - const inst = createGridInstance(makeDescriptor("stoppedDemo", save), { - startListening: appListener.startListening, - }); - const store = makeStore("stoppedDemo", inst.reducer); - - inst.stopEffects(); - store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 })); - await vi.advanceTimersByTimeAsync(600); - - expect(save).not.toHaveBeenCalled(); - }); - - it("refuses to build a persisting grid without a listener, instead of never saving", () => { - const save = vi.fn().mockResolvedValue(undefined); - - expect(() => createGridInstance(makeDescriptor("unwiredDemo", save))).toThrow( - /needs the host app's listener middleware/, - ); - }); - - it("builds a grid that persists nothing without needing a listener at all", () => { - const descriptor = makeDescriptor("readOnlyDemo", undefined as never); - delete (descriptor as { api: { saveColumns?: unknown } }).api.saveColumns; - - const inst = createGridInstance(descriptor); - - expect(typeof inst.stopEffects).toBe("function"); - inst.stopEffects(); - }); - - it("persists a reset, so a layout the user threw away does not come back on reload", async () => { - const save = vi.fn().mockResolvedValue(undefined); - const inst = createGridInstance(makeDescriptor("resetDemo", save), { - startListening: appListener.startListening, - }); - const store = makeStore("resetDemo", inst.reducer); - - store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 })); - await vi.advanceTimersByTimeAsync(600); - save.mockClear(); - - store.dispatch(inst.actions.resetColumns()); - await vi.advanceTimersByTimeAsync(600); - - expect(save).toHaveBeenCalledWith({ order: ["id"], widths: {}, hidden: [] }); - - inst.stopEffects(); - }); -}); diff --git a/src/features/dataGrid/store/effects/index.ts b/src/features/dataGrid/store/effects/index.ts deleted file mode 100644 index 32a18de..0000000 --- a/src/features/dataGrid/store/effects/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { GridEffect, GridEffectContext } from "./types"; -import type { StartListening } from "../../types"; -import { columnsPersistenceEffect } from "./columnsPersistenceEffect"; - -/** All action-driven side-effects a grid runs. Add new effects here. */ -export const gridEffects: GridEffect[] = [columnsPersistenceEffect]; - -/** - * Register every gridEffect for one grid instance against the HOST app's - * listener middleware. Keeps the effect-wiring concern owned by effects/, the - * way each slice owns its reducer/actions. - * - * The listener arrives from the caller rather than from a module import: this - * package cannot reach the app's store, and an app that already runs one shared - * listener should not gain a second one just because it mounted a grid. - * - * Returns a function that removes this instance's listeners again. The store - * never needs it — grids live as long as the app — but it keeps tests that - * build throwaway instances from leaking listeners into later tests. - */ -export function registerEffects( - ctx: GridEffectContext, - startAppListening: StartListening, -): () => void { - const unsubscribers: Array<() => void> = []; - - // GridEffect returns void so one effect may register several listeners; wrap - // startListening to collect every unsubscribe it hands back. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const startListening = (options: any) => { - const unsubscribe = startAppListening(options); - unsubscribers.push(unsubscribe); - return unsubscribe; - }; - - for (const effect of gridEffects) effect(ctx, startListening); - - return () => { - for (const unsubscribe of unsubscribers) unsubscribe(); - unsubscribers.length = 0; - }; -} diff --git a/src/features/dataGrid/store/effects/types.ts b/src/features/dataGrid/store/effects/types.ts deleted file mode 100644 index 67f8702..0000000 --- a/src/features/dataGrid/store/effects/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { GridDescriptor, GridSliceState } from "../../types"; - -/** - * The per-slice action creators an effect may react to / dispatch. Typed loosely - * (engine-factory layer), but carrying the `.type` tag RTK attaches to every action - * creator, so effects can match by action type without unsafe casts. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Actions = Record { type: string }) & { type: string }>; - -/** - * Everything an effect needs to reach one grid: its identity, its descriptor, - * its state, and its action creators. - */ -export interface GridEffectContext { - /** - * Store namespace of this grid. Same value as `descriptor.name`. - */ - name: string; - - /** - * The grid's static description, including the server calls. - */ - descriptor: GridDescriptor; - - /** - * Reads this grid's state out of the app store. - */ - selectRoot: (s: unknown) => GridSliceState; - - /** - * Action creators, one group per slice. - */ - actions: { - /** - * Column order, width, and visibility actions. - */ - columns: Actions; - - /** - * Group discovery, collapse, and sort actions. - */ - groups: Actions; - - /** - * Row selection actions. - */ - selection: Actions; - - /** - * Edit-error actions. - */ - edits: Actions; - }; -} - -/** - * An effect registers one (or more) listeners via `startListening`, or no-ops - * (e.g. when its descriptor api hook is absent). Returning void keeps the - * signature simple and lets one effect add several listeners if needed. - */ -export type GridEffect = ( - /** - * The grid the effect attaches to. - */ - ctx: GridEffectContext, - - /** - * RTK's listener registration function. Its real type is heavily generic, so - * we type it `any` here (engine factory layer — localized `any` is already - * sanctioned). Effects themselves stay readable. Keep the `any` in effects/. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - startListening: any, -) => void; diff --git a/src/features/dataGrid/store/localStorageColumnsAdapter.ts b/src/features/dataGrid/store/localStorageColumnsAdapter.ts deleted file mode 100644 index fe2fb47..0000000 --- a/src/features/dataGrid/store/localStorageColumnsAdapter.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ColumnsState } from "../types"; - -/** - * Builds `loadColumns`/`saveColumns` backed by Web Storage. This is the home of - * localStorage knowledge — the slice and engine never touch it directly. A future - * grid can supply a REST-backed adapter with the same shape (descriptor.api.*). - */ -export function localStorageColumnsAdapter( - storageKey: string, - storage: Pick = localStorage, -): { - loadColumns: () => Promise; - saveColumns: (s: ColumnsState) => Promise; -} { - return { - async loadColumns() { - try { - const raw = storage.getItem(storageKey); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - // Only hydrate fields that are present & well-formed; missing → caller keeps defaults. - if (!parsed || typeof parsed !== "object") return null; - return { - order: Array.isArray(parsed.order) && parsed.order.length ? parsed.order : [], - widths: parsed.widths ?? {}, - hidden: parsed.hidden ?? [], - }; - } catch { - return null; - } - }, - async saveColumns(state) { - try { - storage.setItem(storageKey, JSON.stringify(state)); - } catch { - /* ignore quota/unavailable */ - } - }, - }; -} diff --git a/src/features/dataGrid/types.ts b/src/features/dataGrid/types.ts index be68ee6..b6a14a4 100644 --- a/src/features/dataGrid/types.ts +++ b/src/features/dataGrid/types.ts @@ -1,14 +1,23 @@ import type { ReactNode } from "react"; import type { Reducer } from "@reduxjs/toolkit"; -import type { CellRegistry } from "../../lib/grid/cellRegistry"; +import type { CellRegistry, CellTypeDef } from "../../lib/grid/cellRegistry"; +import type { DateCellOptions } from "../../lib/grid/dateCell"; +import type { EnumCellOptions } from "../../lib/grid/enumChoices"; +import type { NumberCellOptions } from "../../lib/grid/numberCell"; +import type { PhoneCellOptions } from "../../lib/grid/phoneCell"; import type { RadixColor } from "../../lib/grid/radixBadgePalette"; +import type { TextCellOptions } from "../../lib/grid/textCell"; import type { RowStore } from "./data/rowStore"; import type { SortSpec } from "./data/sortSpec"; /** - * Generic column definition. + * What every column says, whatever it draws. + * + * Everything here is read by the GRID itself — the header, the layout, the sort + * menu, the edit path. A setting only one cell type understands does not belong + * here; it belongs on that column's `options`. */ -export interface ColumnDef { +interface ColumnDefBase { /** * Row property this column reads. Also the column's id in every state slice. */ @@ -26,6 +35,9 @@ export interface ColumnDef { /** * Whether a cell in this column accepts an edit. + * + * Read by the grid, not just by the cell: it gates the edit overlay and the + * save path as well as whether an editor opens. */ editable: boolean; @@ -38,16 +50,72 @@ export interface ColumnDef { * such a column is an arrow onto an error. */ sortable?: boolean; +} + +/** + * A column definition: what the column is, and how its cells behave. + * + * `type` names the cell, exactly the way glide names its own. The five this + * package draws are prefixed `dg:`; glide's own kinds are reachable under + * glide's own names, unprefixed. The prefix is what keeps the two sets apart + * forever, so glide may add kinds without ever colliding with ours. + * + * `options` is that cell type's own settings, and each branch below types its + * own. A column naming a cell the HOST registered through `descriptor.cells` + * says so with `custom: true`, and its `options` is then `unknown`. + * + * That one word is what makes every other branch check. An open `type: string` + * branch would swallow everything: a mistyped `type`, a mistyped key inside a + * built-in column's `options`, even a `dg:enum` with no choices at all would + * satisfy it and raise nothing. With `custom` as the discriminant, TypeScript + * has to pick a real branch, so all three are errors — and the cost is one key + * on the columns that genuinely are custom. + */ +export type ColumnDef = + | (ColumnDefBase & { type: "dg:text"; custom?: false; options?: TextCellOptions }) + | (ColumnDefBase & { type: "dg:number"; custom?: false; options?: NumberCellOptions }) + | (ColumnDefBase & { type: "dg:date"; custom?: false; options?: DateCellOptions }) + | (ColumnDefBase & { type: "dg:enum"; custom?: false; options: EnumCellOptions }) + | (ColumnDefBase & { type: "dg:phone"; custom?: false; options?: PhoneCellOptions }) + | (ColumnDefBase & { + /** One of glide's own cell kinds, drawn by glide. These take no options. */ + type: "text" | "number" | "boolean" | "uri" | "markdown" | "image" | "bubble" | "drilldown"; + custom?: false; + options?: undefined; + }) + | (ColumnDefBase & { + /** A cell type the host registered through `descriptor.cells`. */ + type: string; + + /** + * REQUIRED for a host-registered cell type, and the only thing that marks + * one. Without it a `type` this package does not know is an error naming + * the ones it does — which is what catches `"dg:numbr"`. + */ + custom: true; + + /** Whatever that cell type reads. Only it knows the shape. */ + options?: unknown; + }); +/** + * Reads and writes the user's column layout. + * + * Both calls are async, whatever the storage behind them: the engine's own + * default writes to Web Storage and resolves immediately, and a host that keeps + * layouts on a server returns a real request. One shape, so the engine's save + * path has one thing to await. + */ +export interface ColumnsAdapter { /** - * Cell kind. Must match a cell registry entry. + * Reads the saved layout, or null when there is none to restore. */ - type: string; + loadColumns: () => Promise; /** - * Date cells only: keep the time part instead of a floating calendar date. + * Persists the layout. Called debounced, after the user stops changing it. */ - withTime?: boolean; + saveColumns: (state: ColumnsState) => Promise; } /** @@ -176,17 +244,18 @@ export interface GridGrouping { /** * Text color of a group's header row, named as a Radix scale. * - * The point is to make the header agree with the column it groups by: when - * that column is an enum cell, return `enumCell.colorOf(g)` and the header - * text lands on the same scale as the badge in the cell and the badge in the - * editor's dropdown. The engine resolves the scale to a concrete color - * through the same CSS vars the badges read, so it follows the appearance - * without being told about it. + * OMIT IT when grouping by a `dg:enum` column: the engine already holds that + * column's choices, so it colours the header from them and the name lands on + * the same scale as the badge in the cell and the badge in the editor's + * dropdown. Setting this overrides that. * - * Answering `undefined` — for the whole grid by omitting this, or for one - * group by returning it — falls the header back to the theme's header text - * color. That is the right answer for a group with no color of its own; it is - * not an error. + * The engine resolves the scale to a concrete color through the same CSS vars + * the badges read, so it follows the appearance without being told about it. + * + * Answering `undefined` — for one group by returning it, or for a grid that + * groups by a column with no colours of its own — falls the header back to + * the theme's header text color. That is the right answer for a group with no + * color of its own; it is not an error. */ color?: (g: TGroup) => RadixColor | undefined; @@ -237,7 +306,8 @@ export interface GridDescriptor TKey; /** - * Column set and its default presentation. + * Column set, its default presentation, and how the user's changes to it are + * remembered. */ columns: { /** @@ -249,6 +319,24 @@ export interface GridDescriptor) => Promise<{ ok: boolean; row?: TRow }>; - - /** - * Reads the user's saved column layout. Omit it to always start from the - * defaults. - */ - loadColumns?: () => Promise; - - /** - * Persists the user's column layout. - */ - saveColumns?: (state: ColumnsState) => Promise; }; /** - * Cell renderers and editors, looked up by `ColumnDef.type`. + * Cell types this grid understands ON TOP of the built-in ones. + * + * Omit it entirely for a grid built from `dg:` cells and glide's own kinds — + * which is most grids. A def whose `type` matches a built-in replaces that + * built-in for this grid, which is how a host changes a cell we draw without + * forking the package. */ - cells: CellRegistry; + cells?: CellTypeDef[]; /** * Row-window page size. Defaults to 100. @@ -449,42 +531,11 @@ export interface GridSliceState { edits: EditState; } -/** - * RTK's `startListening`, as a grid needs it. - * - * The engine registers its action-driven side-effects on the HOST app's - * listener middleware rather than owning one of its own: a listener already - * discriminates by action type, so a middleware per library buys nothing and - * forces every consumer to `.concat` another entry. Pass - * `listenerMiddleware.startListening` (or a `.withTypes<...>()` wrapper of it). - * - * The real RTK type is heavily generic and its state parameter belongs to the - * host, not to us, so this stays deliberately loose — the same localized `any` - * the rest of the engine-factory layer uses. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type StartListening = (options: any) => () => void; - -/** - * Host wiring handed to `createGridInstance`. - */ -export interface GridInstanceOptions { - /** - * The host app's listener registration function. - * - * Required whenever the descriptor sets `api.saveColumns`, because column - * persistence runs as a listener effect; `createGridInstance` throws when it - * is missing rather than silently never saving. Omit it for a grid that - * persists nothing. - */ - startListening?: StartListening; -} - /** * Everything a grid needs at runtime, created once at module scope per grid: * the combined reducer (mount under `descriptor.name`) for client-owned state, - * a root selector, all slice action creators, the thunks, and the cell that - * holds the mounted grid's row store. Hooks and take this. + * a root selector, all slice action creators, the columns adapter, and the cell + * that holds the mounted grid's row store. Hooks and take this. */ export interface GridInstance { /** @@ -586,18 +637,20 @@ export interface GridInstance void; /** - * Removes this instance's effects from the shared grid listener. + * Where this grid's column layout is read from and written to, or null when + * it persists nothing. + * + * Resolved once, from `descriptor.columns`, so the load on mount and every + * save afterwards cannot disagree about where the layout lives. */ - stopEffects: () => void; + columnsAdapter: ColumnsAdapter | null; /** - * Async actions that need the descriptor. + * Every cell type this grid can draw: the built-ins with the descriptor's own + * laid over them, resolved once. + * + * Built here rather than in the component because glide re-creates every + * editor whenever the `customRenderers` array it was handed changes identity. */ - thunks: { - /** - * Restores the saved column layout through `api.loadColumns`. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - loadColumns: () => any; - }; + cells: CellRegistry; } diff --git a/src/index.ts b/src/index.ts index 421938b..b3d7823 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ * `package.json` exposes only `.`, `./testing`, `./radix-styles`, and the * stylesheet, so nothing below this file is reachable from a consuming app. * That is deliberate: everything NOT re-exported here — the row store, the - * display model, the slices, every hook but the ones named below — is free to - * be renamed, moved, or rewritten in a patch release. + * display model, the slices, the cell registry, every hook but the ones named + * below — is free to be renamed, moved, or rewritten in a patch release. * * Add an export here only when a consuming app genuinely cannot do its job * without it. Every symbol added is a promise. @@ -13,14 +13,20 @@ // ------------------------------------------------------------- the grid -- -export { DataGrid } from "./features/dataGrid/DataGrid"; +export { + DataGrid, + type DataGridProps, + type GridDrawHeader, + type GridDrawHeaderArgs, +} from "./features/dataGrid/DataGrid"; + export { createGridInstance } from "./features/dataGrid/store/createGridInstance"; -export { localStorageColumnsAdapter } from "./features/dataGrid/store/localStorageColumnsAdapter"; // -------------------------------------------------- describing one grid -- export type { ColumnDef, + ColumnsAdapter, ColumnsState, EditError, EditState, @@ -28,13 +34,11 @@ export type { GridDescriptor, GridGrouping, GridInstance, - GridInstanceOptions, GridSliceState, GridSort, GroupsState, RowChange, SelectionState, - StartListening, UpdateRowParams, } from "./features/dataGrid/types"; @@ -50,29 +54,29 @@ export { rowsKeyPrefix } from "./features/dataGrid/hooks/useRowPages"; // ---------------------------------------------------------------- cells -- -export { - createCellRegistry, - type CellContext, - type CellRegistry, - type CellTypeDef, -} from "./lib/grid/cellRegistry"; +/** + * A column names its cell by `type`, the way glide does. + * + * The five this package draws are prefixed `dg:` — `dg:text`, `dg:number`, + * `dg:date`, `dg:enum`, `dg:phone` — and each takes its settings on the + * column's own `options`. Glide's kinds are reachable under glide's own names, + * unprefixed: `text`, `number`, `boolean`, `uri`, `markdown`, `image`, + * `bubble`, `drilldown`. + * + * Nothing has to be registered for any of them. The options types below are + * exported because a host writes them into a `ColumnDef`, not because a host + * ever builds a cell. + */ +export type { TextCellOptions } from "./lib/grid/textCell"; +export type { NumberCellOptions } from "./lib/grid/numberCell"; +export type { DateCellOptions } from "./lib/grid/dateCell"; +export type { EnumCellOptions, EnumOption } from "./lib/grid/enumChoices"; +export type { PhoneCellOptions } from "./lib/grid/phoneCell"; -export { createTextCell, type TextCell, type TextCellData } from "./lib/grid/createTextCell"; -export { - createNumberCell, - type NumberCell, - type NumberCellData, -} from "./lib/grid/createNumberCell"; -export { createDateCell, type DateCell, type DateCellData } from "./lib/grid/createDateCell"; -export { - createEnumCell, - type EnumCell, - type EnumCellData, - type EnumOption, -} from "./lib/grid/createEnumCell"; -export { createPhoneCell, type PhoneCell, type PhoneCellData } from "./lib/grid/createPhoneCell"; - -/** For a cell kind this package does not ship. */ +export type { NumberFormat } from "./lib/number/numberUtils"; +export type { TextValidationOptions } from "./lib/text/textValidation"; + +/** For a cell kind this package does not ship. Register it on `descriptor.cells`. */ export { createCustomCell, drawEmptyDash, @@ -82,6 +86,8 @@ export { type EditorProps, } from "./lib/grid/createCustomCell"; +export type { CellContext, CellTypeDef } from "./lib/grid/cellRegistry"; + export { drawSoftBadge, type ResolvedBadgeColors } from "./lib/grid/softBadge"; export { BADGE_SEQUENCE, radixColorByIndex, type RadixColor } from "./lib/grid/radixBadgePalette"; @@ -99,10 +105,7 @@ export { type DateRange, } from "./components/ui/datePicker"; -export { - DatePickerPopover, - type DatePickerPopoverProps, -} from "./components/ui/datePickerPopover"; +export { DatePickerPopover, type DatePickerPopoverProps } from "./components/ui/datePickerPopover"; // ---------------------------------------------------------------- theme -- diff --git a/src/lib/date/dateMask.ts b/src/lib/date/dateMask.ts index 56184ba..55e69a3 100644 --- a/src/lib/date/dateMask.ts +++ b/src/lib/date/dateMask.ts @@ -27,11 +27,7 @@ export function maskToDate(digits: string): Date | undefined { // Rejects both out-of-range parts and overflow days (Feb 30 → Mar 2), since // the Date constructor silently rolls those forward. - if ( - date.getFullYear() !== year || - date.getMonth() !== month - 1 || - date.getDate() !== day - ) { + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { return undefined; } diff --git a/src/lib/grid/cellRegistry.test.ts b/src/lib/grid/cellRegistry.test.ts index ed381ac..fbe7a32 100644 --- a/src/lib/grid/cellRegistry.test.ts +++ b/src/lib/grid/cellRegistry.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { GridCellKind, type CustomCell, type GridCell } from "@glideapps/glide-data-grid"; -import { createCellRegistry, type CellTypeDef } from "./cellRegistry"; +import { createCellRegistry, resolveCellRegistry, type CellTypeDef } from "./cellRegistry"; +import { ENUM_CELL_TYPE } from "./enumCell"; +import { TEXT_CELL_TYPE } from "./textCell"; -const textCell = (s: string): GridCell => ({ +const plainText = (s: string): GridCell => ({ kind: GridCellKind.Text, data: s, displayData: s, @@ -10,7 +12,7 @@ const textCell = (s: string): GridCell => ({ }); const defs: CellTypeDef[] = [ - { type: "text", make: (raw) => textCell(String(raw ?? "")) }, + { type: "plain", make: (raw) => plainText(String(raw ?? "")) }, { type: "phone", kind: "phone", @@ -30,12 +32,12 @@ const defs: CellTypeDef[] = [ }, ]; +const ctx = { editable: false, options: undefined }; + describe("createCellRegistry", () => { it("makeCell dispatches by column type", () => { const r = createCellRegistry(defs); - expect(r.makeCell("text", "hi", { editable: false, withTime: false })).toMatchObject({ - data: "hi", - }); + expect(r.makeCell("plain", "hi", ctx)).toMatchObject({ data: "hi" }); }); it("collects only defined renderers", () => { @@ -45,34 +47,71 @@ describe("createCellRegistry", () => { it("validateCell routes custom cells by kind and passes everything else", () => { const r = createCellRegistry(defs); - const good = { - kind: GridCellKind.Custom, - data: { kind: "phone", value: "ok" }, - copyData: "", - allowOverlay: true, - } as unknown as CustomCell<{ kind: string }>; - const bad = { - kind: GridCellKind.Custom, - data: { kind: "phone", value: "bad" }, - copyData: "", - allowOverlay: true, - } as unknown as CustomCell<{ kind: string }>; - const unknown = { - kind: GridCellKind.Custom, - data: { kind: "mystery" }, - copyData: "", - allowOverlay: true, - } as unknown as CustomCell<{ kind: string }>; - expect(r.validateCell(good)).toBe(true); - expect(r.validateCell(bad)).toBe(false); - expect(r.validateCell(unknown)).toBe(true); // no validator → valid - expect(r.validateCell(textCell("x"))).toBe(true); // non-custom → valid + const custom = (value: unknown, kind = "phone") => + ({ + kind: GridCellKind.Custom, + data: { kind, value }, + copyData: "", + allowOverlay: true, + }) as unknown as CustomCell<{ kind: string }>; + + expect(r.validateCell(custom("ok"))).toBe(true); + expect(r.validateCell(custom("bad"))).toBe(false); + expect(r.validateCell(custom("x", "mystery"))).toBe(true); // no validator → valid + expect(r.validateCell(plainText("x"))).toBe(true); // non-custom → valid }); it("throws on an unknown column type (fail fast, not a blank cell)", () => { const r = createCellRegistry(defs); - expect(() => r.makeCell("nope", 1, { editable: false, withTime: false })).toThrow( - /unknown cell type/i, - ); + expect(() => r.makeCell("nope", 1, ctx)).toThrow(/unknown cell type/i); + }); +}); + +describe("resolveCellRegistry", () => { + it("knows every dg: cell without being handed one", () => { + const r = resolveCellRegistry(); + const cell = r.makeCell(TEXT_CELL_TYPE, "hi", { editable: true, options: {} }); + expect((cell as unknown as { data: { value: string } }).data.value).toBe("hi"); + }); + + it("knows glide's own kinds under glide's own names", () => { + const r = resolveCellRegistry(); + expect(r.makeCell("text", "hi", ctx).kind).toBe(GridCellKind.Text); + expect(r.makeCell("boolean", true, ctx).kind).toBe(GridCellKind.Boolean); + }); + + it("registers one renderer per dg: cell and none for glide's kinds", () => { + // Five drawn by this package; glide draws its own, so they add nothing here. + expect(resolveCellRegistry().customRenderers).toHaveLength(5); + }); + + it("lets a host's def of the same type win over the built-in", () => { + const mine: CellTypeDef = { type: ENUM_CELL_TYPE, make: () => plainText("mine") }; + const r = resolveCellRegistry([mine]); + + expect(r.makeCell(ENUM_CELL_TYPE, 1, ctx)).toMatchObject({ data: "mine" }); + }); + + it("adds a host's own type alongside the built-ins", () => { + const mine: CellTypeDef = { type: "invoice-status", make: () => plainText("custom") }; + const r = resolveCellRegistry([mine]); + + expect(r.makeCell("invoice-status", 1, ctx)).toMatchObject({ data: "custom" }); + expect(r.makeCell("text", "still here", ctx).kind).toBe(GridCellKind.Text); + }); + + it("hands the column's options to the maker", () => { + const seen: unknown[] = []; + const mine: CellTypeDef = { + type: "spy", + make: (_raw, c) => { + seen.push(c.options); + return plainText(""); + }, + }; + + resolveCellRegistry([mine]).makeCell("spy", 1, { editable: true, options: { a: 1 } }); + + expect(seen).toEqual([{ a: 1 }]); }); }); diff --git a/src/lib/grid/cellRegistry.ts b/src/lib/grid/cellRegistry.ts index ed690da..69edcb3 100644 --- a/src/lib/grid/cellRegistry.ts +++ b/src/lib/grid/cellRegistry.ts @@ -4,17 +4,29 @@ import { type CustomRenderer, type GridCell, } from "@glideapps/glide-data-grid"; +import { dateCellDef } from "./dateCell"; +import { enumCellDef } from "./enumCell"; +import { glideCellDefs } from "./glideCells"; +import { numberCellDef } from "./numberCell"; +import { phoneCellDef } from "./phoneCell"; +import { textCellDef } from "./textCell"; -/** Per-column-cell context the maker needs beyond the raw value. */ +/** + * What a cell maker knows about the column it is drawing, beyond the raw value. + * + * `options` is the column's own `options` object, typed `unknown` because only + * the cell type it belongs to knows its shape. A built-in cell casts it to its + * own options interface; a custom cell casts it to whatever that cell defined. + */ export interface CellContext { editable: boolean; - withTime: boolean; + options: unknown; } /** * One cell type. `type` keys the column→cell dispatch; `kind` (for custom cells) - * is the runtime `cell.data.kind` used to route validation. Built-in cells omit - * `kind`, `renderer`, and `validate`. + * is the runtime `cell.data.kind` used to route validation. A def wrapping one + * of glide's own kinds omits `kind`, `renderer`, and `validate`. */ export interface CellTypeDef { type: string; @@ -30,11 +42,23 @@ export interface CellRegistry { validateCell: (cell: GridCell) => boolean; } +/** + * Every cell type a grid understands without being told: the five this package + * draws, prefixed `dg:`, plus glide's own kinds under glide's own names. + */ +export const builtinCellDefs: CellTypeDef[] = [ + textCellDef, + numberCellDef, + dateCellDef, + enumCellDef, + phoneCellDef, + ...glideCellDefs, +]; + /** * Build a registry from cell-type defs. Derives the three things a grid needs * from one source of truth: a type→maker dispatch, the custom-renderer list, and - * a kind→validator router. Adding a cell type means adding one def — no edits to - * the column dispatch, the renderer array, or a validation if-chain. + * a kind→validator router. */ export function createCellRegistry(defs: CellTypeDef[]): CellRegistry { const byType = new Map(defs.map((d) => [d.type, d])); @@ -51,7 +75,7 @@ export function createCellRegistry(defs: CellTypeDef[]): CellRegistry { const def = byType.get(type); if (!def) { - throw new Error(`createCellRegistry: unknown cell type "${type}"`); + throw new Error(`Unknown cell type "${type}"`); } return def.make(raw, ctx); @@ -73,3 +97,21 @@ export function createCellRegistry(defs: CellTypeDef[]): CellRegistry { }, }; } + +/** + * The registry one grid runs on: the built-ins, with the descriptor's own cell + * types laid over them. + * + * A host def whose `type` matches a built-in WINS. That is deliberate and is the + * only way a host can change how, say, `dg:date` draws without forking the + * package — and it is the same order the rest of the engine reads in: the + * consumer's answer beats ours, ours beats glide's default. + */ +export function resolveCellRegistry(custom: readonly CellTypeDef[] = []): CellRegistry { + const byType = new Map(); + + for (const def of builtinCellDefs) byType.set(def.type, def); + for (const def of custom) byType.set(def.type, def); + + return createCellRegistry([...byType.values()]); +} diff --git a/src/lib/grid/createDateCell.test.ts b/src/lib/grid/createDateCell.test.ts deleted file mode 100644 index f7c32bf..0000000 --- a/src/lib/grid/createDateCell.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CustomCell } from "@glideapps/glide-data-grid"; -import { createDateCell, type DateCellData } from "./createDateCell"; - -const date = createDateCell({ kind: "date", nullable: true }); - -describe("createDateCell.makeCell", () => { - it("stores the ISO value, withTime flag, and ISO copyData", () => { - const cell = date.makeCell("2026-06-20T00:00:00Z", true); - expect(cell.data.kind).toBe("date"); - expect(cell.data.value).toBe("2026-06-20T00:00:00Z"); - expect(cell.data.withTime).toBe(true); - expect(cell.copyData).toBe("2026-06-20T00:00:00Z"); - }); - - it("renders a null value as empty copyData", () => { - const cell = date.makeCell(null, false); - expect(cell.data.value).toBeNull(); - expect(cell.data.withTime).toBe(false); - expect(cell.copyData).toBe(""); - }); - - it("disables the overlay when allowOverlay is false", () => { - const cell = date.makeCell("2026-06-20T00:00:00Z", false, false); - expect(cell.allowOverlay).toBe(false); - }); - - it("allows the overlay by default", () => { - const cell = date.makeCell("2026-06-20T00:00:00Z", false); - expect(cell.allowOverlay).toBe(true); - }); - - it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { - expect(date.makeCell(null, false, false).data.readOnly).toBe(true); - }); - - it("leaves an editable cell unflagged so an empty value stays blank", () => { - expect(date.makeCell(null, false).data.readOnly).toBeUndefined(); - }); -}); - -describe("createDateCell.validate", () => { - const mk = (value: string | null): CustomCell => date.makeCell(value, false); - - it("accepts a valid ISO value", () => { - expect(date.validate(mk("2026-06-20T00:00:00Z"))).toBe(true); - }); - - it("accepts empty because the cell is nullable", () => { - expect(date.validate(mk(null))).toBe(true); - }); - - it("rejects a malformed non-empty value", () => { - expect(date.validate(mk("garbage"))).toBe(false); - }); -}); - -describe("createDateCell.renderer", () => { - it("matches cells of its kind", () => { - const cell = date.makeCell("2026-06-20T00:00:00Z", false); - expect(date.renderer.isMatch(cell)).toBe(true); - }); -}); - -describe("createDateCell.onPaste", () => { - const baseData: DateCellData = { kind: "date", value: null, withTime: false }; - - it("pins a pasted ISO date to UTC midnight for a date-only cell", () => { - expect(date.renderer.onPaste?.("2026-06-20", baseData)).toEqual({ - kind: "date", - value: "2026-06-20T00:00:00.000Z", - withTime: false, - }); - }); - - it("rejects an unparseable paste (returns undefined)", () => { - expect(date.renderer.onPaste?.("garbage", baseData)).toBeUndefined(); - }); - - it("accepts an empty paste as null because the cell is nullable", () => { - expect(date.renderer.onPaste?.(" ", baseData)).toEqual({ - kind: "date", - value: null, - withTime: false, - }); - }); - - it("preserves the time component when the cell is withTime", () => { - expect( - date.renderer.onPaste?.("2026-06-20T14:30:00Z", { ...baseData, withTime: true }), - ).toEqual({ - kind: "date", - value: "2026-06-20T14:30:00.000Z", - withTime: true, - }); - }); -}); - -function fakeCtx() { - const calls: string[] = []; - const ctx = { - font: "", - fillStyle: "" as string, - textBaseline: "alphabetic" as CanvasTextBaseline, - fillText: (t: string) => { - calls.push(t); - }, - }; - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - -const theme = { - textDark: "#111", - textLight: "#999", - baseFontFull: "13px sans-serif", - cellHorizontalPadding: 8, -}; -const rect = { x: 0, y: 0, width: 120, height: 34 }; -const drawArgs = (ctx: CanvasRenderingContext2D) => - ({ ctx, rect, theme }) as unknown as Parameters[0]; - -describe("createDateCell draw", () => { - it("draws an em dash for a read-only empty cell", () => { - const { ctx, calls } = fakeCtx(); - date.renderer.draw(drawArgs(ctx), date.makeCell(null, false, false)); - expect(calls).toEqual(["—"]); - }); - - it("draws nothing for an editable empty cell", () => { - const { ctx, calls } = fakeCtx(); - date.renderer.draw(drawArgs(ctx), date.makeCell(null, false)); - expect(calls).toEqual([]); - }); - - it("draws the localized date for a non-empty cell", () => { - const { ctx, calls } = fakeCtx(); - date.renderer.draw(drawArgs(ctx), date.makeCell("2026-06-20T00:00:00Z", false, false)); - expect(calls).toEqual(["6/20/2026"]); - }); -}); diff --git a/src/lib/grid/createDateCell.tsx b/src/lib/grid/createDateCell.tsx deleted file mode 100644 index 003df05..0000000 --- a/src/lib/grid/createDateCell.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; -import { Theme } from "@radix-ui/themes"; -import { DatePicker } from "../../components/ui/datePicker"; -import { gridRadixTheme } from "../../theme/radixTheme"; -import { formatDateDisplay, isValidDateValue, parseDatePaste } from "../date/dateUtils"; -import { - createCustomCell, - drawEmptyDash, - makeCustomCell, - type EditorProps, -} from "./createCustomCell"; - -/** Cell payload. `value` is a canonical UTC ISO string, or null for an unset cell. */ -export interface DateCellData { - kind: string; - value: string | null; - withTime: boolean; - readOnly?: boolean; -} - -export interface DateCell { - renderer: CustomRenderer>; - makeCell: ( - value: string | null, - withTime: boolean, - allowOverlay?: boolean, - ) => CustomCell; - validate: (cell: CustomCell) => boolean; -} - -/** - * Build a reusable date cell: a canvas text `draw` (localized date, or date+time - * when the cell's `withTime` is set) plus a `DatePicker` overlay editor. Stores - * canonical UTC ISO strings. - * - * The editor HOLDS the edit: nothing reaches the cell until the user presses OK, - * and Cancel or Escape discards it. A click on a day is no longer a commit. - * - * Call once at module scope (not inside a React render): the editor component - * identity is tied to this call, so re-creating it per render would remount it. - */ -export function createDateCell({ - kind, - nullable = false, -}: { - kind: string; - nullable?: boolean; -}): DateCell { - const Editor = ({ value, onFinishedEditing }: EditorProps) => ( - - onFinishedEditing({ ...value, value: next })} - onCancel={() => onFinishedEditing(undefined)} - /> - - ); - - const renderer = createCustomCell({ - kind, - draw: (args, data) => { - if (data.value == null || data.value === "") { - if (data.readOnly) drawEmptyDash(args); - return; - } - - const { ctx, rect, theme } = args; - ctx.fillStyle = theme.textDark; - ctx.font = theme.baseFontFull; - ctx.textBaseline = "middle"; - ctx.fillText( - formatDateDisplay(data.value, data.withTime), - rect.x + theme.cellHorizontalPadding, - rect.y + rect.height / 2, - ); - }, - editor: Editor, - onPaste: (val, data) => { - const parsed = parseDatePaste(val, data.withTime); - - if (parsed === undefined) return undefined; - if (parsed === null && !nullable) return undefined; - - return { ...data, value: parsed }; - }, - }); - - const makeCell = ( - value: string | null, - withTime: boolean, - allowOverlay = true, - ): CustomCell => - makeCustomCell( - { kind, value, withTime, ...(allowOverlay ? {} : { readOnly: true }) }, - value ?? "", - allowOverlay, - ); - - const validate = (cell: CustomCell): boolean => - isValidDateValue(cell.data.value, nullable); - - return { renderer, makeCell, validate }; -} diff --git a/src/lib/grid/createEnumCell.test.ts b/src/lib/grid/createEnumCell.test.ts deleted file mode 100644 index 6eb9d93..0000000 --- a/src/lib/grid/createEnumCell.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { GridCellKind } from "@glideapps/glide-data-grid"; -import { describe, expect, it } from "vitest"; -import { createEnumCell } from "./createEnumCell"; -import { radixColorByIndex } from "./radixBadgePalette"; - -const cell = createEnumCell({ - kind: "enum-test", - options: [ - { value: 1, label: "One" }, - { value: 2, label: "Two", color: "red" }, - ], -}); - -const nullableCell = createEnumCell({ - kind: "enum-nullable-test", - nullable: true, - options: [ - { value: 1, label: "One" }, - { value: 2, label: "Two", color: "red" }, - ], -}); - -describe("createEnumCell", () => { - it("makeCell builds a custom cell with kind, value, and label copyData", () => { - const c = cell.makeCell(1); - expect(c.kind).toBe(GridCellKind.Custom); - expect(c.data).toEqual({ kind: "enum-test", value: 1 }); - expect(c.copyData).toBe("One"); - }); - - it("colorOf uses the index default when no override is given", () => { - expect(cell.colorOf(1)).toBe(radixColorByIndex(1)); - }); - - it("colorOf prefers a per-value override over the index default", () => { - expect(cell.colorOf(2)).toBe("red"); - }); - - it("the renderer matches its own cell kind and not others", () => { - const match = cell.renderer.isMatch(cell.makeCell(1)); - expect(match).toBe(true); - }); - - it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { - const c = nullableCell.makeCell(null); - expect(c.data).toEqual({ kind: "enum-nullable-test", value: null }); - expect(c.copyData).toBe(""); - }); -}); - -describe("createEnumCell.makeCell allowOverlay", () => { - it("disables the overlay (no Select) when allowOverlay is false", () => { - expect(cell.makeCell(1, false).allowOverlay).toBe(false); - }); - - it("allows the overlay by default", () => { - expect(cell.makeCell(1).allowOverlay).toBe(true); - }); - - it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { - expect(nullableCell.makeCell(null, false).data.readOnly).toBe(true); - }); - - it("leaves an editable cell unflagged so an empty value stays blank", () => { - expect(cell.makeCell(1).data.readOnly).toBeUndefined(); - }); -}); - -function fakeCtx() { - const calls: string[] = []; - const ctx = { - font: "", - fillStyle: "" as string, - textBaseline: "alphabetic" as CanvasTextBaseline, - fillText: (t: string) => { - calls.push(t); - }, - }; - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - -const theme = { - textDark: "#111", - textLight: "#999", - baseFontFull: "13px sans-serif", - cellHorizontalPadding: 8, -}; -const rect = { x: 0, y: 0, width: 160, height: 34 }; -const drawArgs = (ctx: CanvasRenderingContext2D) => - ({ ctx, rect, theme }) as unknown as Parameters[0]; - -describe("createEnumCell draw", () => { - it("draws an em dash for a read-only empty cell", () => { - const { ctx, calls } = fakeCtx(); - nullableCell.renderer.draw(drawArgs(ctx), nullableCell.makeCell(null, false)); - expect(calls).toEqual(["—"]); - }); - - it("draws nothing for an editable empty cell", () => { - const { ctx, calls } = fakeCtx(); - nullableCell.renderer.draw(drawArgs(ctx), nullableCell.makeCell(null)); - expect(calls).toEqual([]); - }); -}); diff --git a/src/lib/grid/createEnumCell.tsx b/src/lib/grid/createEnumCell.tsx deleted file mode 100644 index 513320b..0000000 --- a/src/lib/grid/createEnumCell.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; -import { Select, Text, Theme } from "@radix-ui/themes"; -import { gridRadixTheme } from "../../theme/radixTheme"; -import { - createCustomCell, - drawEmptyDash, - makeCustomCell, - type EditorProps, -} from "./createCustomCell"; -import { radixColorByIndex, type RadixColor } from "./radixBadgePalette"; -import { drawSoftBadge } from "./softBadge"; -import { SoftBadge } from "./softBadgeView"; - -/** One selectable enum value: numeric value, label, and an optional color override. */ -export interface EnumOption { - value: number; - label: string; - color?: RadixColor; -} - -/** Cell payload stored in the grid. `kind` routes the renderer; `value` is the - * enum, or `null` for an unset (cleared) cell on a nullable column. */ -export interface EnumCellData { - kind: string; - value: number | null; - readOnly?: boolean; -} - -interface ResolvedOption { - label: string; - color: RadixColor; -} - -export interface EnumCell { - renderer: CustomRenderer>; - /** - * Build a cell for `value`. Pass `allowOverlay: false` for a read-only column: - * the badge still renders, the `Select` editor never opens, and an empty value - * draws as "—" instead of blank. - */ - makeCell: (value: number | null, allowOverlay?: boolean) => CustomCell; - /** Resolved color for a value (override or index default). Exposed for tests/reuse. */ - colorOf: (value: number) => RadixColor; -} - -/** Sentinel item value for the "clear" option in a nullable Select. Radix - * reserves the empty string for items, so a non-empty sentinel is required. */ -const NONE_VALUE = "__none__"; - -/** - * Build a reusable enum cell: a canvas soft-badge `draw` plus a Radix `Select` - * editor. Colors default to radixColorByIndex(value), overridable per option. - * Each enum column must pass a unique `kind` so glide's isMatch routes correctly. - * - * Call this once at module scope (not inside a React render): the editor component - * identity is tied to this call, so re-creating it per render would remount editors. - */ -export function createEnumCell({ - kind, - options, - nullable = false, -}: { - kind: string; - options: EnumOption[]; - nullable?: boolean; -}): EnumCell { - const byValue = new Map(); - - for (const opt of options) { - byValue.set(opt.value, { label: opt.label, color: opt.color ?? radixColorByIndex(opt.value) }); - } - - const lookup = (value: number): ResolvedOption => - byValue.get(value) ?? { label: String(value), color: radixColorByIndex(value) }; - - const Editor = ({ value, onFinishedEditing }: EditorProps) => { - const handleValueChange = (next: string) => { - onFinishedEditing({ ...value, value: next === NONE_VALUE ? null : Number(next) }); - }; - - return ( - - - - {/* - The dropdown is PORTALLED out of the cell overlay, so glide sees a - press on an option as a click outside the editor. Its outside-click - handler runs on `pointerdown`, in the capture phase, which is before - the Select decides anything on `pointerup` — so the overlay closed - and the choice was thrown away. Only the keyboard worked, because it - never presses a pointer. - - `click-outside-ignore` is glide's own escape hatch for exactly this: - it walks up from whatever was pressed and stops if it finds the - class. See `internal/click-outside-container`. - */} - - {nullable && ( - - None - - )} - {options.map((opt) => ( - - - - ))} - - - - ); - }; - - const renderer = createCustomCell({ - kind, - draw: (args, data) => { - if (data.value == null) { - if (data.readOnly) drawEmptyDash(args); - return; - } - - const { label, color } = lookup(data.value); - // Pass the grid's base font so drawSoftBadge measures with the same font it - // renders (glide's FullTheme exposes the composed font as `baseFontFull`). - drawSoftBadge(args.ctx, args.rect, color, label, args.theme.baseFontFull); - }, - editor: Editor, - }); - - const makeCell = (value: number | null, allowOverlay = true): CustomCell => - makeCustomCell( - { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, - value == null ? "" : lookup(value).label, - allowOverlay, - ); - - return { renderer, makeCell, colorOf: (value) => lookup(value).color }; -} diff --git a/src/lib/grid/createNumberCell.test.ts b/src/lib/grid/createNumberCell.test.ts deleted file mode 100644 index c52f73b..0000000 --- a/src/lib/grid/createNumberCell.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { GridCellKind } from "@glideapps/glide-data-grid"; -import { describe, expect, it } from "vitest"; -import { createNumberCell } from "./createNumberCell"; - -const cell = createNumberCell({ kind: "num-test", min: 0, max: 100 }); -const nullableCell = createNumberCell({ - kind: "num-nullable-test", - nullable: true, - min: 0, - max: 100, -}); - -describe("createNumberCell", () => { - it("makeCell builds a custom cell with kind, numeric value, and string copyData", () => { - const c = cell.makeCell(42.5); - expect(c.kind).toBe(GridCellKind.Custom); - expect(c.data).toEqual({ kind: "num-test", value: 42.5 }); - expect(c.copyData).toBe("42.5"); - }); - - it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { - const c = cell.makeCell(null); - expect(c.data).toEqual({ kind: "num-test", value: null }); - expect(c.copyData).toBe(""); - }); - - it("the renderer matches its own cell kind and not a foreign one", () => { - expect(cell.renderer.isMatch(cell.makeCell(1))).toBe(true); - expect(cell.renderer.isMatch(nullableCell.makeCell(1))).toBe(false); - }); - - it("validate blocks empty when not nullable, allows it when nullable", () => { - expect(cell.validate(cell.makeCell(null))).toBe(false); - expect(nullableCell.validate(nullableCell.makeCell(null))).toBe(true); - }); - - it("validate accepts in-range and rejects out-of-range values", () => { - expect(cell.validate(cell.makeCell(50))).toBe(true); - expect(cell.validate(cell.makeCell(500))).toBe(false); - }); - - it("onPaste parses a separated string and rejects junk / out-of-range", () => { - const onPaste = cell.renderer.onPaste!; - const data = cell.makeCell(null).data; - expect(onPaste("50", data)).toEqual({ kind: "num-test", value: 50 }); - expect(onPaste("garbage", data)).toBeUndefined(); - expect(onPaste("500", data)).toBeUndefined(); - }); - - it("onPaste rejects empty when not nullable but clears when nullable", () => { - expect(cell.renderer.onPaste!(" ", cell.makeCell(1).data)).toBeUndefined(); - expect(nullableCell.renderer.onPaste!(" ", nullableCell.makeCell(1).data)).toEqual({ - kind: "num-nullable-test", - value: null, - }); - }); -}); - -describe("createNumberCell.makeCell allowOverlay", () => { - it("disables the overlay when allowOverlay is false", () => { - expect(cell.makeCell(1, false).allowOverlay).toBe(false); - }); - - it("allows the overlay by default", () => { - expect(cell.makeCell(1).allowOverlay).toBe(true); - }); - - it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { - expect(cell.makeCell(1, false).data.readOnly).toBe(true); - }); - - it("leaves an editable cell unflagged so an empty value stays blank", () => { - expect(cell.makeCell(1).data.readOnly).toBeUndefined(); - }); -}); - -function fakeCtx() { - const calls: string[] = []; - const ctx = { - font: "", - fillStyle: "" as string, - textBaseline: "alphabetic" as CanvasTextBaseline, - fillText: (t: string) => { - calls.push(t); - }, - }; - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx }; -} - -const theme = { - textDark: "#111", - textLight: "#999", - baseFontFull: "13px sans-serif", - cellHorizontalPadding: 8, -}; -const rect = { x: 0, y: 0, width: 120, height: 34 }; -const drawArgs = (ctx: CanvasRenderingContext2D) => - ({ ctx, rect, theme }) as unknown as Parameters[0]; - -describe("createNumberCell draw", () => { - it("draws an em dash for a read-only empty cell", () => { - const { ctx, calls, raw } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false)); - expect(calls).toEqual(["—"]); - expect(raw.fillStyle).toBe("#999"); - }); - - it("draws nothing for an editable empty cell", () => { - const { ctx, calls } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(null)); - expect(calls).toEqual([]); - }); - - it("draws the formatted value for a non-empty cell", () => { - const { ctx, calls, raw } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(42.5, false)); - expect(calls).toEqual(["42.5"]); - expect(raw.fillStyle).toBe("#111"); - }); -}); diff --git a/src/lib/grid/createNumberCell.tsx b/src/lib/grid/createNumberCell.tsx deleted file mode 100644 index 35341b6..0000000 --- a/src/lib/grid/createNumberCell.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; -import { Theme } from "@radix-ui/themes"; -import { NumericInput } from "../../components/ui/numericInput"; -import { gridRadixTheme } from "../../theme/radixTheme"; -import { - formatNumberDisplay, - isValueInRange, - parseNumberPaste, - type NumberFormat, -} from "../number/numberUtils"; -import { - createCustomCell, - drawEmptyDash, - makeCustomCell, - type EditorProps, -} from "./createCustomCell"; - -/** Cell payload. `value` is a JS number, or null for an unset cell. */ -export interface NumberCellData { - kind: string; - value: number | null; - readOnly?: boolean; -} - -export interface NumberCell { - renderer: CustomRenderer>; - makeCell: (value: number | null, allowOverlay?: boolean) => CustomCell; - validate: (cell: CustomCell) => boolean; -} - -/** - * Build a reusable numeric cell: a canvas text `draw` (formatted per options) - * plus a `` editor. Out-of-range entry is blocked in the editor; - * `validate` and `onPaste` gate commits and pastes. Empty is allowed only when - * `nullable`. - * - * Call once at module scope (not inside a React render): the editor component - * identity is tied to this call, so re-creating it per render would remount it. - */ -export function createNumberCell({ - kind, - nullable = false, - format, - decimalScale, - currency, - min, - max, - thousandSeparator, - prefix, - suffix, -}: { - kind: string; - nullable?: boolean; - format?: NumberFormat; - decimalScale?: number; - currency?: string; - min?: number; - max?: number; - thousandSeparator?: boolean; - prefix?: string; - suffix?: string; -}): NumberCell { - const fmt = { format, decimalScale, currency, min, max, thousandSeparator, prefix, suffix }; - - const Editor = ({ value, onChange }: EditorProps) => ( - -
- onChange({ ...value, value: next })} - autoFocus - /> -
-
- ); - - const renderer = createCustomCell({ - kind, - draw: (args, data) => { - if (data.value == null) { - if (data.readOnly) drawEmptyDash(args); - return; - } - - const { ctx, rect, theme } = args; - ctx.fillStyle = theme.textDark; - ctx.font = theme.baseFontFull; - ctx.textBaseline = "middle"; - ctx.fillText( - formatNumberDisplay(data.value, fmt), - rect.x + theme.cellHorizontalPadding, - rect.y + rect.height / 2, - ); - }, - editor: Editor, - onPaste: (val, data) => { - const parsed = parseNumberPaste(val, { min, max, nullable }); - - if (parsed === undefined) return undefined; - - return { ...data, value: parsed }; - }, - }); - - const makeCell = (value: number | null, allowOverlay = true): CustomCell => - makeCustomCell( - { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, - value == null ? "" : String(value), - allowOverlay, - ); - - const validate = (cell: CustomCell): boolean => { - const v = cell.data.value; - - if (v == null) return nullable; - - return isValueInRange(v, min, max); - }; - - return { renderer, makeCell, validate }; -} diff --git a/src/lib/grid/createPhoneCell.test.ts b/src/lib/grid/createPhoneCell.test.ts deleted file mode 100644 index f85e43a..0000000 --- a/src/lib/grid/createPhoneCell.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { GridCellKind } from "@glideapps/glide-data-grid"; -import { describe, expect, it } from "vitest"; -import { createPhoneCell } from "./createPhoneCell"; - -const cell = createPhoneCell({ kind: "phone-test" }); -const nullableCell = createPhoneCell({ kind: "phone-nullable-test", nullable: true }); - -describe("createPhoneCell", () => { - it("makeCell builds a custom cell with kind, E.164 value, and E.164 copyData", () => { - const c = cell.makeCell("+14155552671"); - expect(c.kind).toBe(GridCellKind.Custom); - expect(c.data).toEqual({ kind: "phone-test", value: "+14155552671" }); - expect(c.copyData).toBe("+14155552671"); - }); - - it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { - const c = cell.makeCell(null); - expect(c.data).toEqual({ kind: "phone-test", value: null }); - expect(c.copyData).toBe(""); - }); - - it("the renderer matches its own cell kind and not a foreign one", () => { - expect(cell.renderer.isMatch(cell.makeCell("+14155552671"))).toBe(true); - expect(cell.renderer.isMatch(nullableCell.makeCell("+14155552671"))).toBe(false); - }); - - it("validate blocks empty when not nullable, allows it when nullable", () => { - expect(cell.validate(cell.makeCell(null))).toBe(false); - expect(nullableCell.validate(nullableCell.makeCell(null))).toBe(true); - }); - - it("validate accepts a valid number and rejects a malformed one", () => { - expect(cell.validate(cell.makeCell("+14155552671"))).toBe(true); - expect(cell.validate(cell.makeCell("+1234"))).toBe(false); - }); - - it("onPaste parses a national string to E.164 and rejects junk", () => { - const onPaste = cell.renderer.onPaste!; - const data = cell.makeCell(null).data; - expect(onPaste("(415) 555-2671", data)).toEqual({ kind: "phone-test", value: "+14155552671" }); - expect(onPaste("garbage", data)).toBeUndefined(); - }); - - it("onPaste rejects an empty paste when not nullable but clears when nullable", () => { - expect(cell.renderer.onPaste!(" ", cell.makeCell("+14155552671").data)).toBeUndefined(); - expect( - nullableCell.renderer.onPaste!(" ", nullableCell.makeCell("+14155552671").data), - ).toEqual({ kind: "phone-nullable-test", value: null }); - }); -}); - -describe("createPhoneCell.makeCell allowOverlay", () => { - it("disables the overlay when allowOverlay is false", () => { - expect(cell.makeCell("+14155552671", false).allowOverlay).toBe(false); - }); - - it("allows the overlay by default", () => { - expect(cell.makeCell("+14155552671").allowOverlay).toBe(true); - }); - - it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { - expect(cell.makeCell(null, false).data.readOnly).toBe(true); - }); - - it("leaves an editable cell unflagged so an empty value stays blank", () => { - expect(cell.makeCell("+14155552671").data.readOnly).toBeUndefined(); - }); -}); - -function fakeCtx() { - const calls: string[] = []; - const ctx = { - font: "", - fillStyle: "" as string, - textBaseline: "alphabetic" as CanvasTextBaseline, - fillText: (t: string) => { - calls.push(t); - }, - }; - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - -const theme = { - textDark: "#111", - textLight: "#999", - baseFontFull: "13px sans-serif", - cellHorizontalPadding: 8, -}; -const rect = { x: 0, y: 0, width: 120, height: 34 }; -const drawArgs = (ctx: CanvasRenderingContext2D) => - ({ ctx, rect, theme }) as unknown as Parameters[0]; - -describe("createPhoneCell draw", () => { - it("draws an em dash for a read-only empty cell", () => { - const { ctx, calls } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false)); - expect(calls).toEqual(["—"]); - }); - - it("draws nothing for an editable empty cell", () => { - const { ctx, calls } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(null)); - expect(calls).toEqual([]); - }); -}); diff --git a/src/lib/grid/createPhoneCell.tsx b/src/lib/grid/createPhoneCell.tsx deleted file mode 100644 index 1565e1a..0000000 --- a/src/lib/grid/createPhoneCell.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; -import { Text, Theme } from "@radix-ui/themes"; -import PhoneInput, { type Country } from "react-phone-number-input"; -import "react-phone-number-input/style.css"; -import { gridRadixTheme } from "../../theme/radixTheme"; -import { formatPhoneDisplay, isValidPhoneValue, parsePhonePaste } from "../phone/phoneUtils"; -import { - createCustomCell, - drawEmptyDash, - makeCustomCell, - type EditorProps, -} from "./createCustomCell"; - -/** Cell payload. `value` is a canonical E.164 string, or null for an unset cell. */ -export interface PhoneCellData { - kind: string; - value: string | null; - readOnly?: boolean; -} - -export interface PhoneCell { - renderer: CustomRenderer>; - makeCell: (value: string | null, allowOverlay?: boolean) => CustomCell; - validate: (cell: CustomCell) => boolean; -} - -/** - * Build a reusable phone-number cell: a canvas text `draw` (international format) - * plus a `react-phone-number-input` editor. Stores canonical E.164 strings. - * Empty is allowed only when `nullable`; any non-empty value must be valid. - * - * Call once at module scope (not inside a React render): the editor component - * identity is tied to this call, so re-creating it per render would remount it. - */ -export function createPhoneCell({ - kind, - nullable = false, - defaultCountry = "US", -}: { - kind: string; - nullable?: boolean; - defaultCountry?: Country; -}): PhoneCell { - const Editor = ({ value, onChange, isValid }: EditorProps) => { - const handleChange = (next?: string) => { - // PhoneInput emits E.164 (or undefined when cleared). Live-sync to glide; - // glide commits this value on overlay close, gated by validateCell. - onChange({ ...value, value: next ?? null }); - }; - - const invalid = isValid === false; - - return ( - -
-
- -
- {invalid && ( - - Invalid phone number - - )} -
-
- ); - }; - - const renderer = createCustomCell({ - kind, - draw: (args, data) => { - if (data.value == null || data.value === "") { - if (data.readOnly) drawEmptyDash(args); - return; - } - - const { ctx, rect, theme } = args; - ctx.fillStyle = theme.textDark; - ctx.font = theme.baseFontFull; - ctx.textBaseline = "middle"; - ctx.fillText( - formatPhoneDisplay(data.value, defaultCountry), - rect.x + theme.cellHorizontalPadding, - rect.y + rect.height / 2, - ); - }, - editor: Editor, - onPaste: (val, data) => { - const parsed = parsePhonePaste(val, defaultCountry); - - if (parsed === undefined) return undefined; - if (parsed === null && !nullable) return undefined; - - return { ...data, value: parsed }; - }, - }); - - const makeCell = (value: string | null, allowOverlay = true): CustomCell => - makeCustomCell( - { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, - value ?? "", - allowOverlay, - ); - - const validate = (cell: CustomCell): boolean => - isValidPhoneValue(cell.data.value, nullable); - - return { renderer, makeCell, validate }; -} diff --git a/src/lib/grid/createTextCell.test.ts b/src/lib/grid/createTextCell.test.ts deleted file mode 100644 index c976c03..0000000 --- a/src/lib/grid/createTextCell.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { GridCellKind } from "@glideapps/glide-data-grid"; -import { describe, expect, it } from "vitest"; -import { createTextCell } from "./createTextCell"; - -const cell = createTextCell({ kind: "text-test", validation: { required: true, maxLength: 5 } }); -const optional = createTextCell({ kind: "text-optional", validation: { email: true } }); - -describe("createTextCell", () => { - it("makeCell builds a custom cell with kind, value, and string copyData", () => { - const c = cell.makeCell("hi"); - expect(c.kind).toBe(GridCellKind.Custom); - expect(c.data).toEqual({ kind: "text-test", value: "hi" }); - expect(c.copyData).toBe("hi"); - }); - - it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { - const c = cell.makeCell(null); - expect(c.data).toEqual({ kind: "text-test", value: null }); - expect(c.copyData).toBe(""); - }); - - it("the renderer matches its own cell kind and not a foreign one", () => { - expect(cell.renderer.isMatch(cell.makeCell("a"))).toBe(true); - expect(cell.renderer.isMatch(optional.makeCell("a"))).toBe(false); - }); - - it("validate blocks empty when required, allows it when optional", () => { - expect(cell.validate(cell.makeCell(null))).toBe(false); - expect(optional.validate(optional.makeCell(null))).toBe(true); - }); - - it("validate enforces the configured rules (maxLength / email)", () => { - expect(cell.validate(cell.makeCell("hello"))).toBe(true); - expect(cell.validate(cell.makeCell("toolong"))).toBe(false); - expect(optional.validate(optional.makeCell("a@b.co"))).toBe(true); - expect(optional.validate(optional.makeCell("nope"))).toBe(false); - }); - - it("onPaste trims and stores the value, clearing to null when empty", () => { - const onPaste = cell.renderer.onPaste!; - const data = cell.makeCell(null).data; - expect(onPaste(" hey ", data)).toEqual({ kind: "text-test", value: "hey" }); - expect(onPaste(" ", data)).toEqual({ kind: "text-test", value: null }); - }); -}); - -describe("createTextCell.makeCell allowOverlay", () => { - it("disables the overlay when allowOverlay is false", () => { - expect(cell.makeCell("a", false).allowOverlay).toBe(false); - }); - it("allows the overlay by default", () => { - expect(cell.makeCell("a").allowOverlay).toBe(true); - }); - it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { - expect(cell.makeCell(null, false).data.readOnly).toBe(true); - }); - it("leaves an editable cell unflagged so an empty value stays blank", () => { - expect(cell.makeCell("a").data.readOnly).toBeUndefined(); - }); -}); - -function fakeCtx() { - const calls: string[] = []; - const ctx = { - font: "", - fillStyle: "" as string, - textBaseline: "alphabetic" as CanvasTextBaseline, - fillText: (t: string) => { - calls.push(t); - }, - }; - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - -const theme = { - textDark: "#111", - textLight: "#999", - baseFontFull: "13px sans-serif", - cellHorizontalPadding: 8, -}; -const rect = { x: 0, y: 0, width: 120, height: 34 }; -const drawArgs = (ctx: CanvasRenderingContext2D) => - ({ ctx, rect, theme }) as unknown as Parameters[0]; - -describe("createTextCell draw", () => { - it("draws an em dash for a read-only empty cell", () => { - const { ctx, calls } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false)); - expect(calls).toEqual(["—"]); - }); - - it("draws nothing for an editable empty cell", () => { - const { ctx, calls } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell(null)); - expect(calls).toEqual([]); - }); - - it("draws the text for a non-empty cell", () => { - const { ctx, calls } = fakeCtx(); - cell.renderer.draw(drawArgs(ctx), cell.makeCell("hi", false)); - expect(calls).toEqual(["hi"]); - }); -}); diff --git a/src/lib/grid/createTextCell.tsx b/src/lib/grid/createTextCell.tsx deleted file mode 100644 index 15523ee..0000000 --- a/src/lib/grid/createTextCell.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; -import { Text, Theme } from "@radix-ui/themes"; -import { Input } from "../../components/ui/input"; -import { gridRadixTheme } from "../../theme/radixTheme"; -import { validateText, type TextValidationOptions } from "../text/textValidation"; -import { - createCustomCell, - drawEmptyDash, - makeCustomCell, - type EditorProps, -} from "./createCustomCell"; - -/** Cell payload. `value` is the text string, or null for an unset cell. */ -export interface TextCellData { - kind: string; - value: string | null; - readOnly?: boolean; -} - -export interface TextCell { - renderer: CustomRenderer>; - makeCell: (value: string | null, allowOverlay?: boolean) => CustomCell; - validate: (cell: CustomCell) => boolean; -} - -/** - * Build a reusable text cell: a canvas text `draw` plus an `` editor with - * configurable validation. The editor shows the first failing rule's message; - * `validate` gates commit; `onPaste` accepts the trimmed string (commit-gated). - * `validation.required` plays the role of the number/phone cells' `nullable`. - * - * Call once at module scope (not inside a React render): the editor component - * identity is tied to this call, so re-creating it per render would remount it. - */ -export function createTextCell({ - kind, - validation = {}, -}: { - kind: string; - validation?: TextValidationOptions; -}): TextCell { - const Editor = ({ value, onChange, isValid }: EditorProps) => { - const text = value.value ?? ""; - - const result = validateText(text, validation); - const error = result.valid ? null : result.error; - - const invalid = isValid === false; - - return ( - -
- - onChange({ ...value, value: e.target.value === "" ? null : e.target.value }) - } - autoFocus - style={invalid ? { borderColor: "var(--red-9)" } : undefined} - /> - {invalid && error && ( - - {error} - - )} -
-
- ); - }; - - const renderer = createCustomCell({ - kind, - draw: (args, data) => { - if (data.value == null || data.value === "") { - if (data.readOnly) drawEmptyDash(args); - return; - } - - const { ctx, rect, theme } = args; - ctx.fillStyle = theme.textDark; - ctx.font = theme.baseFontFull; - ctx.textBaseline = "middle"; - ctx.fillText(data.value, rect.x + theme.cellHorizontalPadding, rect.y + rect.height / 2); - }, - editor: Editor, - onPaste: (val, data) => { - const trimmed = val.trim(); - return { ...data, value: trimmed === "" ? null : trimmed }; - }, - }); - - const makeCell = (value: string | null, allowOverlay = true): CustomCell => - makeCustomCell( - { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, - value ?? "", - allowOverlay, - ); - - const validate = (cell: CustomCell): boolean => - validateText(cell.data.value ?? "", validation).valid; - - return { renderer, makeCell, validate }; -} diff --git a/src/lib/grid/createDateCell.editor.test.tsx b/src/lib/grid/dateCell.editor.test.tsx similarity index 70% rename from src/lib/grid/createDateCell.editor.test.tsx rename to src/lib/grid/dateCell.editor.test.tsx index 193e415..b991991 100644 --- a/src/lib/grid/createDateCell.editor.test.tsx +++ b/src/lib/grid/dateCell.editor.test.tsx @@ -4,9 +4,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentType } from "react"; import { describe, expect, it, vi } from "vitest"; -import { createDateCell, type DateCellData } from "./createDateCell"; +import { dateCellRenderer, makeDateCell, DATE_CELL_TYPE, type DateCellData } from "./dateCell"; -const date = createDateCell({ kind: "date", nullable: true }); +const nullable = { nullable: true }; +const nullableWithTime = { nullable: true, withTime: true }; interface EditorHandlers { value: CustomCell; @@ -17,7 +18,7 @@ interface EditorHandlers { /** Mount the cell's overlay editor the way glide-data-grid does. */ function mountEditor(cell: CustomCell, handlers: Partial = {}) { - const Editor = date.renderer.provideEditor?.(cell) as unknown as ComponentType; + const Editor = dateCellRenderer.provideEditor?.(cell) as unknown as ComponentType; const props: EditorHandlers = { value: cell, @@ -37,7 +38,7 @@ const utcIso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, describe("date cell editor", () => { it("shows the cell's value in the masked input", () => { - mountEditor(date.makeCell(utcIso(2000, 1, 1), false)); + mountEditor(makeDateCell(utcIso(2000, 1, 1), nullable)); expect(screen.getByLabelText("Date")).toHaveValue("01/01/2000"); }); @@ -47,7 +48,7 @@ describe("date cell editor", () => { const onFinishedEditing = vi.fn(); const onChange = vi.fn(); - mountEditor(date.makeCell(null, false), { onFinishedEditing, onChange }); + mountEditor(makeDateCell(null, nullable), { onFinishedEditing, onChange }); await user.type(screen.getByLabelText("Date"), "06202026"); expect(onFinishedEditing).not.toHaveBeenCalled(); @@ -57,9 +58,9 @@ describe("date cell editor", () => { expect(onFinishedEditing).toHaveBeenCalledOnce(); expect(onFinishedEditing.mock.calls[0][0].data).toEqual({ - kind: "date", + kind: DATE_CELL_TYPE, value: utcIso(2026, 6, 20), - withTime: false, + options: nullable, }); }); @@ -67,36 +68,35 @@ describe("date cell editor", () => { const user = userEvent.setup(); const onFinishedEditing = vi.fn(); - mountEditor(date.makeCell(null, false), { onFinishedEditing }); + mountEditor(makeDateCell(null, nullable), { onFinishedEditing }); await user.type(screen.getByLabelText("Date"), "06202026"); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onFinishedEditing).toHaveBeenCalledExactlyOnceWith(undefined); }); - it("offers a time input for a withTime cell", () => { - mountEditor(date.makeCell(new Date(2026, 5, 20, 13, 30).toISOString(), true)); + it("offers a time input when the column's options say withTime", () => { + mountEditor(makeDateCell(new Date(2026, 5, 20, 13, 30).toISOString(), nullableWithTime)); expect(screen.getByLabelText("Time")).toHaveValue("13:30"); }); - it("commits null from an empty value because the cell is nullable", async () => { + it("commits null from an empty value because the column is nullable", async () => { const user = userEvent.setup(); const onFinishedEditing = vi.fn(); - mountEditor(date.makeCell(utcIso(2000, 1, 1), false), { onFinishedEditing }); + mountEditor(makeDateCell(utcIso(2000, 1, 1), nullable), { onFinishedEditing }); await user.clear(screen.getByLabelText("Date")); await user.click(screen.getByRole("button", { name: "OK" })); expect(onFinishedEditing.mock.calls[0][0].data.value).toBeNull(); }); - it("blocks OK on an empty value when the cell is not nullable", () => { - const required = createDateCell({ kind: "required-date", nullable: false }); - const cell = required.makeCell(null, false); - const Editor = required.renderer.provideEditor?.( - cell, - ) as unknown as ComponentType>; + it("blocks OK on an empty value when the column is not nullable", () => { + const cell = makeDateCell(null, {}); + const Editor = dateCellRenderer.provideEditor?.(cell) as unknown as ComponentType< + Record + >; render( diff --git a/src/lib/grid/dateCell.test.ts b/src/lib/grid/dateCell.test.ts new file mode 100644 index 0000000..5fa00b1 --- /dev/null +++ b/src/lib/grid/dateCell.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { + dateCellDef, + dateCellRenderer, + makeDateCell, + DATE_CELL_TYPE, + type DateCellData, +} from "./dateCell"; + +const dateOnly = { nullable: true }; +const withTime = { nullable: true, withTime: true }; + +const validate = dateCellDef.validate!; + +describe("dg:date makeDateCell", () => { + it("stores the ISO value, the column's options, and ISO copyData", () => { + const cell = makeDateCell("2026-06-20T00:00:00Z", withTime); + expect(cell.data.kind).toBe(DATE_CELL_TYPE); + expect(cell.data.value).toBe("2026-06-20T00:00:00Z"); + expect(cell.data.options.withTime).toBe(true); + expect(cell.copyData).toBe("2026-06-20T00:00:00Z"); + }); + + it("renders a null value as empty copyData", () => { + const cell = makeDateCell(null, dateOnly); + expect(cell.data.value).toBeNull(); + expect(cell.data.options.withTime).toBeUndefined(); + expect(cell.copyData).toBe(""); + }); + + it("disables the overlay when allowOverlay is false", () => { + expect(makeDateCell("2026-06-20T00:00:00Z", dateOnly, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(makeDateCell("2026-06-20T00:00:00Z", dateOnly).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(makeDateCell(null, dateOnly, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(makeDateCell(null, dateOnly).data.readOnly).toBeUndefined(); + }); +}); + +describe("dg:date validate", () => { + it("accepts a valid ISO value", () => { + expect(validate(makeDateCell("2026-06-20T00:00:00Z", dateOnly) as never)).toBe(true); + }); + + it("accepts empty when the column is nullable", () => { + expect(validate(makeDateCell(null, dateOnly) as never)).toBe(true); + }); + + it("rejects empty when the column is not nullable", () => { + expect(validate(makeDateCell(null, {}) as never)).toBe(false); + }); + + it("rejects a malformed non-empty value", () => { + expect(validate(makeDateCell("garbage", dateOnly) as never)).toBe(false); + }); +}); + +describe("dg:date renderer", () => { + it("matches cells of its kind", () => { + expect(dateCellRenderer.isMatch(makeDateCell("2026-06-20T00:00:00Z", dateOnly))).toBe(true); + }); +}); + +describe("dg:date onPaste", () => { + const baseData: DateCellData = { kind: DATE_CELL_TYPE, value: null, options: dateOnly }; + + it("pins a pasted ISO date to UTC midnight for a date-only cell", () => { + expect(dateCellRenderer.onPaste?.("2026-06-20", baseData)).toEqual({ + ...baseData, + value: "2026-06-20T00:00:00.000Z", + }); + }); + + it("rejects an unparseable paste (returns undefined)", () => { + expect(dateCellRenderer.onPaste?.("garbage", baseData)).toBeUndefined(); + }); + + it("accepts an empty paste as null because the column is nullable", () => { + expect(dateCellRenderer.onPaste?.(" ", baseData)).toEqual({ ...baseData, value: null }); + }); + + it("rejects an empty paste when the column is not nullable", () => { + const strict: DateCellData = { kind: DATE_CELL_TYPE, value: null, options: {} }; + expect(dateCellRenderer.onPaste?.(" ", strict)).toBeUndefined(); + }); + + it("preserves the time component when the column is withTime", () => { + const timed: DateCellData = { kind: DATE_CELL_TYPE, value: null, options: withTime }; + expect(dateCellRenderer.onPaste?.("2026-06-20T14:30:00Z", timed)).toEqual({ + ...timed, + value: "2026-06-20T14:30:00.000Z", + }); + }); +}); + +describe("dg:date def", () => { + it("reads withTime off the column's options, not off a ctx field", () => { + const cell = dateCellDef.make("2026-06-20T00:00:00Z", { + editable: true, + options: withTime, + }); + expect((cell as unknown as { data: DateCellData }).data.options.withTime).toBe(true); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("dg:date draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + dateCellRenderer.draw(drawArgs(ctx), makeDateCell(null, dateOnly, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + dateCellRenderer.draw(drawArgs(ctx), makeDateCell(null, dateOnly)); + expect(calls).toEqual([]); + }); + + it("draws the localized date for a non-empty cell", () => { + const { ctx, calls } = fakeCtx(); + dateCellRenderer.draw(drawArgs(ctx), makeDateCell("2026-06-20T00:00:00Z", dateOnly, false)); + expect(calls).toEqual(["6/20/2026"]); + }); +}); diff --git a/src/lib/grid/dateCell.tsx b/src/lib/grid/dateCell.tsx new file mode 100644 index 0000000..05550cf --- /dev/null +++ b/src/lib/grid/dateCell.tsx @@ -0,0 +1,84 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { formatDateDisplay, isValidDateValue, parseDatePaste } from "../date/dateUtils"; +import type { CellTypeDef } from "./cellRegistry"; +import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell"; +import { DateCellEditor } from "./dateCellEditor"; + +/** The `ColumnDef.type` string that selects this cell. */ +export const DATE_CELL_TYPE = "dg:date"; + +/** + * Per-column settings for a `dg:date` column, written on the column's `options`. + * + * `withTime` used to sit on `ColumnDef` itself, which made one cell type's + * setting a field every other column carried and ignored. + */ +export interface DateCellOptions { + /** Allow an empty cell. */ + nullable?: boolean; + + /** Keep the time part instead of a floating calendar date. */ + withTime?: boolean; +} + +/** Cell payload. `value` is a canonical UTC ISO string, or null for an unset cell. */ +export interface DateCellData { + kind: string; + value: string | null; + readOnly?: boolean; + options: DateCellOptions; +} + +export const dateCellRenderer: CustomRenderer> = + createCustomCell({ + kind: DATE_CELL_TYPE, + draw: (args, data) => { + if (data.value == null || data.value === "") { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText( + formatDateDisplay(data.value, data.options.withTime ?? false), + rect.x + theme.cellHorizontalPadding, + rect.y + rect.height / 2, + ); + }, + editor: DateCellEditor, + onPaste: (val, data) => { + const parsed = parseDatePaste(val, data.options.withTime ?? false); + + if (parsed === undefined) return undefined; + if (parsed === null && data.options.nullable !== true) return undefined; + + return { ...data, value: parsed }; + }, + }); + +export function makeDateCell( + value: string | null, + options: DateCellOptions, + allowOverlay = true, +): CustomCell { + return makeCustomCell( + { kind: DATE_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) }, + value ?? "", + allowOverlay, + ); +} + +export const dateCellDef: CellTypeDef = { + type: DATE_CELL_TYPE, + kind: DATE_CELL_TYPE, + renderer: dateCellRenderer as unknown as CustomRenderer, + make: (raw, ctx) => + makeDateCell(raw == null ? null : String(raw), ctx.options as DateCellOptions, ctx.editable), + validate: (cell) => { + const data = cell.data as unknown as DateCellData; + return isValidDateValue(data.value, data.options.nullable ?? false); + }, +}; diff --git a/src/lib/grid/dateCellEditor.tsx b/src/lib/grid/dateCellEditor.tsx new file mode 100644 index 0000000..b4dd9d8 --- /dev/null +++ b/src/lib/grid/dateCellEditor.tsx @@ -0,0 +1,25 @@ +import { Theme } from "@radix-ui/themes"; +import { DatePicker } from "../../components/ui/datePicker"; +import { gridRadixTheme } from "../../theme/radixTheme"; +import type { EditorProps } from "./createCustomCell"; +import type { DateCellData } from "./dateCell"; + +/** + * The overlay editor for a `dg:date` cell. + * + * Its own file so `dateCell.tsx` exports no component. + * + * The editor HOLDS the edit: nothing reaches the cell until the user presses + * OK, and Cancel or Escape discards it. A click on a day is not a commit. + */ +export const DateCellEditor = ({ value, onFinishedEditing }: EditorProps) => ( + + onFinishedEditing({ ...value, value: next })} + onCancel={() => onFinishedEditing(undefined)} + /> + +); diff --git a/src/lib/grid/enumCell.test.ts b/src/lib/grid/enumCell.test.ts new file mode 100644 index 0000000..f83b439 --- /dev/null +++ b/src/lib/grid/enumCell.test.ts @@ -0,0 +1,115 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { + enumCellDef, + enumCellRenderer, + enumColorOf, + makeEnumCell, + ENUM_CELL_TYPE, +} from "./enumCell"; +import { radixColorByIndex } from "./radixBadgePalette"; + +const choices = [ + { value: 1, label: "One" }, + { value: 2, label: "Two", color: "red" as const }, +]; + +const strict = { choices }; +const nullable = { choices, nullable: true }; + +describe("dg:enum cell", () => { + it("makeEnumCell builds a custom cell with kind, value, options, and label copyData", () => { + const c = makeEnumCell(1, strict); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: ENUM_CELL_TYPE, value: 1, options: strict }); + expect(c.copyData).toBe("One"); + }); + + it("makeEnumCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = makeEnumCell(null, nullable); + expect(c.data).toEqual({ kind: ENUM_CELL_TYPE, value: null, options: nullable }); + expect(c.copyData).toBe(""); + }); + + it("one renderer serves every enum column, whatever its choices", () => { + expect(enumCellRenderer.isMatch(makeEnumCell(1, strict))).toBe(true); + expect(enumCellRenderer.isMatch(makeEnumCell(1, nullable))).toBe(true); + }); +}); + +describe("enumColorOf", () => { + it("uses the index default when no override is given", () => { + expect(enumColorOf(choices, 1)).toBe(radixColorByIndex(1)); + }); + + it("prefers a per-choice override over the index default", () => { + expect(enumColorOf(choices, 2)).toBe("red"); + }); + + it("falls back to the index default for a value no choice names", () => { + expect(enumColorOf(choices, 9)).toBe(radixColorByIndex(9)); + }); +}); + +describe("dg:enum allowOverlay", () => { + it("disables the overlay (no Select) when allowOverlay is false", () => { + expect(makeEnumCell(1, strict, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(makeEnumCell(1, strict).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(makeEnumCell(null, nullable, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(makeEnumCell(1, strict).data.readOnly).toBeUndefined(); + }); +}); + +describe("dg:enum def", () => { + it("make() coerces a raw row value and honours ctx.editable", () => { + const cell = enumCellDef.make("2", { editable: false, options: strict }); + expect(cell.allowOverlay).toBe(false); + expect((cell as unknown as { data: { value: number } }).data.value).toBe(2); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 160, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("dg:enum draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + enumCellRenderer.draw(drawArgs(ctx), makeEnumCell(null, nullable, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + enumCellRenderer.draw(drawArgs(ctx), makeEnumCell(null, nullable)); + expect(calls).toEqual([]); + }); +}); diff --git a/src/lib/grid/enumCell.tsx b/src/lib/grid/enumCell.tsx new file mode 100644 index 0000000..fe06d99 --- /dev/null +++ b/src/lib/grid/enumCell.tsx @@ -0,0 +1,58 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import type { CellTypeDef } from "./cellRegistry"; +import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell"; +import { resolveEnumOption, type EnumCellOptions } from "./enumChoices"; +import { EnumCellEditor } from "./enumCellEditor"; +import { drawSoftBadge } from "./softBadge"; + +/** The `ColumnDef.type` string that selects this cell. */ +export const ENUM_CELL_TYPE = "dg:enum"; + +export { enumColorOf, resolveEnumOption } from "./enumChoices"; +export type { EnumCellOptions, EnumOption } from "./enumChoices"; + +/** Cell payload. `value` is the enum, or null for an unset cell. */ +export interface EnumCellData { + kind: string; + value: number | null; + readOnly?: boolean; + options: EnumCellOptions; +} + +export const enumCellRenderer: CustomRenderer> = + createCustomCell({ + kind: ENUM_CELL_TYPE, + draw: (args, data) => { + if (data.value == null) { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { label, color } = resolveEnumOption(data.options.choices, data.value); + + // Pass the grid's base font so drawSoftBadge measures with the same font it + // renders (glide's FullTheme exposes the composed font as `baseFontFull`). + drawSoftBadge(args.ctx, args.rect, color, label, args.theme.baseFontFull); + }, + editor: EnumCellEditor, + }); + +export function makeEnumCell( + value: number | null, + options: EnumCellOptions, + allowOverlay = true, +): CustomCell { + return makeCustomCell( + { kind: ENUM_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) }, + value == null ? "" : resolveEnumOption(options.choices, value).label, + allowOverlay, + ); +} + +export const enumCellDef: CellTypeDef = { + type: ENUM_CELL_TYPE, + kind: ENUM_CELL_TYPE, + renderer: enumCellRenderer as unknown as CustomRenderer, + make: (raw, ctx) => + makeEnumCell(raw == null ? null : Number(raw), ctx.options as EnumCellOptions, ctx.editable), +}; diff --git a/src/lib/grid/enumCellEditor.tsx b/src/lib/grid/enumCellEditor.tsx new file mode 100644 index 0000000..e601bc1 --- /dev/null +++ b/src/lib/grid/enumCellEditor.tsx @@ -0,0 +1,65 @@ +import { Select, Text, Theme } from "@radix-ui/themes"; +import { gridRadixTheme } from "../../theme/radixTheme"; +import type { EditorProps } from "./createCustomCell"; +import type { EnumCellData } from "./enumCell"; +import { enumColorOf } from "./enumChoices"; +import { SoftBadge } from "./softBadgeView"; + +/** + * Sentinel item value for the "clear" option in a nullable Select. Radix + * reserves the empty string for items, so a non-empty sentinel is required. + */ +const NONE_VALUE = "__none__"; + +/** + * The overlay editor for a `dg:enum` cell. + * + * Its own file so `enumCell.tsx` exports no component. The choices come off + * `value.options`, so one editor serves every enum column — where each enum + * column used to need a `kind` of its own for glide's `isMatch` to route by. + */ +export const EnumCellEditor = ({ value, onFinishedEditing }: EditorProps) => { + const { choices, nullable = false } = value.options; + + const handleValueChange = (next: string) => { + onFinishedEditing({ ...value, value: next === NONE_VALUE ? null : Number(next) }); + }; + + return ( + + + + {/* + The dropdown is PORTALLED out of the cell overlay, so glide sees a + press on an option as a click outside the editor. Its outside-click + handler runs on `pointerdown`, in the capture phase, which is before + the Select decides anything on `pointerup` — so the overlay closed + and the choice was thrown away. Only the keyboard worked, because it + never presses a pointer. + + `click-outside-ignore` is glide's own escape hatch for exactly this: + it walks up from whatever was pressed and stops if it finds the + class. See `internal/click-outside-container`. + */} + + {nullable && ( + + None + + )} + {choices.map((choice) => ( + + + + ))} + + + + ); +}; diff --git a/src/lib/grid/enumChoices.ts b/src/lib/grid/enumChoices.ts new file mode 100644 index 0000000..7ac19e2 --- /dev/null +++ b/src/lib/grid/enumChoices.ts @@ -0,0 +1,63 @@ +import { radixColorByIndex, type RadixColor } from "./radixBadgePalette"; + +/** + * What a `dg:enum` column can hold, and how each value is coloured. + * + * Its own module because three places need it and two of them draw: the cell, + * its editor, and the group header. Keeping it here is what stops + * `enumCell.tsx` and `enumCellEditor.tsx` importing each other's VALUES, which + * is a cycle a bundler is free to order the wrong way round. + */ + +/** One selectable enum value: numeric value, label, and an optional color override. */ +export interface EnumOption { + value: number; + label: string; + color?: RadixColor; +} + +/** Per-column settings for a `dg:enum` column, written on the column's `options`. */ +export interface EnumCellOptions { + /** + * Every value this column can hold. Colors default to + * `radixColorByIndex(value)` and are overridable per choice. + */ + choices: EnumOption[]; + + /** Allow an empty cell, which the editor offers as a "None" item. */ + nullable?: boolean; +} + +interface ResolvedOption { + label: string; + color: RadixColor; +} + +/** + * The label and colour one value draws with. + * + * A value no choice names is not an error: it draws as itself, in the colour + * its number indexes, which is more use than a blank cell when a server adds a + * value before the client hears about it. + */ +export function resolveEnumOption(choices: readonly EnumOption[], value: number): ResolvedOption { + const found = choices.find((c) => c.value === value); + + if (found === undefined) { + return { label: String(value), color: radixColorByIndex(value) }; + } + + return { label: found.label, color: found.color ?? radixColorByIndex(found.value) }; +} + +/** + * The Radix scale one enum value draws in: the choice's own override, or the + * palette entry its value indexes. + * + * The group header reads it too. Grouping by an enum column makes the header's + * name take the colour of the badges under it, and both sides have to answer + * from the same place — see `groupColumnColor`. + */ +export function enumColorOf(choices: readonly EnumOption[], value: number): RadixColor { + return resolveEnumOption(choices, value).color; +} diff --git a/src/lib/grid/enumEditorClickOutside.test.tsx b/src/lib/grid/enumEditorClickOutside.test.tsx index 638cc6a..3a1b4e1 100644 --- a/src/lib/grid/enumEditorClickOutside.test.tsx +++ b/src/lib/grid/enumEditorClickOutside.test.tsx @@ -1,15 +1,14 @@ import { render, screen } from "@testing-library/react"; import type { ComponentType } from "react"; import { describe, expect, it, vi } from "vitest"; -import { createEnumCell } from "./createEnumCell"; +import { enumCellRenderer, makeEnumCell } from "./enumCell"; -const cell = createEnumCell({ - kind: "enum-click-outside-test", - options: [ +const options = { + choices: [ { value: 1, label: "One" }, { value: 2, label: "Two" }, ], -}); +}; /** * Glide's own outside-click test, copied from @@ -35,7 +34,7 @@ function glideWouldCloseTheEditor(target: Element | null): boolean { function renderEditor() { const onFinishedEditing = vi.fn(); - const provided = cell.renderer.provideEditor?.(cell.makeCell(1)); + const provided = enumCellRenderer.provideEditor?.(makeEnumCell(1, options)); const Editor = ( typeof provided === "function" ? provided : (provided as { editor: unknown })?.editor @@ -43,7 +42,7 @@ function renderEditor() { render( {}} onFinishedEditing={onFinishedEditing} forceEditMode diff --git a/src/lib/grid/glideCells.test.ts b/src/lib/grid/glideCells.test.ts new file mode 100644 index 0000000..92c3c5e --- /dev/null +++ b/src/lib/grid/glideCells.test.ts @@ -0,0 +1,128 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { + glideBooleanCellDef, + glideBubbleCellDef, + glideDrilldownCellDef, + glideImageCellDef, + glideMarkdownCellDef, + glideNumberCellDef, + glideTextCellDef, + glideUriCellDef, +} from "./glideCells"; + +const editable = { editable: true, options: undefined }; +const readOnly = { editable: false, options: undefined }; + +describe("glide text", () => { + it("builds a plain glide TextCell", () => { + expect(glideTextCellDef.make("hi", editable)).toEqual({ + kind: GridCellKind.Text, + data: "hi", + displayData: "hi", + allowOverlay: true, + readonly: false, + }); + }); + + it("reads a null row value as an empty string, never as the text 'null'", () => { + expect(glideTextCellDef.make(null, editable)).toMatchObject({ data: "", displayData: "" }); + }); + + it("closes the overlay for a read-only column", () => { + expect(glideTextCellDef.make("hi", readOnly)).toMatchObject({ + allowOverlay: false, + readonly: true, + }); + }); +}); + +describe("glide number", () => { + it("coerces the row value and displays it", () => { + expect(glideNumberCellDef.make("42", editable)).toMatchObject({ + kind: GridCellKind.Number, + data: 42, + displayData: "42", + }); + }); + + it("reads null and empty string as no number at all", () => { + expect(glideNumberCellDef.make(null, editable)).toMatchObject({ + data: undefined, + displayData: "", + }); + expect(glideNumberCellDef.make("", editable)).toMatchObject({ data: undefined }); + }); +}); + +describe("glide boolean", () => { + it("never opens an overlay, because glide's checkbox has none", () => { + expect(glideBooleanCellDef.make(true, editable)).toEqual({ + kind: GridCellKind.Boolean, + data: true, + allowOverlay: false, + readonly: false, + }); + }); + + it("keeps a null row value null rather than reading it as false", () => { + expect(glideBooleanCellDef.make(null, editable)).toMatchObject({ data: null }); + }); + + it("blocks the toggle on a read-only column", () => { + expect(glideBooleanCellDef.make(true, readOnly)).toMatchObject({ readonly: true }); + }); +}); + +describe("glide uri and markdown", () => { + it("builds a Uri cell", () => { + expect(glideUriCellDef.make("https://example.com", editable)).toMatchObject({ + kind: GridCellKind.Uri, + data: "https://example.com", + }); + }); + + it("builds a Markdown cell", () => { + expect(glideMarkdownCellDef.make("# hi", editable)).toMatchObject({ + kind: GridCellKind.Markdown, + data: "# hi", + }); + }); +}); + +describe("glide image, bubble, and drilldown", () => { + it("takes a list straight from the row", () => { + expect(glideImageCellDef.make(["a.png", "b.png"], editable)).toMatchObject({ + kind: GridCellKind.Image, + data: ["a.png", "b.png"], + }); + }); + + it("wraps a lone value, so a one-string row field needs no mapping", () => { + expect(glideImageCellDef.make("a.png", editable)).toMatchObject({ data: ["a.png"] }); + expect(glideBubbleCellDef.make("one", editable)).toMatchObject({ data: ["one"] }); + }); + + it("reads a null row value as an empty list", () => { + expect(glideImageCellDef.make(null, editable)).toMatchObject({ data: [] }); + }); + + it("drops nulls inside a list rather than drawing 'null'", () => { + expect(glideBubbleCellDef.make(["a", null, "b"], editable)).toMatchObject({ + data: ["a", "b"], + }); + }); + + it("shapes drilldown items the way glide wants them", () => { + expect(glideDrilldownCellDef.make(["one", "two"], editable)).toMatchObject({ + kind: GridCellKind.Drilldown, + data: [{ text: "one" }, { text: "two" }], + }); + }); + + it("never edits, whatever the column says", () => { + for (const def of [glideImageCellDef, glideBubbleCellDef, glideDrilldownCellDef]) { + expect(def.make("x", editable).allowOverlay).toBe(false); + } + }); +}); diff --git a/src/lib/grid/glideCells.ts b/src/lib/grid/glideCells.ts new file mode 100644 index 0000000..fdaaeb3 --- /dev/null +++ b/src/lib/grid/glideCells.ts @@ -0,0 +1,140 @@ +import { GridCellKind, type GridCell } from "@glideapps/glide-data-grid"; +import type { CellTypeDef } from "./cellRegistry"; + +/** + * Glide's own cell kinds, reachable from a `ColumnDef` by glide's own name. + * + * A column that writes `type: "text"` gets a plain glide `TextCell` — no + * renderer of ours, no editor of ours, nothing to register. That is the point: + * the engine's five `dg:` cells are additions to glide's set, never a + * replacement for it, and a host that wants glide's boolean should not have to + * rebuild it as a custom cell first. + * + * The names here are glide's, unprefixed, which is exactly why every cell this + * package DRAWS is prefixed `dg:`. The two sets never collide, and glide may add + * kinds to its own set without ever colliding with ours. + * + * Absent are glide's internal kinds — `custom`, `loading`, `protected`, and + * `row-id`. They describe a cell's STATE rather than a column's data, and the + * engine already answers `loading` itself for a row it has not fetched. + */ + +/** + * Read a value that may be one item or many as a list. + * + * `image`, `bubble`, and `drilldown` are the only cells whose row field is a + * list, and a server field like `avatarUrl` is one string. Wrapping a lone + * value here is what stops every such host mapping `[row.avatarUrl]` by hand on + * every row. + */ +function asStringArray(raw: unknown): string[] { + if (raw == null) return []; + if (Array.isArray(raw)) return raw.filter((v) => v != null).map((v) => String(v)); + + return [String(raw)]; +} + +function asText(raw: unknown): string { + return raw == null ? "" : String(raw); +} + +export const glideTextCellDef: CellTypeDef = { + type: "text", + make: (raw, ctx): GridCell => ({ + kind: GridCellKind.Text, + data: asText(raw), + displayData: asText(raw), + allowOverlay: ctx.editable, + readonly: !ctx.editable, + }), +}; + +export const glideNumberCellDef: CellTypeDef = { + type: "number", + make: (raw, ctx): GridCell => { + const value = raw == null || raw === "" ? undefined : Number(raw); + + return { + kind: GridCellKind.Number, + data: value, + displayData: value === undefined || Number.isNaN(value) ? "" : String(value), + allowOverlay: ctx.editable, + readonly: !ctx.editable, + }; + }, +}; + +export const glideBooleanCellDef: CellTypeDef = { + type: "boolean", + make: (raw, ctx): GridCell => ({ + kind: GridCellKind.Boolean, + data: raw == null ? null : Boolean(raw), + + // Glide's boolean is a checkbox drawn in the cell, toggled by a click on + // the cell itself. It has no overlay editor at all, so `allowOverlay` is + // false whether or not the column is editable; `readonly` is what decides + // whether the click does anything. + allowOverlay: false, + readonly: !ctx.editable, + }), +}; + +export const glideUriCellDef: CellTypeDef = { + type: "uri", + make: (raw, ctx): GridCell => ({ + kind: GridCellKind.Uri, + data: asText(raw), + allowOverlay: ctx.editable, + readonly: !ctx.editable, + }), +}; + +export const glideMarkdownCellDef: CellTypeDef = { + type: "markdown", + make: (raw, ctx): GridCell => ({ + kind: GridCellKind.Markdown, + data: asText(raw), + allowOverlay: ctx.editable, + readonly: !ctx.editable, + }), +}; + +export const glideImageCellDef: CellTypeDef = { + type: "image", + make: (raw): GridCell => ({ + kind: GridCellKind.Image, + data: asStringArray(raw), + allowOverlay: false, + readonly: true, + }), +}; + +export const glideBubbleCellDef: CellTypeDef = { + type: "bubble", + make: (raw): GridCell => ({ + kind: GridCellKind.Bubble, + data: asStringArray(raw), + allowOverlay: false, + }), +}; + +export const glideDrilldownCellDef: CellTypeDef = { + type: "drilldown", + make: (raw): GridCell => ({ + kind: GridCellKind.Drilldown, + data: asStringArray(raw).map((text) => ({ text })), + allowOverlay: false, + }), +}; + +/** Every glide kind a column may name, in one list. */ +export const glideCellDefs: CellTypeDef[] = [ + glideTextCellDef, + glideNumberCellDef, + glideBooleanCellDef, + glideUriCellDef, + glideMarkdownCellDef, + glideImageCellDef, + glideBubbleCellDef, + glideDrilldownCellDef, +]; diff --git a/src/lib/grid/numberCell.test.ts b/src/lib/grid/numberCell.test.ts new file mode 100644 index 0000000..39a0a8c --- /dev/null +++ b/src/lib/grid/numberCell.test.ts @@ -0,0 +1,137 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { makeNumberCell, numberCellDef, numberCellRenderer, NUMBER_CELL_TYPE } from "./numberCell"; + +const bounded = { min: 0, max: 100 }; +const nullable = { min: 0, max: 100, nullable: true }; + +const validate = numberCellDef.validate!; + +describe("dg:number cell", () => { + it("makeNumberCell builds a custom cell with kind, value, options, and string copyData", () => { + const c = makeNumberCell(42.5, bounded); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: NUMBER_CELL_TYPE, value: 42.5, options: bounded }); + expect(c.copyData).toBe("42.5"); + }); + + it("makeNumberCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = makeNumberCell(null, bounded); + expect(c.data).toEqual({ kind: NUMBER_CELL_TYPE, value: null, options: bounded }); + expect(c.copyData).toBe(""); + }); + + it("one renderer serves every number column, whatever its options", () => { + expect(numberCellRenderer.isMatch(makeNumberCell(1, bounded))).toBe(true); + expect(numberCellRenderer.isMatch(makeNumberCell(1, nullable))).toBe(true); + }); + + it("validate blocks empty when not nullable, allows it when nullable", () => { + expect(validate(makeNumberCell(null, bounded) as never)).toBe(false); + expect(validate(makeNumberCell(null, nullable) as never)).toBe(true); + }); + + it("validate accepts in-range and rejects out-of-range values", () => { + expect(validate(makeNumberCell(50, bounded) as never)).toBe(true); + expect(validate(makeNumberCell(500, bounded) as never)).toBe(false); + }); + + it("onPaste parses a separated string and rejects junk / out-of-range", () => { + const onPaste = numberCellRenderer.onPaste!; + const data = makeNumberCell(null, bounded).data; + expect(onPaste("50", data)).toEqual({ ...data, value: 50 }); + expect(onPaste("garbage", data)).toBeUndefined(); + expect(onPaste("500", data)).toBeUndefined(); + }); + + it("onPaste rejects empty when not nullable but clears when nullable", () => { + const onPaste = numberCellRenderer.onPaste!; + expect(onPaste(" ", makeNumberCell(1, bounded).data)).toBeUndefined(); + + const nullableData = makeNumberCell(1, nullable).data; + expect(onPaste(" ", nullableData)).toEqual({ ...nullableData, value: null }); + }); +}); + +describe("dg:number allowOverlay", () => { + it("disables the overlay when allowOverlay is false", () => { + expect(makeNumberCell(1, bounded, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(makeNumberCell(1, bounded).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(makeNumberCell(1, bounded, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(makeNumberCell(1, bounded).data.readOnly).toBeUndefined(); + }); +}); + +describe("dg:number def", () => { + it("make() coerces a raw row value and honours ctx.editable", () => { + const cell = numberCellDef.make("42.5", { editable: false, options: bounded }); + expect(cell.allowOverlay).toBe(false); + expect((cell as unknown as { data: { value: number } }).data.value).toBe(42.5); + }); + + it("make() reads a null row value as an empty cell", () => { + const cell = numberCellDef.make(null, { editable: true, options: bounded }); + expect((cell as unknown as { data: { value: null } }).data.value).toBeNull(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("dg:number draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls, raw } = fakeCtx(); + numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(null, bounded, false)); + expect(calls).toEqual(["—"]); + expect(raw.fillStyle).toBe("#999"); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(null, bounded)); + expect(calls).toEqual([]); + }); + + it("draws the formatted value for a non-empty cell", () => { + const { ctx, calls, raw } = fakeCtx(); + numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(42.5, bounded, false)); + expect(calls).toEqual(["42.5"]); + expect(raw.fillStyle).toBe("#111"); + }); + + it("draws each column's own format, from the cell's own options", () => { + const { ctx, calls } = fakeCtx(); + const currency = { format: "currency" as const, currency: "USD", decimalScale: 2 }; + numberCellRenderer.draw(drawArgs(ctx), makeNumberCell(1234.5, currency, false)); + expect(calls[0]).toContain("1,234.50"); + }); +}); diff --git a/src/lib/grid/numberCell.tsx b/src/lib/grid/numberCell.tsx new file mode 100644 index 0000000..c23203a --- /dev/null +++ b/src/lib/grid/numberCell.tsx @@ -0,0 +1,91 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { + formatNumberDisplay, + isValueInRange, + parseNumberPaste, + type NumberFormatOptions, +} from "../number/numberUtils"; +import type { CellTypeDef } from "./cellRegistry"; +import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell"; +import { NumberCellEditor } from "./numberCellEditor"; + +/** The `ColumnDef.type` string that selects this cell. */ +export const NUMBER_CELL_TYPE = "dg:number"; + +/** + * Per-column settings for a `dg:number` column, written on the column's + * `options`. Everything the display formatter takes, plus whether an empty cell + * is allowed. + */ +export interface NumberCellOptions extends NumberFormatOptions { + nullable?: boolean; +} + +/** Cell payload. `value` is a JS number, or null for an unset cell. */ +export interface NumberCellData { + kind: string; + value: number | null; + readOnly?: boolean; + options: NumberCellOptions; +} + +export const numberCellRenderer: CustomRenderer> = + createCustomCell({ + kind: NUMBER_CELL_TYPE, + draw: (args, data) => { + if (data.value == null) { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText( + formatNumberDisplay(data.value, data.options), + rect.x + theme.cellHorizontalPadding, + rect.y + rect.height / 2, + ); + }, + editor: NumberCellEditor, + onPaste: (val, data) => { + const { min, max, nullable = false } = data.options; + const parsed = parseNumberPaste(val, { min, max, nullable }); + + if (parsed === undefined) return undefined; + + return { ...data, value: parsed }; + }, + }); + +export function makeNumberCell( + value: number | null, + options: NumberCellOptions, + allowOverlay = true, +): CustomCell { + return makeCustomCell( + { kind: NUMBER_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) }, + value == null ? "" : String(value), + allowOverlay, + ); +} + +export const numberCellDef: CellTypeDef = { + type: NUMBER_CELL_TYPE, + kind: NUMBER_CELL_TYPE, + renderer: numberCellRenderer as unknown as CustomRenderer, + make: (raw, ctx) => + makeNumberCell( + raw == null ? null : Number(raw), + ctx.options as NumberCellOptions, + ctx.editable, + ), + validate: (cell) => { + const data = cell.data as unknown as NumberCellData; + + if (data.value == null) return data.options.nullable === true; + + return isValueInRange(data.value, data.options.min, data.options.max); + }, +}; diff --git a/src/lib/grid/numberCellEditor.tsx b/src/lib/grid/numberCellEditor.tsx new file mode 100644 index 0000000..0fa1527 --- /dev/null +++ b/src/lib/grid/numberCellEditor.tsx @@ -0,0 +1,24 @@ +import { Theme } from "@radix-ui/themes"; +import { NumericInput } from "../../components/ui/numericInput"; +import { gridRadixTheme } from "../../theme/radixTheme"; +import type { EditorProps } from "./createCustomCell"; +import type { NumberCellData } from "./numberCell"; + +/** + * The overlay editor for a `dg:number` cell. + * + * Its own file so `numberCell.tsx` exports no component. The format and the + * range come off `value.options`, so one editor serves every number column. + */ +export const NumberCellEditor = ({ value, onChange }: EditorProps) => ( + +
+ onChange({ ...value, value: next })} + autoFocus + /> +
+
+); diff --git a/src/lib/grid/phoneCell.test.ts b/src/lib/grid/phoneCell.test.ts new file mode 100644 index 0000000..cf839e6 --- /dev/null +++ b/src/lib/grid/phoneCell.test.ts @@ -0,0 +1,114 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { makePhoneCell, phoneCellDef, phoneCellRenderer, PHONE_CELL_TYPE } from "./phoneCell"; + +const strict = {}; +const nullable = { nullable: true }; + +const validate = phoneCellDef.validate!; + +describe("dg:phone cell", () => { + it("makePhoneCell builds a custom cell with kind, E.164 value, and E.164 copyData", () => { + const c = makePhoneCell("+14155552671", strict); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: PHONE_CELL_TYPE, value: "+14155552671", options: strict }); + expect(c.copyData).toBe("+14155552671"); + }); + + it("makePhoneCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = makePhoneCell(null, strict); + expect(c.data).toEqual({ kind: PHONE_CELL_TYPE, value: null, options: strict }); + expect(c.copyData).toBe(""); + }); + + it("one renderer serves every phone column, whatever its options", () => { + expect(phoneCellRenderer.isMatch(makePhoneCell("+14155552671", strict))).toBe(true); + expect(phoneCellRenderer.isMatch(makePhoneCell("+14155552671", nullable))).toBe(true); + }); + + it("validate blocks empty when not nullable, allows it when nullable", () => { + expect(validate(makePhoneCell(null, strict) as never)).toBe(false); + expect(validate(makePhoneCell(null, nullable) as never)).toBe(true); + }); + + it("validate accepts a valid number and rejects a malformed one", () => { + expect(validate(makePhoneCell("+14155552671", strict) as never)).toBe(true); + expect(validate(makePhoneCell("+1234", strict) as never)).toBe(false); + }); + + it("onPaste parses a national string to E.164 and rejects junk", () => { + const onPaste = phoneCellRenderer.onPaste!; + const data = makePhoneCell(null, strict).data; + expect(onPaste("(415) 555-2671", data)).toEqual({ ...data, value: "+14155552671" }); + expect(onPaste("garbage", data)).toBeUndefined(); + }); + + it("onPaste rejects an empty paste when not nullable but clears when nullable", () => { + const onPaste = phoneCellRenderer.onPaste!; + expect(onPaste(" ", makePhoneCell("+14155552671", strict).data)).toBeUndefined(); + + const nullableData = makePhoneCell("+14155552671", nullable).data; + expect(onPaste(" ", nullableData)).toEqual({ ...nullableData, value: null }); + }); + + it("parses a national string against the column's own defaultCountry", () => { + const onPaste = phoneCellRenderer.onPaste!; + const gb = makePhoneCell(null, { defaultCountry: "GB" as const }).data; + expect(onPaste("020 7946 0958", gb)).toEqual({ ...gb, value: "+442079460958" }); + }); +}); + +describe("dg:phone allowOverlay", () => { + it("disables the overlay when allowOverlay is false", () => { + expect(makePhoneCell("+14155552671", strict, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(makePhoneCell("+14155552671", strict).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(makePhoneCell(null, strict, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(makePhoneCell("+14155552671", strict).data.readOnly).toBeUndefined(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("dg:phone draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + phoneCellRenderer.draw(drawArgs(ctx), makePhoneCell(null, strict, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + phoneCellRenderer.draw(drawArgs(ctx), makePhoneCell(null, strict)); + expect(calls).toEqual([]); + }); +}); diff --git a/src/lib/grid/phoneCell.tsx b/src/lib/grid/phoneCell.tsx new file mode 100644 index 0000000..27c5caf --- /dev/null +++ b/src/lib/grid/phoneCell.tsx @@ -0,0 +1,87 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import type { Country } from "react-phone-number-input"; +import { + DEFAULT_PHONE_COUNTRY, + formatPhoneDisplay, + isValidPhoneValue, + parsePhonePaste, +} from "../phone/phoneUtils"; +import type { CellTypeDef } from "./cellRegistry"; +import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell"; +import { PhoneCellEditor } from "./phoneCellEditor"; + +/** The `ColumnDef.type` string that selects this cell. */ +export const PHONE_CELL_TYPE = "dg:phone"; + +export { DEFAULT_PHONE_COUNTRY } from "../phone/phoneUtils"; + +/** Per-column settings for a `dg:phone` column, written on the column's `options`. */ +export interface PhoneCellOptions { + /** Allow an empty cell. Any non-empty value must still be a valid number. */ + nullable?: boolean; + + /** Country assumed for a number typed or pasted without a `+` prefix. */ + defaultCountry?: Country; +} + +/** Cell payload. `value` is a canonical E.164 string, or null for an unset cell. */ +export interface PhoneCellData { + kind: string; + value: string | null; + readOnly?: boolean; + options: PhoneCellOptions; +} + +export const phoneCellRenderer: CustomRenderer> = + createCustomCell({ + kind: PHONE_CELL_TYPE, + draw: (args, data) => { + if (data.value == null || data.value === "") { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText( + formatPhoneDisplay(data.value, data.options.defaultCountry ?? DEFAULT_PHONE_COUNTRY), + rect.x + theme.cellHorizontalPadding, + rect.y + rect.height / 2, + ); + }, + editor: PhoneCellEditor, + onPaste: (val, data) => { + const parsed = parsePhonePaste(val, data.options.defaultCountry ?? DEFAULT_PHONE_COUNTRY); + + if (parsed === undefined) return undefined; + if (parsed === null && data.options.nullable !== true) return undefined; + + return { ...data, value: parsed }; + }, + }); + +export function makePhoneCell( + value: string | null, + options: PhoneCellOptions, + allowOverlay = true, +): CustomCell { + return makeCustomCell( + { kind: PHONE_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) }, + value ?? "", + allowOverlay, + ); +} + +export const phoneCellDef: CellTypeDef = { + type: PHONE_CELL_TYPE, + kind: PHONE_CELL_TYPE, + renderer: phoneCellRenderer as unknown as CustomRenderer, + make: (raw, ctx) => + makePhoneCell(raw == null ? null : String(raw), ctx.options as PhoneCellOptions, ctx.editable), + validate: (cell) => { + const data = cell.data as unknown as PhoneCellData; + return isValidPhoneValue(data.value, data.options.nullable ?? false); + }, +}; diff --git a/src/lib/grid/phoneCellEditor.tsx b/src/lib/grid/phoneCellEditor.tsx new file mode 100644 index 0000000..2a8ef99 --- /dev/null +++ b/src/lib/grid/phoneCellEditor.tsx @@ -0,0 +1,49 @@ +import { Text, Theme } from "@radix-ui/themes"; +import PhoneInput from "react-phone-number-input"; +import "react-phone-number-input/style.css"; +import { gridRadixTheme } from "../../theme/radixTheme"; +import type { EditorProps } from "./createCustomCell"; +import { DEFAULT_PHONE_COUNTRY } from "../phone/phoneUtils"; +import type { PhoneCellData } from "./phoneCell"; + +/** + * The overlay editor for a `dg:phone` cell. + * + * Its own file so `phoneCell.tsx` exports no component. + */ +export const PhoneCellEditor = ({ value, onChange, isValid }: EditorProps) => { + const handleChange = (next?: string) => { + // PhoneInput emits E.164 (or undefined when cleared). Live-sync to glide; + // glide commits this value on overlay close, gated by validateCell. + onChange({ ...value, value: next ?? null }); + }; + + const invalid = isValid === false; + + return ( + +
+
+ +
+ {invalid && ( + + Invalid phone number + + )} +
+
+ ); +}; diff --git a/src/lib/grid/textCell.test.ts b/src/lib/grid/textCell.test.ts new file mode 100644 index 0000000..95e4f6c --- /dev/null +++ b/src/lib/grid/textCell.test.ts @@ -0,0 +1,126 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { makeNumberCell } from "./numberCell"; +import { makeTextCell, textCellDef, textCellRenderer, TEXT_CELL_TYPE } from "./textCell"; + +const strict = { required: true, maxLength: 5 }; +const optional = { email: true }; + +const validate = textCellDef.validate!; + +describe("dg:text cell", () => { + it("makeTextCell builds a custom cell with kind, value, options, and string copyData", () => { + const c = makeTextCell("hi", strict); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: TEXT_CELL_TYPE, value: "hi", options: strict }); + expect(c.copyData).toBe("hi"); + }); + + it("makeTextCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = makeTextCell(null, strict); + expect(c.data).toEqual({ kind: TEXT_CELL_TYPE, value: null, options: strict }); + expect(c.copyData).toBe(""); + }); + + it("one renderer serves every text column, whatever its options", () => { + expect(textCellRenderer.isMatch(makeTextCell("a", strict))).toBe(true); + expect(textCellRenderer.isMatch(makeTextCell("a", optional))).toBe(true); + }); + + it("the renderer does not match another cell type", () => { + expect(textCellRenderer.isMatch(makeNumberCell(1, {}) as never)).toBe(false); + }); + + it("validate reads the rules off the cell, not off a factory closure", () => { + expect(validate(makeTextCell(null, strict) as never)).toBe(false); + expect(validate(makeTextCell(null, optional) as never)).toBe(true); + }); + + it("validate enforces the configured rules (maxLength / email)", () => { + expect(validate(makeTextCell("hello", strict) as never)).toBe(true); + expect(validate(makeTextCell("toolong", strict) as never)).toBe(false); + expect(validate(makeTextCell("a@b.co", optional) as never)).toBe(true); + expect(validate(makeTextCell("nope", optional) as never)).toBe(false); + }); + + it("onPaste trims and stores the value, clearing to null when empty", () => { + const onPaste = textCellRenderer.onPaste!; + const data = makeTextCell(null, strict).data; + expect(onPaste(" hey ", data)).toEqual({ ...data, value: "hey" }); + expect(onPaste(" ", data)).toEqual({ ...data, value: null }); + }); +}); + +describe("dg:text allowOverlay", () => { + it("disables the overlay when allowOverlay is false", () => { + expect(makeTextCell("a", strict, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(makeTextCell("a", strict).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(makeTextCell(null, strict, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(makeTextCell("a", strict).data.readOnly).toBeUndefined(); + }); +}); + +describe("dg:text def", () => { + it("make() coerces a raw row value and honours ctx.editable", () => { + const cell = textCellDef.make(42, { editable: false, options: strict }); + expect(cell.allowOverlay).toBe(false); + expect((cell as unknown as { data: { value: string } }).data.value).toBe("42"); + }); + + it("make() reads a null row value as an empty cell", () => { + const cell = textCellDef.make(null, { editable: true, options: strict }); + expect((cell as unknown as { data: { value: null } }).data.value).toBeNull(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("dg:text draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + textCellRenderer.draw(drawArgs(ctx), makeTextCell(null, strict, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + textCellRenderer.draw(drawArgs(ctx), makeTextCell(null, strict)); + expect(calls).toEqual([]); + }); + + it("draws the text for a non-empty cell", () => { + const { ctx, calls } = fakeCtx(); + textCellRenderer.draw(drawArgs(ctx), makeTextCell("hi", strict, false)); + expect(calls).toEqual(["hi"]); + }); +}); diff --git a/src/lib/grid/textCell.tsx b/src/lib/grid/textCell.tsx new file mode 100644 index 0000000..36e766c --- /dev/null +++ b/src/lib/grid/textCell.tsx @@ -0,0 +1,78 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { validateText, type TextValidationOptions } from "../text/textValidation"; +import type { CellTypeDef } from "./cellRegistry"; +import { createCustomCell, drawEmptyDash, makeCustomCell } from "./createCustomCell"; +import { TextCellEditor } from "./textCellEditor"; + +/** The `ColumnDef.type` string that selects this cell. */ +export const TEXT_CELL_TYPE = "dg:text"; + +/** + * Per-column settings for a `dg:text` column, written on the column's `options`. + * + * They are the same declarative rules `` takes, so a text column + * and a text field in the host's own form validate identically. + * `required` plays the role the number and phone cells call `nullable`. + */ +export type TextCellOptions = TextValidationOptions; + +/** + * Cell payload. `value` is the text string, or null for an unset cell. + * + * `options` travels WITH the cell rather than living in a factory closure. That + * is what lets one module-scope renderer serve every text column in the app: + * the draw, the editor, and the validator all read the column's settings off + * the cell they were handed. + */ +export interface TextCellData { + kind: string; + value: string | null; + readOnly?: boolean; + options: TextCellOptions; +} + +export const textCellRenderer: CustomRenderer> = + createCustomCell({ + kind: TEXT_CELL_TYPE, + draw: (args, data) => { + if (data.value == null || data.value === "") { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText(data.value, rect.x + theme.cellHorizontalPadding, rect.y + rect.height / 2); + }, + editor: TextCellEditor, + onPaste: (val, data) => { + const trimmed = val.trim(); + return { ...data, value: trimmed === "" ? null : trimmed }; + }, + }); + +export function makeTextCell( + value: string | null, + options: TextCellOptions, + allowOverlay = true, +): CustomCell { + return makeCustomCell( + { kind: TEXT_CELL_TYPE, value, options, ...(allowOverlay ? {} : { readOnly: true }) }, + value ?? "", + allowOverlay, + ); +} + +export const textCellDef: CellTypeDef = { + type: TEXT_CELL_TYPE, + kind: TEXT_CELL_TYPE, + renderer: textCellRenderer as unknown as CustomRenderer, + make: (raw, ctx) => + makeTextCell(raw == null ? null : String(raw), ctx.options as TextCellOptions, ctx.editable), + validate: (cell) => { + const data = cell.data as unknown as TextCellData; + return validateText(data.value ?? "", data.options).valid; + }, +}; diff --git a/src/lib/grid/textCellEditor.tsx b/src/lib/grid/textCellEditor.tsx new file mode 100644 index 0000000..1bf5719 --- /dev/null +++ b/src/lib/grid/textCellEditor.tsx @@ -0,0 +1,46 @@ +import { Text, Theme } from "@radix-ui/themes"; +import { Input } from "../../components/ui/input"; +import { gridRadixTheme } from "../../theme/radixTheme"; +import { validateText } from "../text/textValidation"; +import type { EditorProps } from "./createCustomCell"; +import type { TextCellData } from "./textCell"; + +/** + * The overlay editor for a `dg:text` cell. + * + * Its own file so `textCell.tsx` exports no component: a module that mixes a + * component with plain exports cannot be hot-reloaded on its own. + * + * It reads the column's rules off `value.options`, not off a closure, which is + * why one editor serves every text column in the app. + */ +export const TextCellEditor = ({ value, onChange, isValid }: EditorProps) => { + const text = value.value ?? ""; + const options = value.options; + + const result = validateText(text, options); + const error = result.valid ? null : result.error; + + const invalid = isValid === false; + + return ( + +
+ + onChange({ ...value, value: e.target.value === "" ? null : e.target.value }) + } + autoFocus + style={invalid ? { borderColor: "var(--red-9)" } : undefined} + /> + {invalid && error && ( + + {error} + + )} +
+
+ ); +}; diff --git a/src/lib/phone/phoneUtils.ts b/src/lib/phone/phoneUtils.ts index e6cd8a1..e65b51d 100644 --- a/src/lib/phone/phoneUtils.ts +++ b/src/lib/phone/phoneUtils.ts @@ -5,6 +5,15 @@ import { type Country, } from "react-phone-number-input"; +/** + * The country assumed for a number typed or pasted without a `+` prefix, when a + * column names none. + * + * Here rather than beside the cell so the cell and its editor can both read it + * without importing each other. + */ +export const DEFAULT_PHONE_COUNTRY: Country = "US"; + /** * Format a phone value for canvas display, in international form. *