diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index dab61a60..55245828 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -50,6 +50,10 @@ export const dataTableLabels = defineI18nLabels({ filterOperator_between: "between", filterOperator_in: "in", filterOperator_nin: "not in", + filterBetweenFrom: "From", + filterBetweenTo: "To", + filterBetweenMin: "Min", + filterBetweenMax: "Max", // Filter chip label templates filterChipLabel: (props: { column: string; operator: string; value: string }) => @@ -97,6 +101,10 @@ export const dataTableLabels = defineI18nLabels({ filterOperator_between: "の範囲内", filterOperator_in: "次のいずれか", filterOperator_nin: "次のいずれでもない", + filterBetweenFrom: "開始", + filterBetweenTo: "終了", + filterBetweenMin: "最小", + filterBetweenMax: "最大", // Filter chip label templates (Japanese: column: value operator) filterChipLabel: (props: { column: string; operator: string; value: string }) => diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index f3e5ff3e..9d18216f 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -653,3 +653,184 @@ describe("BooleanFilterEditor", () => { expect(control.addFilter).toHaveBeenCalledWith("enabled", "ne", true); }); }); + +// --------------------------------------------------------------------------- +// NumericFilterEditor — between operator (two inputs) +// --------------------------------------------------------------------------- + +describe("NumericFilterEditor (between)", () => { + it("shows two number inputs when filter operator is between", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ })); + + const inputs = await screen.findAllByRole("spinbutton"); + expect(inputs.length).toBe(2); + expect((inputs[0] as HTMLInputElement).value).toBe("10"); + expect((inputs[1] as HTMLInputElement).value).toBe("50"); + }); + + it("Apply button calls addFilter with min/max range object", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ })); + + const inputs = await screen.findAllByRole("spinbutton"); + await user.clear(inputs[0]); + await user.type(inputs[0], "5"); + await user.clear(inputs[1]); + await user.type(inputs[1], "100"); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.addFilter).toHaveBeenCalledWith("count", "between", { + min: 5, + max: 100, + }); + }); + + it("Apply button does not call addFilter when only min is set and max is empty", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "count", operator: "between", value: { min: 10 } }], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Count between 10/ })); + + const inputs = await screen.findAllByRole("spinbutton"); + // min should already be "10", max should be empty + expect((inputs[0] as HTMLInputElement).value).toBe("10"); + expect((inputs[1] as HTMLInputElement).value).toBe(""); + + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.addFilter).not.toHaveBeenCalled(); + }); + + it("Apply button calls removeFilter when both inputs are empty", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ })); + + const inputs = await screen.findAllByRole("spinbutton"); + await user.clear(inputs[0]); + await user.clear(inputs[1]); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.removeFilter).toHaveBeenCalledWith("count"); + }); +}); + +// --------------------------------------------------------------------------- +// TemporalFilterEditor — between operator (two inputs) +// --------------------------------------------------------------------------- + +describe("TemporalFilterEditor (between)", () => { + it("shows two date inputs when filter operator is between", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [ + { + field: "createdAt", + operator: "between", + value: { min: "2025-01-01", max: "2025-12-31" }, + }, + ], + }); + render(, { + wrapper, + }); + + await user.click( + screen.getByRole("button", { + name: /Created At between 2025-01-01 - 2025-12-31/, + }), + ); + + const inputs = await screen.findAllByDisplayValue(/2025/); + expect(inputs.length).toBe(2); + }); + + it("Apply button calls addFilter with min/max range for date between", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [ + { + field: "createdAt", + operator: "between", + value: { min: "2025-01-01", max: "2025-12-31" }, + }, + ], + }); + render(, { + wrapper, + }); + + await user.click( + screen.getByRole("button", { + name: /Created At between 2025-01-01 - 2025-12-31/, + }), + ); + + const inputs = await screen.findAllByDisplayValue(/2025/); + await user.clear(inputs[0]); + await user.type(inputs[0], "2026-03-01"); + await user.clear(inputs[1]); + await user.type(inputs[1], "2026-06-30"); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.addFilter).toHaveBeenCalledWith("createdAt", "between", { + min: "2026-03-01", + max: "2026-06-30", + }); + }); + + it("Apply button calls removeFilter when both temporal inputs are empty", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [ + { + field: "createdAt", + operator: "between", + value: { min: "2025-01-01", max: "2025-12-31" }, + }, + ], + }); + render(, { + wrapper, + }); + + await user.click( + screen.getByRole("button", { + name: /Created At between 2025-01-01 - 2025-12-31/, + }), + ); + + const inputs = await screen.findAllByDisplayValue(/2025/); + await user.clear(inputs[0]); + await user.clear(inputs[1]); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.removeFilter).toHaveBeenCalledWith("createdAt"); + }); +}); diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 82f23a9c..3b2051a9 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -46,7 +46,7 @@ const DEFAULT_OPERATOR: Record = { }; /** Number/temporal operators available in the operator selector. */ -const NUMERIC_TEMPORAL_OPERATORS = ["eq", "ne", "gt", "gte", "lt", "lte"] as const; +const NUMERIC_TEMPORAL_OPERATORS = ["eq", "ne", "gt", "gte", "lt", "lte", "between"] as const; type NumericTemporalOperator = (typeof NUMERIC_TEMPORAL_OPERATORS)[number]; /** String operators available in the operator selector. */ @@ -108,6 +108,61 @@ function DataTableFilters({ className }: { className?: string }) { } DataTableFilters.displayName = "DataTable.Filters"; +// ============================================================================= +// BetweenInputGroup — shared UI for "between" filter inputs +// ============================================================================= + +function BetweenInputGroup({ + labels, + values, + onChangeMin, + onChangeMax, + onSubmit, + inputProps, +}: { + labels: [string, string]; + values: [string, string]; + onChangeMin: (value: string) => void; + onChangeMax: (value: string) => void; + onSubmit: () => void; + inputProps?: React.ComponentProps; +}) { + return ( +
+
+ + {labels[0]} + + onChangeMin(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onSubmit(); + }} + className="astw:h-full astw:text-sm astw:border-0 astw:shadow-none astw:focus-visible:ring-0" + /> +
+
+ + {labels[1]} + + onChangeMax(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onSubmit(); + }} + className="astw:h-full astw:text-sm astw:border-0 astw:shadow-none astw:focus-visible:ring-0" + /> +
+
+ ); +} + function AddFilterPopover({ availableColumns, control, @@ -138,7 +193,8 @@ function AddFilterPopover({ ); const canSubmit = - selectedColumn != null && isAddFilterDraftValueValid(selectedColumn.filter.type, value); + selectedColumn != null && + isAddFilterDraftValueValid(selectedColumn.filter.type, operator, value); const initDraft = useCallback((column: FilterableColumn | null) => { if (!column) { @@ -180,12 +236,12 @@ function AddFilterPopover({ const handleSubmit = useCallback(() => { if (!selectedColumn) return; - if (!isAddFilterDraftValueValid(selectedColumn.filter.type, value)) return; + if (!isAddFilterDraftValueValid(selectedColumn.filter.type, operator, value)) return; control.addFilter( selectedColumn.filter.field, operator, - toAddFilterSubmittedValue(selectedColumn.filter.type, value), + toAddFilterSubmittedValue(selectedColumn.filter.type, operator, value), ); setOpen(false); }, [selectedColumn, value, operator, control]); @@ -256,6 +312,19 @@ function AddFilterPopover({ } if (isTemporalFilterType(config.type)) { + if (operator === "between") { + const [min, max] = Array.isArray(value) ? value : ["", ""]; + return ( + setValue([v, max])} + onChangeMax={(v) => setValue([min, v])} + onSubmit={handleSubmit} + inputProps={getTemporalInputProps(config.type)} + /> + ); + } return ( setValue([v, max])} + onChangeMax={(v) => setValue([min, v])} + onSubmit={handleSubmit} + inputProps={{ type: "number" }} + /> + ); + } return ( @@ -336,7 +418,13 @@ function AddFilterPopover({ items={operatorItems} value={operator} onValueChange={(nextOp) => { - if (nextOp) setOperator(nextOp); + if (!nextOp) return; + const wasBetween = operator === "between"; + const isBetween = nextOp === "between"; + setOperator(nextOp); + if (wasBetween !== isBetween) { + setValue(isBetween ? ["", ""] : ""); + } }} mapItem={(op) => ({ value: op, @@ -747,17 +835,59 @@ function NumericFilterEditor({ ? (filter.operator as NumericTemporalOperator) : "eq", ); - const [localValue, setLocalValue] = useState(String(filter.value ?? "")); + const [localValue, setLocalValue] = useState(() => { + if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) { + const range = filter.value as { min?: unknown; max?: unknown }; + return String(range.min ?? ""); + } + return String(filter.value ?? ""); + }); + const [localValueMax, setLocalValueMax] = useState(() => { + if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) { + const range = filter.value as { min?: unknown; max?: unknown }; + return String(range.max ?? ""); + } + return ""; + }); + + const canCommit = (() => { + if (localOp === "between") { + const minEmpty = localValue.trim() === ""; + const maxEmpty = localValueMax.trim() === ""; + if (minEmpty && maxEmpty) return true; // will removeFilter + if (minEmpty || maxEmpty) return false; // both required + return !Number.isNaN(Number(localValue)) && !Number.isNaN(Number(localValueMax)); + } + return localValue.trim() === "" || !Number.isNaN(Number(localValue)); + })(); const handleCommit = useCallback(() => { - const num = Number(localValue); - if (localValue.trim() === "" || Number.isNaN(num)) { - control.removeFilter(config.field); + if (localOp === "between") { + const minEmpty = localValue.trim() === ""; + const maxEmpty = localValueMax.trim() === ""; + 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)) { + control.addFilter(config.field, localOp, { min, max }); + } else { + return; + } + } else { + return; + } } else { - control.addFilter(config.field, localOp, num); + const num = Number(localValue); + if (localValue.trim() === "" || Number.isNaN(num)) { + control.removeFilter(config.field); + } else { + control.addFilter(config.field, localOp, num); + } } onClose(); - }, [localValue, localOp, control, config.field, onClose]); + }, [localValue, localValueMax, localOp, control, config.field, onClose]); return (
({ value: op, label: t(`filterOperator_${op}`) })} className="astw:h-8 astw:text-sm" /> - setLocalValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleCommit(); - }} - className="astw:h-8 astw:text-sm" - /> -
@@ -810,19 +951,60 @@ function TemporalFilterEditor({ ? (filter.operator as NumericTemporalOperator) : "eq", ); - const [localValue, setLocalValue] = useState(String(filter.value ?? "")); - const canCommit = localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue); + const [localValue, setLocalValue] = useState(() => { + if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) { + const range = filter.value as { min?: unknown; max?: unknown }; + return String(range.min ?? ""); + } + return String(filter.value ?? ""); + }); + const [localValueMax, setLocalValueMax] = useState(() => { + if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) { + const range = filter.value as { min?: unknown; max?: unknown }; + return String(range.max ?? ""); + } + return ""; + }); + + const canCommit = (() => { + if (localOp === "between") { + const minEmpty = localValue.trim() === ""; + const maxEmpty = localValueMax.trim() === ""; + if (minEmpty && maxEmpty) return true; // will removeFilter + if (minEmpty || maxEmpty) return false; // both required + return ( + isTemporalFilterValueValid(config.type, localValue) && + isTemporalFilterValueValid(config.type, localValueMax) + ); + } + return localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue); + })(); const handleCommit = useCallback(() => { - if (localValue.trim() === "") { - control.removeFilter(config.field); - } else if (isTemporalFilterValueValid(config.type, localValue)) { - control.addFilter(config.field, localOp, localValue); + if (localOp === "between") { + const minEmpty = localValue.trim() === ""; + const maxEmpty = localValueMax.trim() === ""; + if (minEmpty && maxEmpty) { + control.removeFilter(config.field); + } else if (!minEmpty && !maxEmpty) { + const minValid = isTemporalFilterValueValid(config.type, localValue); + const maxValid = isTemporalFilterValueValid(config.type, localValueMax); + if (!minValid || !maxValid) return; + control.addFilter(config.field, localOp, { min: localValue, max: localValueMax }); + } else { + return; + } } else { - return; + if (localValue.trim() === "") { + control.removeFilter(config.field); + } else if (isTemporalFilterValueValid(config.type, localValue)) { + control.addFilter(config.field, localOp, localValue); + } else { + return; + } } onClose(); - }, [localValue, localOp, control, config.field, config.type, onClose]); + }, [localValue, localValueMax, localOp, control, config.field, config.type, onClose]); return (
({ value: op, label: t(`filterOperator_${op}`) })} className="astw:h-8 astw:text-sm" /> - { - setLocalValue(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleCommit(); - } - }} - className="astw:h-8 astw:text-sm" - /> + {localOp === "between" ? ( + + ) : ( + { + setLocalValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCommit(); + } + }} + className="astw:h-8 astw:text-sm" + /> + )} @@ -888,6 +1081,7 @@ function getInitialAddFilterDraftValue(type: FilterConfig["type"]): AddFilterDra function isAddFilterDraftValueValid( type: FilterConfig["type"], + operator: FilterOperator, value: AddFilterDraftValue, ): boolean { if (type === "enum") { @@ -897,6 +1091,21 @@ function isAddFilterDraftValueValid( return value === "true" || value === "false"; } + if (operator === "between") { + if (!Array.isArray(value)) return false; + const [min, max] = value; + const minEmpty = !min || min.trim() === ""; + const maxEmpty = !max || max.trim() === ""; + if (minEmpty || maxEmpty) return false; // both required + if (type === "number") { + return !Number.isNaN(Number(min)) && !Number.isNaN(Number(max)); + } + if (isTemporalFilterType(type)) { + return isTemporalFilterValueValid(type, min) && isTemporalFilterValueValid(type, max); + } + return true; + } + if (typeof value !== "string") return false; if (type === "number") { if (value.trim() === "") return false; @@ -910,6 +1119,7 @@ function isAddFilterDraftValueValid( function toAddFilterSubmittedValue( type: FilterConfig["type"], + operator: FilterOperator, value: AddFilterDraftValue, ): unknown { if (type === "enum") { @@ -918,6 +1128,28 @@ function toAddFilterSubmittedValue( if (type === "boolean") { return value === "true"; } + + if (operator === "between" && Array.isArray(value)) { + const [min, max] = value; + const trimmedMin = typeof min === "string" ? min.trim() : ""; + const trimmedMax = typeof max === "string" ? max.trim() : ""; + + if (type === "number") { + if (trimmedMin === "" || trimmedMax === "") return undefined; + + const parsedMin = Number(trimmedMin); + const parsedMax = Number(trimmedMax); + if (Number.isNaN(parsedMin) || Number.isNaN(parsedMax)) return undefined; + + return { min: parsedMin, max: parsedMax }; + } + + if (trimmedMin === "" || trimmedMax === "") return undefined; + + // temporal types + return { min: trimmedMin, max: trimmedMax }; + } + if (type === "number") { return Number(value); } @@ -1024,6 +1256,14 @@ function formatFilterValue( return [min, max].filter(Boolean).join(" - "); } + if (isTemporalFilterType(config.type) && 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) : ""; + const max = range.max != null ? String(range.max) : ""; + return [min, max].filter(Boolean).join(" - "); + } + if (Array.isArray(filter.value)) { return filter.value.map((v) => String(v)).join(", "); }