diff --git a/.changeset/tidy-decimals-flow.md b/.changeset/tidy-decimals-flow.md new file mode 100644 index 000000000..090e21e8e --- /dev/null +++ b/.changeset/tidy-decimals-flow.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/app-shell": patch +"@tailor-platform/sdk-plugin-app-shell": patch +--- + +Fix Decimal metadata and query types to use strings with numeric sort/filter operators, matching the GraphQL Decimal scalar. +Preserve decimal precision when adding, editing, or restoring DataTable filters. Regenerate SDK metadata to update decimal fields previously marked as `number`. diff --git a/docs-manifest.json b/docs-manifest.json index 749f4e4b3..a189dc9c1 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -446,10 +446,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "fe43108015be4a00", - "outline": "dc0b82256114191b", + "typeSurface": "d2910e2d95012c6c", + "outline": "5fc3f188a0f9fb8d", "snapshot": null, - "outputMd": "3e8474e4d1437c91", + "outputMd": "7a44214b5f1fdc6b", "examples": null } }, @@ -1693,7 +1693,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "3e8474e4d1437c91", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "7a44214b5f1fdc6b", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "bc69f14829004f9d", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 8e4b23a06..fdc5a8716 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -755,6 +755,7 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`, | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `decimal` | Decimal text (string value) | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `datetime` | Calendar / DatePicker + Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | @@ -762,7 +763,15 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`, | `boolean` | Toggle | `eq`, `ne` | | `uuid` | Text | `eq`, `ne`, `in`, `nin` | -When the `between` operator is selected on a `number`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds. +When the `between` operator is selected on a `number`, `decimal`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds. + +### Decimal Filters + +TailorDB `db.decimal()` fields are generated as metadata `type: "decimal"`. They support numeric sort and filter operators, but values remain **strings**, matching the GraphQL `Decimal` scalar without JavaScript number rounding. Scalar filters, `in`/`nin` arrays, and `between` bounds all use strings in `variables.query`. + +The editor accepts signed decimal and scientific notation (for example, `"9007199254740993.123456789"` or `"1.23e-10"`) and checks range ordering without rounding. Sorting is delegated to the backend. As with `number`, `in`/`nin` are available programmatically, not in the built-in editor. + +Regenerate metadata with the updated SDK plugin to replace older decimal fields marked as `"number"`. Decimal metadata does not select a numeric cell renderer; the default text rendering preserves the original value. ### Datetime Filters and Business Timezones diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 5dcc4c8ff..7ac281e88 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -749,6 +749,7 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`, | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `decimal` | Decimal text (string value) | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `datetime` | Calendar / DatePicker + Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | @@ -756,7 +757,15 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`, | `boolean` | Toggle | `eq`, `ne` | | `uuid` | Text | `eq`, `ne`, `in`, `nin` | -When the `between` operator is selected on a `number`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds. +When the `between` operator is selected on a `number`, `decimal`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds. + +### Decimal Filters + +TailorDB `db.decimal()` fields are generated as metadata `type: "decimal"`. They support numeric sort and filter operators, but values remain **strings**, matching the GraphQL `Decimal` scalar without JavaScript number rounding. Scalar filters, `in`/`nin` arrays, and `between` bounds all use strings in `variables.query`. + +The editor accepts signed decimal and scientific notation (for example, `"9007199254740993.123456789"` or `"1.23e-10"`) and checks range ordering without rounding. Sorting is delegated to the backend. As with `number`, `in`/`nin` are available programmatically, not in the built-in editor. + +Regenerate metadata with the updated SDK plugin to replace older decimal fields marked as `"number"`. Decimal metadata does not select a numeric cell renderer; the default text rendering preserves the original value. ### Datetime Filters and Business Timezones diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 8676fd268..99f62b7ef 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -590,6 +590,31 @@ describe("DataTable", () => { expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice"); }); + it("preserves decimal strings when filtering by a cell value", async () => { + const user = userEvent.setup(); + const control = makeControl(); + const amount = "9007199254740993.123456789"; + function Harness() { + const table = useDataTable<{ amount: string }>({ + columns: [ + { id: "amount", label: "Amount", filter: { type: "decimal", field: "amount" } }, + ], + data: { rows: [{ amount }] }, + control, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + openCellContextMenu(container, amount); + hoverMenuItem("Add filter"); + await user.click(await screen.findByRole("menuitem", { name: "is" })); + expect(control.addFilter).toHaveBeenCalledWith("amount", "eq", amount); + }); + it.each([ ["2026-10-09T01:00:00Z", "Oct 8, 2026, 6:00 PM", "2026-10-09T01:00:00.000Z"], ["2026-10-09T10:00:00+09:00", "Oct 8, 2026, 6:00 PM", "2026-10-09T01:00:00.000Z"], diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index c6755d4b9..fc5a0b6d2 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -35,6 +35,7 @@ import type { import { DataTableContext, type DataTableContextValue } from "./data-table-context"; import { useDataTableT } from "./i18n"; import { getCellValue, createTypedCellRenderer } from "./cell-renderers"; +import { isDecimalFilterValueValid } from "./filter-value-utils"; import { isTemporalFilterType, normalizeTemporalFilterValue } from "@/lib/temporal-filter-values"; import { useCellContextMenu, type CellContextMenuState } from "./use-cell-context-menu"; import { @@ -177,6 +178,10 @@ function toFilterValue(value: unknown, config: DataTableFilterConfig, timeZone: const parsed = typeof value === "number" ? value : Number(value); return Number.isNaN(parsed) ? undefined : parsed; } + case "decimal": { + const decimal = String(value).trim(); + return isDecimalFilterValueValid(decimal) ? decimal : undefined; + } case "boolean": { if (typeof value === "boolean") return value; if (value === "true" || value === "false") return value === "true"; diff --git a/packages/core/src/components/data-table/field-helpers.test.ts b/packages/core/src/components/data-table/field-helpers.test.ts index 1201da66c..781796428 100644 --- a/packages/core/src/components/data-table/field-helpers.test.ts +++ b/packages/core/src/components/data-table/field-helpers.test.ts @@ -103,6 +103,29 @@ describe("inferColumns()", () => { }); }); +describe("decimal metadata", () => { + it("infers numeric sort and string-backed numeric filtering", () => { + const metadata = { + name: "order", + pluralForm: "orders", + fields: [{ name: "amount", type: "decimal", required: false }], + } as const; + const infer = createColumnHelper<{ amount: string }>().inferColumns(metadata); + expect(infer("amount")).toMatchObject({ + id: "amount", + sort: { field: "amount", type: "number" }, + filter: { field: "amount", type: "decimal" }, + }); + expect(infer("amount", { filter: { operators: ["gte", "between"] } }).filter).toEqual({ + field: "amount", + type: "decimal", + operators: ["gte", "between"], + }); + // @ts-expect-error decimal fields do not support string-only operators + infer("amount", { filter: { operators: ["contains"] } }); + }); +}); + describe("fieldTypeToSortConfig", () => { it("maps string to string sort", () => { expect(fieldTypeToSortConfig("name", "string")).toEqual({ diff --git a/packages/core/src/components/data-table/filter-value-utils.test.ts b/packages/core/src/components/data-table/filter-value-utils.test.ts index 8446ba2a5..cbb761014 100644 --- a/packages/core/src/components/data-table/filter-value-utils.test.ts +++ b/packages/core/src/components/data-table/filter-value-utils.test.ts @@ -5,6 +5,7 @@ import { localDateTimeParts, normalizeTemporalFilterValue, } from "@/lib/temporal-filter-values"; +import { isDecimalFilterValueValid, isDecimalRangeOrdered } from "./filter-value-utils"; function mockDate(local: { year: number; @@ -29,6 +30,51 @@ function mockDate(local: { return value; } +describe("decimal filters", () => { + it.each(["0", "-0", "+001.20", ".5", "1.", "-9007199254740993.123456789", "1.23e-400"])( + "accepts decimal %s without converting it to Number", + (value) => expect(isDecimalFilterValueValid(value)).toBe(true), + ); + + it.each(["", " ", "NaN", "Infinity", "0x10", "1,23", "1e", "1.2.3", "--1"])( + "rejects invalid decimal %s", + (value) => expect(isDecimalFilterValueValid(value)).toBe(false), + ); + + it.each([ + ["9007199254740992", "9007199254740993"], + ["0.12345678901234567890", "0.12345678901234567891"], + ["-9007199254740993.1", "-9007199254740993.0"], + ["-0.001", "0"], + ["0", "0.001"], + ["-0", "+0"], + ["-1.2", "-1.20"], + ["001.20", "1.2"], + [".5", "0.5"], + ["1.", "1"], + ["123e-2", "1.2300"], + ["1e-400", "2e-400"], + ["9e99999999999999999999", "1e100000000000000000000"], + ])("compares %s ≤ %s exactly", (min, max) => { + expect(isDecimalRangeOrdered(min, max)).toBe(true); + if (min === "9007199254740992" || min.startsWith("0.123") || min === "1e-400") { + expect(isDecimalRangeOrdered(max, min)).toBe(false); + } + }); + + it.each([ + ["10", "2"], + ["-2", "-10"], + ["0", "-0.001"], + ["0.001", "-0"], + ["1e400", "9e399"], + ["invalid", "1"], + ["1", "invalid"], + ])("rejects unordered or invalid range %s … %s", (min, max) => { + expect(isDecimalRangeOrdered(min, max)).toBe(false); + }); +}); + describe("filter-value-utils", () => { it("detects temporal filter types", () => { expect(isTemporalFilterType("date")).toBe(true); diff --git a/packages/core/src/components/data-table/filter-value-utils.ts b/packages/core/src/components/data-table/filter-value-utils.ts new file mode 100644 index 000000000..d4b0b1e89 --- /dev/null +++ b/packages/core/src/components/data-table/filter-value-utils.ts @@ -0,0 +1,35 @@ +const DECIMAL_RE = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/; + +export function isDecimalFilterValueValid(value: string): boolean { + return DECIMAL_RE.test(value.trim()); +} + +function decimalParts(value: string) { + const match = DECIMAL_RE.exec(value.trim()); + if (!match) return undefined; + const fraction = match[3] ?? match[4] ?? ""; + const digits = `${match[2] ?? ""}${fraction}`.replace(/^0+/, ""); + const sign = match[1] === "-" ? -1 : 1; + return { + digits, + sign: digits === "" ? 0 : sign, + magnitude: BigInt(digits.length - fraction.length) + BigInt(match[5] ?? "0"), + }; +} + +export function isDecimalRangeOrdered(min: string, max: string): boolean { + const lower = decimalParts(min); + const upper = decimalParts(max); + if (!lower || !upper) return false; + if (lower.sign !== upper.sign) return lower.sign < upper.sign; + if (lower.sign === 0) return true; + + // Compare magnitude, then significant digits, without rounding or expanding exponents. + if (lower.magnitude !== upper.magnitude) { + return lower.sign === 1 ? lower.magnitude < upper.magnitude : lower.magnitude > upper.magnitude; + } + const width = Math.max(lower.digits.length, upper.digits.length); + const left = lower.digits.padEnd(width, "0"); + const right = upper.digits.padEnd(width, "0"); + return lower.sign === 1 ? left <= right : left >= right; +} diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index 2030c0d7a..a6384a5d4 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -165,6 +165,12 @@ const numberColumn: Column = { filter: { type: "number", field: "count" }, }; +const decimalColumn: Column = { + id: "amount", + label: "Amount", + filter: { type: "decimal", field: "amount" }, +}; + const dateColumn: Column = { id: "createdAt", label: "Created At", @@ -941,6 +947,138 @@ describe("NumericFilterEditor", () => { }); }); +// --------------------------------------------------------------------------- +// Decimal filters — numeric operators with precision-preserving string values +// --------------------------------------------------------------------------- + +describe("Decimal filters", () => { + const preciseValue = "9007199254740993.123456789"; + const preciseMax = "9007199254740993.123456790"; + + it.each(["panel", "chip"] as const)( + "%s submits scalar decimals as exact strings", + async (surface) => { + const user = userEvent.setup(); + const control = makeControl({ + filters: surface === "chip" ? [{ field: "amount", operator: "eq", value: "1" }] : [], + }); + render(, { wrapper }); + + if (surface === "panel") await user.click(screen.getByRole("button", { name: "Add filter" })); + else await openValueEditor(user); + + const input = await screen.findByPlaceholderText("Enter amount"); + expect(input.getAttribute("type")).toBe("text"); + expect(input.getAttribute("inputmode")).toBe("decimal"); + await user.clear(input); + await user.type(input, preciseValue); + await user.click(screen.getByRole("button", { name: "Apply" })); + + if (surface === "panel") { + expect(control.addFilter).toHaveBeenCalledWith("amount", "eq", preciseValue, undefined); + } else { + expect(control.addFilter).toHaveBeenCalledWith("amount", "eq", preciseValue); + } + }, + ); + + it.each(["panel", "chip"] as const)( + "%s submits precise ranges and rejects reversed bounds", + async (surface) => { + const user = userEvent.setup(); + const control = makeControl({ + filters: + surface === "chip" + ? [ + { + field: "amount", + operator: "between", + value: { min: preciseValue, max: preciseMax }, + }, + ] + : [], + }); + render(, { wrapper }); + + if (surface === "panel") { + await user.click(screen.getByRole("button", { name: "Add filter" })); + await user.click(await screen.findByRole("button", { name: "is between" })); + } else { + expect(screen.getByText(`${preciseValue} - ${preciseMax}`)).toBeDefined(); + await openValueEditor(user); + } + + const min = await screen.findByRole("textbox", { name: "Min" }); + const max = screen.getByRole("textbox", { name: "Max" }); + fireEvent.change(min, { target: { value: "9007199254740993" } }); + fireEvent.change(max, { target: { value: "9007199254740992" } }); + expect((screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement).disabled).toBe( + true, + ); + expect(screen.getByText(/must be greater than or equal to/i)).toBeDefined(); + fireEvent.keyDown(max, { key: "Enter" }); + expect(control.addFilter).not.toHaveBeenCalled(); + + fireEvent.change(min, { target: { value: preciseValue } }); + fireEvent.change(max, { target: { value: preciseMax } }); + await user.click(screen.getByRole("button", { name: "Apply" })); + expect(control.addFilter).toHaveBeenCalledWith("amount", "between", { + min: preciseValue, + max: preciseMax, + }); + }, + ); + + it("chip editing rejects invalid decimals without clearing the active filter", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "amount", operator: "eq", value: preciseValue }], + }); + render(, { wrapper }); + await openValueEditor(user); + + const input = await screen.findByRole("textbox"); + fireEvent.change(input, { target: { value: "not-a-decimal" } }); + expect((screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement).disabled).toBe( + true, + ); + fireEvent.keyDown(input, { key: "Enter" }); + expect(control.addFilter).not.toHaveBeenCalled(); + expect(control.removeFilter).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: "-1.23456789123456789e-100" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(control.addFilter).toHaveBeenCalledWith("amount", "eq", "-1.23456789123456789e-100"); + }); + + it("operator transitions preserve decimal strings in both directions", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "amount", operator: "eq", value: preciseValue }], + }); + const { rerender } = render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: "is" })); + await user.click(await screen.findByRole("button", { name: "is between" })); + expect(control.addFilter).toHaveBeenCalledWith("amount", "between", { + min: preciseValue, + max: preciseValue, + }); + + const rangeControl = makeControl({ + filters: [ + { field: "amount", operator: "between", value: { min: preciseValue, max: preciseMax } }, + ], + }); + rerender(); + await user.click(screen.getByRole("button", { name: "is between" })); + await user.click(await screen.findByRole("button", { name: "is" })); + expect(rangeControl.addFilter).toHaveBeenCalledWith("amount", "eq", preciseValue); + }); +}); + // --------------------------------------------------------------------------- // DateFilterEditor — Apply button // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 93e77d473..77a5544b8 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -20,6 +20,7 @@ import { localDateTimeParts, normalizeTemporalFilterValue, } from "@/lib/temporal-filter-values"; +import { isDecimalFilterValueValid, isDecimalRangeOrdered } from "./filter-value-utils"; import { useDataTableT } from "./i18n"; import type { CollectionControl, @@ -77,6 +78,7 @@ const DEFAULT_OPERATOR: Record = { boolean: "eq", string: "contains", number: "eq", + decimal: "eq", datetime: "eq", date: "eq", time: "eq", @@ -138,6 +140,7 @@ function getDefaultFilterOperators(type: FilterConfig["type"]): FilterOperator[] case "date": return [...DATE_OPERATORS]; case "number": + case "decimal": case "datetime": case "time": return [...NUMERIC_TEMPORAL_OPERATORS]; @@ -157,6 +160,7 @@ function isUiOperatorAllowedForType(type: FilterConfig["type"], operator: Filter case "date": return (DATE_OPERATORS as readonly string[]).includes(operator); case "number": + case "decimal": case "datetime": case "time": return (NUMERIC_TEMPORAL_OPERATORS as readonly string[]).includes(operator); @@ -883,7 +887,9 @@ function PanelValueEditor({ ); } else if (isBetween) { // Non-date range: two simple From/To (or Min/Max) text boxes. - const numeric = type === "number"; + const numeric = type === "number" || type === "decimal"; + const numericInputProps = + type === "decimal" ? { type: "text", inputMode: "decimal" as const } : { type: "number" }; const labels: [string, string] = numeric ? [t("filterBetweenMin"), t("filterBetweenMax")] : [t("filterBetweenFrom"), t("filterBetweenTo")]; @@ -896,7 +902,7 @@ function PanelValueEditor({ onChangeMax={setMax} onSubmit={apply} inputProps={ - numeric ? { type: "number" } : getTemporalInputProps(type as "datetime" | "time") + numeric ? numericInputProps : getTemporalInputProps(type as "datetime" | "time") } error={betweenOrderError(type, min, max, labels[0], labels[1], t)} /> @@ -936,6 +942,7 @@ function PanelValueEditor({
setText(e.target.value)} @@ -1067,6 +1074,8 @@ function betweenOrderError( let bothValid = true; if (type === "number") { bothValid = !Number.isNaN(Number(min)) && !Number.isNaN(Number(max)); + } else if (type === "decimal") { + bothValid = isDecimalFilterValueValid(min) && isDecimalFilterValueValid(max); } else if (isTemporalFilterType(type)) { bothValid = isTemporalFilterValueValid(type, min, timeZone) && @@ -1485,6 +1494,7 @@ function FilterPopoverContent({ ); case "number": + case "decimal": return ( ; + config: Extract; filter: Filter; control: CollectionControl; onClose: () => void; @@ -1833,12 +1843,11 @@ function NumericFilterEditor({ if (minEmpty && maxEmpty) return true; // will removeFilter if (minEmpty || maxEmpty) return false; // both required return ( - !Number.isNaN(Number(localValue)) && - !Number.isNaN(Number(localValueMax)) && - isRangeOrdered("number", localValue, localValueMax) + isAddFilterDraftValueValid(config.type, "between", [localValue, localValueMax]) && + isRangeOrdered(config.type, localValue, localValueMax) ); } - return localValue.trim() === "" || !Number.isNaN(Number(localValue)); + return localValue.trim() === "" || isAddFilterDraftValueValid(config.type, localOp, localValue); })(); const handleCommit = useCallback(() => { @@ -1848,16 +1857,30 @@ function NumericFilterEditor({ if (minEmpty && maxEmpty) { control.removeFilter(config.field); } else if (!minEmpty && !maxEmpty) { - const min = Number(localValue); - const max = Number(localValueMax); - if (!Number.isNaN(min) && !Number.isNaN(max) && min <= max) { - control.addFilter(config.field, localOp, { min, max }); + const draft: AddFilterDraftValue = [localValue, localValueMax]; + if ( + isAddFilterDraftValueValid(config.type, localOp, draft) && + isRangeOrdered(config.type, localValue, localValueMax) + ) { + control.addFilter( + config.field, + localOp, + toAddFilterSubmittedValue(config.type, localOp, draft), + ); } else { return; } } else { return; } + } else if (config.type === "decimal") { + if (localValue.trim() === "") { + control.removeFilter(config.field); + } else if (isDecimalFilterValueValid(localValue)) { + control.addFilter(config.field, localOp, localValue.trim()); + } else { + return; + } } else { const num = Number(localValue); if (localValue.trim() === "" || Number.isNaN(num)) { @@ -1867,11 +1890,11 @@ function NumericFilterEditor({ } } onClose(); - }, [localValue, localValueMax, localOp, control, config.field, onClose]); + }, [localValue, localValueMax, localOp, control, config.field, config.type, onClose]); return (
{!hideOperator && ( @@ -1892,9 +1915,11 @@ function NumericFilterEditor({ onChangeMin={setLocalValue} onChangeMax={setLocalValueMax} onSubmit={handleCommit} - inputProps={{ type: "number" }} + inputProps={ + config.type === "decimal" ? { type: "text", inputMode: "decimal" } : { type: "number" } + } error={betweenOrderError( - "number", + config.type, localValue, localValueMax, t("filterBetweenMin"), @@ -1904,7 +1929,8 @@ function NumericFilterEditor({ /> ) : ( setLocalValue(e.target.value)} @@ -2153,6 +2179,9 @@ function isAddFilterDraftValueValid( if (type === "number") { return !Number.isNaN(Number(min)) && !Number.isNaN(Number(max)); } + if (type === "decimal") { + return isDecimalFilterValueValid(min) && isDecimalFilterValueValid(max); + } if (isTemporalFilterType(type)) { return ( isTemporalFilterValueValid(type, min, timeZone) && @@ -2167,6 +2196,7 @@ function isAddFilterDraftValueValid( if (value.trim() === "") return false; return !Number.isNaN(Number(value)); } + if (type === "decimal") return isDecimalFilterValueValid(value); if (isTemporalFilterType(type)) { return isTemporalFilterValueValid(type, value, timeZone); } @@ -2234,6 +2264,7 @@ function toAddFilterSubmittedValue( */ function isRangeOrdered(type: FilterConfig["type"], min: string, max: string): boolean { if (type === "number") return Number(min) <= Number(max); + if (type === "decimal") return isDecimalRangeOrdered(min, max); if (type === "datetime") return new Date(min).getTime() <= new Date(max).getTime(); if (isTemporalFilterType(type)) return min <= max; return true; @@ -2376,7 +2407,7 @@ function formatFilterValue( return filter.value === true ? t("filterBooleanTrue") : t("filterBooleanFalse"); } - if (config.type === "number" && filter.operator === "between") { + if ((config.type === "number" || config.type === "decimal") && filter.operator === "between") { const range = filter.value as { min?: unknown; max?: unknown } | null; if (!range || typeof range !== "object") return ""; const min = range.min != null ? String(range.min) : ""; diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 513ab4813..2f3465cbe 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -127,6 +127,7 @@ type NonEmptyReadonlyArray = readonly [T, ...T[]]; type DataTableUiFilterOperatorByType = { string: DataTableStringFilterOperator; number: DataTableNumericTemporalFilterOperator; + decimal: DataTableNumericTemporalFilterOperator; datetime: DataTableNumericTemporalFilterOperator; date: DataTableDateFilterOperator; time: DataTableNumericTemporalFilterOperator; @@ -146,6 +147,9 @@ export type DataTableFilterConfig = | (Extract & { operators?: NonEmptyReadonlyArray; }) + | (Extract & { + operators?: NonEmptyReadonlyArray; + }) | (Extract & { operators?: NonEmptyReadonlyArray; }) diff --git a/packages/core/src/hooks/use-collection-variables.test-d.ts b/packages/core/src/hooks/use-collection-variables.test-d.ts index 1db183af1..89ad49054 100644 --- a/packages/core/src/hooks/use-collection-variables.test-d.ts +++ b/packages/core/src/hooks/use-collection-variables.test-d.ts @@ -32,6 +32,11 @@ type TestTable = { readonly type: "number"; readonly required: false; }, + { + readonly name: "decimalAmount"; + readonly type: "decimal"; + readonly required: false; + }, { readonly name: "status"; readonly type: "enum"; @@ -82,6 +87,24 @@ describe("BuildQueryVariables", () => { }>(); }); + it("decimal fields use numeric operators with GraphQL string values", () => { + type DecimalInput = NonNullable; + expectTypeOf().toEqualTypeOf<{ + eq?: string; + ne?: string; + gt?: string; + gte?: string; + lt?: string; + lte?: string; + between?: { min: string; max: string }; + in?: string[]; + nin?: string[]; + }>(); + expectTypeOf().toExtend< + { decimalAmount?: DecimalInput } | undefined + >(); + }); + it("enum fields produce literal union types from enumValues", () => { expectTypeOf().toExtend<{ status?: { @@ -140,6 +163,7 @@ describe("TableFieldName", () => { | "id" | "title" | "amount" + | "decimalAmount" | "status" | "isActive" | "createdAt" @@ -166,6 +190,10 @@ describe("TableOrderableFieldName", () => { expectTypeOf<"amount">().toExtend>(); }); + it("includes decimal fields", () => { + expectTypeOf<"decimalAmount">().toExtend>(); + }); + it("includes enum fields", () => { expectTypeOf<"status">().toExtend>(); }); diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index 434bded2e..e8a7ee4b5 100644 --- a/packages/core/src/hooks/use-collection-variables.test.ts +++ b/packages/core/src/hooks/use-collection-variables.test.ts @@ -1,7 +1,7 @@ import { renderHook, act } from "@testing-library/react"; import { createElement, type PropsWithChildren } from "react"; import { resetLocalTimeZone } from "@internationalized/date"; -import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { afterEach, beforeEach, describe, it, expect, expectTypeOf, vi } from "vitest"; import { AppShellConfigContext, buildConfigurations } from "@/contexts/appshell-context"; import type { TableMetadataMap } from "@/types/collection"; import { useCollectionVariables } from "./use-collection-variables"; @@ -649,10 +649,42 @@ describe("useCollectionVariables", () => { { name: "createdAt", type: "datetime", required: true }, { name: "startTime", type: "time", required: false }, { name: "count", type: "number", required: false }, + { name: "amount", type: "decimal", required: false }, ], }, } as const satisfies TableMetadataMap; + it.each(["gte", "in", "nin", "between"] as const)( + "builds string-based decimal %s variables compatible with GraphQL inputs", + (operator) => { + const min = "9007199254740993.123456789"; + const max = "9007199254740994.123456789"; + const value = { between: { min, max }, gte: min, in: [min, max], nin: [min, max] }[ + operator + ]; + const { result } = renderHook(() => + useCollectionVariables({ tableMetadata: testMetadata.task }), + ); + act(() => { + result.current.control.addFilter("amount", operator, value); + result.current.control.setSort("amount", "Asc"); + }); + expect(result.current.variables.query).toEqual({ amount: { [operator]: value } }); + expect(result.current.variables.order).toEqual([{ field: "amount", direction: "Asc" }]); + expectTypeOf(result.current.variables.query).toExtend< + | { + amount?: { + gte?: string; + in?: string[]; + nin?: string[]; + between?: { min: string; max: string }; + }; + } + | undefined + >(); + }, + ); + it("works with tableMetadata", () => { const { result } = renderHook(() => useCollectionVariables({ diff --git a/packages/core/src/lib/collection-url-state.test.ts b/packages/core/src/lib/collection-url-state.test.ts index ff2bed613..d2ee98b62 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -23,6 +23,7 @@ const tableMetadata = { { name: "createdAt", type: "datetime", required: true }, { name: "title", type: "string", required: true }, { name: "price", type: "number", required: true }, + { name: "decimalPrice", type: "decimal", required: true }, { name: "archived", type: "boolean", required: false }, ], }, @@ -106,6 +107,22 @@ describe("parseCollectionSearchParams", () => { ]); }); + it.each(["eq", "in", "nin", "between"] as const)( + "round-trips decimal %s values and sorting without losing precision", + (operator) => { + const min = "9007199254740993.123456789"; + const max = "9007199254740994.123456789"; + const value = { between: { min, max }, eq: min, in: [min, max], nin: [min, max] }[operator]; + const state = { + filters: [{ field: "decimalPrice", operator, value }], + sortStates: [{ field: "decimalPrice", direction: "Asc" as const }], + pageSize: 20, + }; + const written = writeCollectionSearchParams(new URLSearchParams(), state); + expect(parseCollectionSearchParams(tableMetadata.task, written)).toEqual(state); + }, + ); + it("leaves numeric-looking values as strings when no metadata is provided", () => { const result = parseCollectionSearchParams(new URLSearchParams("f.price:gt=130")); diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 3bfc3a0ce..1a10b4f27 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -8,6 +8,7 @@ export type FieldType = | "string" | "number" + | "decimal" | "boolean" | "uuid" | "datetime" @@ -86,6 +87,7 @@ export interface SelectOption { export type FilterConfig = | { field: string; type: "string" } | { field: string; type: "number" } + | { field: string; type: "decimal" } | { field: string; type: "datetime" } | { field: string; type: "date" } | { field: string; type: "time" } @@ -115,6 +117,7 @@ export const OPERATORS_BY_FILTER_TYPE = { "regex", ], number: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], + decimal: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], datetime: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], date: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], time: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], @@ -156,6 +159,7 @@ export type OperatorForField = [Extract = TTable["fields"][numb : never : never; -type OrderableFieldType = "string" | "number" | "boolean" | "datetime" | "date" | "time" | "enum"; +type OrderableFieldType = + | "string" + | "number" + | "decimal" + | "boolean" + | "datetime" + | "date" + | "time" + | "enum"; /** * Extract only orderable field names from a single table metadata object. @@ -518,6 +531,7 @@ export function fieldTypeToSortConfig(field: string, type: FieldType): SortConfi case "string": return { field, type: "string" }; case "number": + case "decimal": return { field, type: "number" }; case "boolean": return { field, type: "boolean" }; @@ -546,6 +560,8 @@ export function fieldTypeToFilterConfig( return { field, type: "string" }; case "number": return { field, type: "number" }; + case "decimal": + return { field, type: "decimal" }; case "boolean": return { field, type: "boolean" }; case "uuid": diff --git a/packages/sdk-plugin/src/plugin.test.ts b/packages/sdk-plugin/src/plugin.test.ts index b3e89f71f..8ade3b6a9 100644 --- a/packages/sdk-plugin/src/plugin.test.ts +++ b/packages/sdk-plugin/src/plugin.test.ts @@ -187,6 +187,10 @@ describe("appShellPlugin", () => { name: "decimalField", config: { type: "decimal", required: false }, }, + decimalArrayField: { + name: "decimalArrayField", + config: { type: "decimal", required: false, array: true }, + }, dateField: { name: "dateField", config: { type: "date", required: false }, @@ -217,7 +221,13 @@ describe("appShellPlugin", () => { expect(fields.find((f: { name: string }) => f.name === "intField").type).toBe("number"); expect(fields.find((f: { name: string }) => f.name === "floatField").type).toBe("number"); - expect(fields.find((f: { name: string }) => f.name === "decimalField").type).toBe("number"); + expect(fields.find((f: { name: string }) => f.name === "decimalField").type).toBe("decimal"); + expect(fields.find((f: { name: string }) => f.name === "decimalArrayField")).toEqual({ + name: "decimalArrayField", + type: "array", + arrayItemType: "decimal", + required: false, + }); expect(fields.find((f: { name: string }) => f.name === "dateField").type).toBe("date"); expect(fields.find((f: { name: string }) => f.name === "dtField").type).toBe("datetime"); diff --git a/packages/sdk-plugin/src/plugin.ts b/packages/sdk-plugin/src/plugin.ts index 0f64a3079..c8283bb73 100644 --- a/packages/sdk-plugin/src/plugin.ts +++ b/packages/sdk-plugin/src/plugin.ts @@ -25,7 +25,7 @@ function mapFieldType( string: "string", integer: "number", float: "number", - decimal: "number", + decimal: "decimal", boolean: "boolean", uuid: "uuid", datetime: "datetime",