{!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",