diff --git a/.changeset/string-filter-case-sensitive.md b/.changeset/string-filter-case-sensitive.md new file mode 100644 index 00000000..1211e6fb --- /dev/null +++ b/.changeset/string-filter-case-sensitive.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add case-sensitivity control for string filters in `DataTable.Filters`. String filters are now case-insensitive by default (using Tailor Platform's `regex` operator with `(?i)` prefix). A "Case sensitive" checkbox allows users to opt into exact-case matching. + +The `Filter` type and `CollectionControl.addFilter` now accept an optional `caseSensitive` property to control this behavior programmatically. diff --git a/examples/nextjs-app/src/modules/pages/mock-data.ts b/examples/nextjs-app/src/modules/pages/mock-data.ts index 057ea0b5..3a9509b9 100644 --- a/examples/nextjs-app/src/modules/pages/mock-data.ts +++ b/examples/nextjs-app/src/modules/pages/mock-data.ts @@ -253,8 +253,8 @@ function compareValues(left: unknown, right: unknown): number | null { } function matchStringOperator(fieldValue: unknown, operator: string, expected: unknown): boolean { - const value = String(fieldValue ?? "").toLowerCase(); - const needle = String(expected ?? "").toLowerCase(); + const value = String(fieldValue ?? ""); + const needle = String(expected ?? ""); switch (operator) { case "contains": @@ -284,6 +284,13 @@ function matchOperator(fieldValue: unknown, operator: string, expected: unknown) return Array.isArray(expected) && expected.some((item) => item === fieldValue); case "nin": return Array.isArray(expected) && !expected.some((item) => item === fieldValue); + case "regex": { + const pattern = String(expected ?? ""); + const caseInsensitive = pattern.startsWith("(?i)"); + const regexBody = caseInsensitive ? pattern.slice(4) : pattern; + const re = new RegExp(regexBody, caseInsensitive ? "i" : ""); + return re.test(String(fieldValue ?? "")); + } case "contains": case "notContains": case "hasPrefix": diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 55245828..63aa6371 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -54,6 +54,7 @@ export const dataTableLabels = defineI18nLabels({ filterBetweenTo: "To", filterBetweenMin: "Min", filterBetweenMax: "Max", + filterCaseSensitive: "Case sensitive", // Filter chip label templates filterChipLabel: (props: { column: string; operator: string; value: string }) => @@ -105,6 +106,7 @@ export const dataTableLabels = defineI18nLabels({ filterBetweenTo: "終了", filterBetweenMin: "最小", filterBetweenMax: "最大", + filterCaseSensitive: "大文字小文字を区別する", // 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 9d18216f..4c25fb91 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -239,7 +239,9 @@ describe("StringFilterEditor", () => { await user.type(input, "Bob"); await user.click(screen.getByRole("button", { name: "Apply" })); - expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob"); + expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob", { + caseSensitive: false, + }); }); it("Enter key calls addFilter with the updated value", async () => { @@ -258,7 +260,9 @@ describe("StringFilterEditor", () => { await user.type(input, "Charlie"); await user.keyboard("{Enter}"); - expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Charlie"); + expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Charlie", { + caseSensitive: false, + }); }); it("Apply button calls removeFilter when the value is cleared", async () => { @@ -278,6 +282,63 @@ describe("StringFilterEditor", () => { expect(control.removeFilter).toHaveBeenCalledWith("name"); }); + + it("shows a Case sensitive checkbox", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "name", operator: "contains", value: "Alice" }], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Name contains Alice/ })); + + expect(await screen.findByText("Case sensitive")).toBeDefined(); + }); + + it("Apply with case-sensitive checked calls addFilter with caseSensitive option", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "name", operator: "contains", value: "Alice" }], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Name contains Alice/ })); + + const checkbox = await screen.findByRole("checkbox"); + await user.click(checkbox); + + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice", { + caseSensitive: true, + }); + }); + + it("restores case-sensitive state from existing filter", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [ + { + field: "name", + operator: "contains", + value: "Alice", + caseSensitive: true, + }, + ], + }); + render(, { + wrapper, + }); + + await user.click(screen.getByRole("button", { name: /Name contains Alice/ })); + + const checkbox = await screen.findByRole("checkbox"); + expect((checkbox as HTMLElement).dataset.checked).toBeDefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 3b2051a9..191d9502 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -175,6 +175,7 @@ function AddFilterPopover({ const [field, setField] = useState(null); const [operator, setOperator] = useState("eq"); const [value, setValue] = useState(""); + const [caseSensitive, setCaseSensitive] = useState(false); const fieldLabelMap = useMemo( () => new Map(availableColumns.map((col) => [col.filter.field, col.label ?? col.filter.field])), @@ -201,12 +202,14 @@ function AddFilterPopover({ setField(null); setOperator("eq"); setValue(""); + setCaseSensitive(false); return; } setField(column.filter.field); setOperator(DEFAULT_OPERATOR[column.filter.type]); setValue(getInitialAddFilterDraftValue(column.filter.type)); + setCaseSensitive(false); }, []); const handleOpenChange = useCallback( @@ -230,6 +233,7 @@ function AddFilterPopover({ setField(nextField); setOperator(DEFAULT_OPERATOR[nextColumn.filter.type]); setValue(getInitialAddFilterDraftValue(nextColumn.filter.type)); + setCaseSensitive(false); }, [availableColumns], ); @@ -242,9 +246,10 @@ function AddFilterPopover({ selectedColumn.filter.field, operator, toAddFilterSubmittedValue(selectedColumn.filter.type, operator, value), + selectedColumn.filter.type === "string" ? { caseSensitive } : undefined, ); setOpen(false); - }, [selectedColumn, value, operator, control]); + }, [selectedColumn, value, operator, caseSensitive, control]); const renderValueEditor = () => { if (!selectedColumn) return null; @@ -434,6 +439,20 @@ function AddFilterPopover({ /> ) : null} {renderValueEditor()} + {selectedColumn?.filter.type === "string" && ( + + )} @@ -990,7 +1024,10 @@ function TemporalFilterEditor({ 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 }); + control.addFilter(config.field, localOp, { + min: localValue, + max: localValueMax, + }); } else { return; } @@ -1282,6 +1319,7 @@ function getChipDisplayLabel( if (!valueLabel) return columnLabel; const operatorLabel = getOperatorLabel(filter.operator, t); + const ciSuffix = filter.caseSensitive ? " (Aa)" : ""; if (config.type === "enum") { return t("filterChipLabelEnum", { @@ -1291,11 +1329,13 @@ function getChipDisplayLabel( }); } - return t("filterChipLabel", { - column: columnLabel, - operator: operatorLabel, - value: valueLabel, - }); + return ( + t("filterChipLabel", { + column: columnLabel, + operator: operatorLabel, + value: valueLabel, + }) + ciSuffix + ); } export { DataTableToolbar, DataTableFilters }; diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index a04d8f63..8d0b5ae7 100644 --- a/packages/core/src/hooks/use-collection-variables.test.ts +++ b/packages/core/src/hooks/use-collection-variables.test.ts @@ -161,6 +161,96 @@ describe("useCollectionVariables", () => { expect(result.current.variables.pagination.after).toBeUndefined(); expect(result.current.variables.pagination).toEqual({ first: 20 }); }); + + it("string filter defaults to case-insensitive regex in query variables", () => { + const { result } = renderHook(() => useCollectionVariables({})); + + act(() => { + result.current.control.addFilter("name", "contains", "Alice", { + caseSensitive: false, + }); + }); + + expect(result.current.control.filters[0]).toMatchObject({ + field: "name", + operator: "contains", + value: "Alice", + caseSensitive: false, + }); + expect(result.current.variables.query).toEqual({ + name: { regex: "(?i)Alice" }, + }); + }); + + it("converts eq operator to regex with anchors when caseSensitive is false", () => { + const { result } = renderHook(() => useCollectionVariables({})); + + act(() => { + result.current.control.addFilter("name", "eq", "Alice", { + caseSensitive: false, + }); + }); + + expect(result.current.variables.query).toEqual({ + name: { regex: "(?i)^Alice$" }, + }); + }); + + it("converts hasPrefix operator to regex when caseSensitive is false", () => { + const { result } = renderHook(() => useCollectionVariables({})); + + act(() => { + result.current.control.addFilter("name", "hasPrefix", "Al", { + caseSensitive: false, + }); + }); + + expect(result.current.variables.query).toEqual({ + name: { regex: "(?i)^Al" }, + }); + }); + + it("converts hasSuffix operator to regex when caseSensitive is false", () => { + const { result } = renderHook(() => useCollectionVariables({})); + + act(() => { + result.current.control.addFilter("name", "hasSuffix", "ce", { + caseSensitive: false, + }); + }); + + expect(result.current.variables.query).toEqual({ + name: { regex: "(?i)ce$" }, + }); + }); + + it("escapes regex special characters in case-insensitive filter value", () => { + const { result } = renderHook(() => useCollectionVariables({})); + + act(() => { + result.current.control.addFilter("name", "contains", "a.b*c", { + caseSensitive: false, + }); + }); + + expect(result.current.variables.query).toEqual({ + name: { regex: "(?i)a\\.b\\*c" }, + }); + }); + + it("uses original operator when caseSensitive is true", () => { + const { result } = renderHook(() => useCollectionVariables({})); + + act(() => { + result.current.control.addFilter("name", "contains", "Alice", { + caseSensitive: true, + }); + }); + + expect(result.current.variables.query).toEqual({ + name: { contains: "Alice" }, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 7e452e0e..52c44f7c 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -16,6 +16,43 @@ import type { } from "@/types/collection"; import { useCursorPagination } from "./use-cursor-pagination"; +// ----------------------------------------------------------------------------- +// Case-insensitive regex conversion helpers +// ----------------------------------------------------------------------------- + +/** Escape special regex characters in a string. */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Convert a string filter operator + value into a case-insensitive regex pattern. + * The resulting string is intended for the Tailor Platform `regex` operator. + */ +function toCaseInsensitiveRegex(operator: FilterOperator, value: string): string { + const escaped = escapeRegex(value); + switch (operator) { + case "eq": + return `(?i)^${escaped}$`; + case "ne": + return `(?i)^(?!${escaped}$).*$`; + case "contains": + return `(?i)${escaped}`; + case "notContains": + return `(?i)^(?!.*${escaped}).*$`; + case "hasPrefix": + return `(?i)^${escaped}`; + case "hasSuffix": + return `(?i)${escaped}$`; + case "notHasPrefix": + return `(?i)^(?!${escaped})`; + case "notHasSuffix": + return `(?i)^(?!.*${escaped}$).*$`; + default: + return `(?i)${escaped}`; + } +} + // ----------------------------------------------------------------------------- // Overload signatures // ----------------------------------------------------------------------------- @@ -120,10 +157,20 @@ export function useCollectionVariables( // Filter operations // --------------------------------------------------------------------------- const addFilter = useCallback( - (field: string, operator: FilterOperator, value: unknown) => { + ( + field: string, + operator: FilterOperator, + value: unknown, + filterOptions?: { caseSensitive?: boolean }, + ) => { setFiltersState((prev) => { const existing = prev.findIndex((f) => f.field === field); - const newFilter: Filter = { field, operator, value }; + const newFilter: Filter = { + field, + operator, + value, + caseSensitive: filterOptions?.caseSensitive, + }; if (existing >= 0) { const updated = [...prev]; updated[existing] = newFilter; @@ -187,7 +234,13 @@ export function useCollectionVariables( if (filters.length === 0) return undefined; const filterQuery: Record> = {}; for (const filter of filters) { - filterQuery[filter.field] = { [filter.operator]: filter.value }; + if (filter.caseSensitive === false && typeof filter.value === "string") { + filterQuery[filter.field] = { + regex: toCaseInsensitiveRegex(filter.operator, filter.value), + }; + } else { + filterQuery[filter.field] = { [filter.operator]: filter.value }; + } } return filterQuery; }, [filters]); diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 2bd3038e..0627c92b 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -112,6 +112,7 @@ export const OPERATORS_BY_FILTER_TYPE = { "notHasSuffix", "in", "nin", + "regex", ], number: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], datetime: ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "nin"], @@ -179,6 +180,7 @@ export interface Filter { field: TFieldName; operator: FilterOperator; value: unknown; + caseSensitive?: boolean; } /** @@ -195,6 +197,7 @@ export type TableMetadataFilter = field: N; operator: OperatorForFilterType[FieldTypeToFilterConfigType[T]]; value: unknown; + caseSensitive?: boolean; } : never : never @@ -375,6 +378,7 @@ export interface CollectionControl< field: F, operator: OperatorForField, value: unknown, + options?: { caseSensitive?: boolean }, ): void; setFilters: (filters: Filter[]) => void; removeFilter(field: TFieldName): void; @@ -455,6 +459,7 @@ export const DEFAULT_OPERATOR_LABELS: Record = { between: "between", in: "in", nin: "not in", + regex: "regex", }; // =============================================================================