Skip to content
7 changes: 7 additions & 0 deletions .changeset/tidy-decimals-flow.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 4 additions & 4 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,10 @@
"withURLCollectionState"
],
"hashes": {
"typeSurface": "fe43108015be4a00",
"outline": "dc0b82256114191b",
"typeSurface": "d2910e2d95012c6c",
"outline": "5fc3f188a0f9fb8d",
"snapshot": null,
"outputMd": "3e8474e4d1437c91",
"outputMd": "7a44214b5f1fdc6b",
"examples": null
}
},
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion docs-src/components/data-table.docs.outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,14 +755,23 @@ 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` |
| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` |
| `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

Expand Down
11 changes: 10 additions & 1 deletion docs/components/data-table.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions packages/core/src/components/data-table/data-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DataTable.Root value={table}>
<DataTable.Table />
</DataTable.Root>
);
}
const { container } = render(<Harness />, { 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"],
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/components/data-table/field-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
localDateTimeParts,
normalizeTemporalFilterValue,
} from "@/lib/temporal-filter-values";
import { isDecimalFilterValueValid, isDecimalRangeOrdered } from "./filter-value-utils";

function mockDate(local: {
year: number;
Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/components/data-table/filter-value-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading