Skip to content

Commit 642aa1e

Browse files
authored
feat(DataTable): add case-sensitivity control for string filters (#229)
* feat(DataTable): add case-insensitive option to string filters Add a "Case insensitive" checkbox to StringFilterEditor in DataTable.Filters. When enabled, the filter is internally converted to a regex operator with (?i) prefix for case-insensitive matching on the Tailor Platform API. Changes: - Add caseInsensitive?: boolean to Filter type - Add regex to OPERATORS_BY_FILTER_TYPE.string - Extend addFilter with filterOptions parameter - Add toCaseInsensitiveRegex helper that converts operator+value to regex - Add checkbox UI in StringFilterEditor - Show (Aa) suffix on filter chip when case-insensitive is active - Add i18n labels (en/ja) for filterCaseInsensitive - Add tests for UI checkbox and regex conversion logic * fix: add case-insensitive checkbox to AddFilterPopover The checkbox was only in the edit popover (StringFilterEditor). Now also shown in the add-new-filter popover for string type filters. * refactor: flip to caseSensitive (default is case-insensitive) - Rename caseInsensitive -> caseSensitive on Filter type and options - Default behavior (caseSensitive=false or unset via UI) uses regex (?i) - Checkbox now labeled 'Case sensitive'; checking it opts into exact match - Only triggers regex when caseSensitive is explicitly false (backward compat) * fix(example): handle regex operator and case-sensitivity in mock-data - Add regex operator support (parses (?i) prefix for case-insensitive) - Remove unconditional toLowerCase in matchStringOperator so that case-sensitive mode (original operators) works correctly * Format * Add changeset * Format
1 parent 8526e01 commit 642aa1e

8 files changed

Lines changed: 281 additions & 16 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
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.
6+
7+
The `Filter` type and `CollectionControl.addFilter` now accept an optional `caseSensitive` property to control this behavior programmatically.

examples/nextjs-app/src/modules/pages/mock-data.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ function compareValues(left: unknown, right: unknown): number | null {
253253
}
254254

255255
function matchStringOperator(fieldValue: unknown, operator: string, expected: unknown): boolean {
256-
const value = String(fieldValue ?? "").toLowerCase();
257-
const needle = String(expected ?? "").toLowerCase();
256+
const value = String(fieldValue ?? "");
257+
const needle = String(expected ?? "");
258258

259259
switch (operator) {
260260
case "contains":
@@ -284,6 +284,13 @@ function matchOperator(fieldValue: unknown, operator: string, expected: unknown)
284284
return Array.isArray(expected) && expected.some((item) => item === fieldValue);
285285
case "nin":
286286
return Array.isArray(expected) && !expected.some((item) => item === fieldValue);
287+
case "regex": {
288+
const pattern = String(expected ?? "");
289+
const caseInsensitive = pattern.startsWith("(?i)");
290+
const regexBody = caseInsensitive ? pattern.slice(4) : pattern;
291+
const re = new RegExp(regexBody, caseInsensitive ? "i" : "");
292+
return re.test(String(fieldValue ?? ""));
293+
}
287294
case "contains":
288295
case "notContains":
289296
case "hasPrefix":

packages/core/src/components/data-table/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const dataTableLabels = defineI18nLabels({
5454
filterBetweenTo: "To",
5555
filterBetweenMin: "Min",
5656
filterBetweenMax: "Max",
57+
filterCaseSensitive: "Case sensitive",
5758

5859
// Filter chip label templates
5960
filterChipLabel: (props: { column: string; operator: string; value: string }) =>
@@ -105,6 +106,7 @@ export const dataTableLabels = defineI18nLabels({
105106
filterBetweenTo: "終了",
106107
filterBetweenMin: "最小",
107108
filterBetweenMax: "最大",
109+
filterCaseSensitive: "大文字小文字を区別する",
108110

109111
// Filter chip label templates (Japanese: column: value operator)
110112
filterChipLabel: (props: { column: string; operator: string; value: string }) =>

packages/core/src/components/data-table/toolbar.test.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,9 @@ describe("StringFilterEditor", () => {
239239
await user.type(input, "Bob");
240240
await user.click(screen.getByRole("button", { name: "Apply" }));
241241

242-
expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob");
242+
expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Bob", {
243+
caseSensitive: false,
244+
});
243245
});
244246

245247
it("Enter key calls addFilter with the updated value", async () => {
@@ -258,7 +260,9 @@ describe("StringFilterEditor", () => {
258260
await user.type(input, "Charlie");
259261
await user.keyboard("{Enter}");
260262

261-
expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Charlie");
263+
expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Charlie", {
264+
caseSensitive: false,
265+
});
262266
});
263267

264268
it("Apply button calls removeFilter when the value is cleared", async () => {
@@ -278,6 +282,63 @@ describe("StringFilterEditor", () => {
278282

279283
expect(control.removeFilter).toHaveBeenCalledWith("name");
280284
});
285+
286+
it("shows a Case sensitive checkbox", async () => {
287+
const user = userEvent.setup();
288+
const control = makeControl({
289+
filters: [{ field: "name", operator: "contains", value: "Alice" }],
290+
});
291+
render(<TestFilters control={control} columns={[stringColumn]} />, {
292+
wrapper,
293+
});
294+
295+
await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
296+
297+
expect(await screen.findByText("Case sensitive")).toBeDefined();
298+
});
299+
300+
it("Apply with case-sensitive checked calls addFilter with caseSensitive option", async () => {
301+
const user = userEvent.setup();
302+
const control = makeControl({
303+
filters: [{ field: "name", operator: "contains", value: "Alice" }],
304+
});
305+
render(<TestFilters control={control} columns={[stringColumn]} />, {
306+
wrapper,
307+
});
308+
309+
await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
310+
311+
const checkbox = await screen.findByRole("checkbox");
312+
await user.click(checkbox);
313+
314+
await user.click(screen.getByRole("button", { name: "Apply" }));
315+
316+
expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice", {
317+
caseSensitive: true,
318+
});
319+
});
320+
321+
it("restores case-sensitive state from existing filter", async () => {
322+
const user = userEvent.setup();
323+
const control = makeControl({
324+
filters: [
325+
{
326+
field: "name",
327+
operator: "contains",
328+
value: "Alice",
329+
caseSensitive: true,
330+
},
331+
],
332+
});
333+
render(<TestFilters control={control} columns={[stringColumn]} />, {
334+
wrapper,
335+
});
336+
337+
await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));
338+
339+
const checkbox = await screen.findByRole("checkbox");
340+
expect((checkbox as HTMLElement).dataset.checked).toBeDefined();
341+
});
281342
});
282343

283344
// ---------------------------------------------------------------------------

packages/core/src/components/data-table/toolbar.tsx

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ function AddFilterPopover({
175175
const [field, setField] = useState<string | null>(null);
176176
const [operator, setOperator] = useState<FilterOperator>("eq");
177177
const [value, setValue] = useState<AddFilterDraftValue>("");
178+
const [caseSensitive, setCaseSensitive] = useState(false);
178179

179180
const fieldLabelMap = useMemo(
180181
() => new Map(availableColumns.map((col) => [col.filter.field, col.label ?? col.filter.field])),
@@ -201,12 +202,14 @@ function AddFilterPopover({
201202
setField(null);
202203
setOperator("eq");
203204
setValue("");
205+
setCaseSensitive(false);
204206
return;
205207
}
206208

207209
setField(column.filter.field);
208210
setOperator(DEFAULT_OPERATOR[column.filter.type]);
209211
setValue(getInitialAddFilterDraftValue(column.filter.type));
212+
setCaseSensitive(false);
210213
}, []);
211214

212215
const handleOpenChange = useCallback(
@@ -230,6 +233,7 @@ function AddFilterPopover({
230233
setField(nextField);
231234
setOperator(DEFAULT_OPERATOR[nextColumn.filter.type]);
232235
setValue(getInitialAddFilterDraftValue(nextColumn.filter.type));
236+
setCaseSensitive(false);
233237
},
234238
[availableColumns],
235239
);
@@ -242,9 +246,10 @@ function AddFilterPopover({
242246
selectedColumn.filter.field,
243247
operator,
244248
toAddFilterSubmittedValue(selectedColumn.filter.type, operator, value),
249+
selectedColumn.filter.type === "string" ? { caseSensitive } : undefined,
245250
);
246251
setOpen(false);
247-
}, [selectedColumn, value, operator, control]);
252+
}, [selectedColumn, value, operator, caseSensitive, control]);
248253

249254
const renderValueEditor = () => {
250255
if (!selectedColumn) return null;
@@ -434,6 +439,20 @@ function AddFilterPopover({
434439
/>
435440
) : null}
436441
{renderValueEditor()}
442+
{selectedColumn?.filter.type === "string" && (
443+
<label className="astw:flex astw:items-center astw:gap-1.5 astw:text-sm">
444+
<Checkbox.Root
445+
checked={caseSensitive}
446+
onCheckedChange={setCaseSensitive}
447+
className="astw:flex astw:size-4 astw:items-center astw:justify-center astw:rounded-sm astw:border astw:border-input data-[checked]:astw:border-primary data-[checked]:astw:bg-primary data-[checked]:astw:text-primary-foreground"
448+
>
449+
<Checkbox.Indicator className="astw:flex astw:items-center astw:justify-center">
450+
<Check className="astw:size-3" />
451+
</Checkbox.Indicator>
452+
</Checkbox.Root>
453+
{t("filterCaseSensitive")}
454+
</label>
455+
)}
437456
<Button
438457
size="xs"
439458
onClick={handleSubmit}
@@ -731,15 +750,18 @@ function StringFilterEditor({
731750
: "contains",
732751
);
733752
const [localValue, setLocalValue] = useState(String(filter.value ?? ""));
753+
const [localCaseSensitive, setLocalCaseSensitive] = useState(filter.caseSensitive ?? false);
734754

735755
const handleCommit = useCallback(() => {
736756
if (localValue.trim() === "") {
737757
control.removeFilter(config.field);
738758
} else {
739-
control.addFilter(config.field, localOp, localValue);
759+
control.addFilter(config.field, localOp, localValue, {
760+
caseSensitive: localCaseSensitive,
761+
});
740762
}
741763
onClose();
742-
}, [localValue, localOp, control, config.field, onClose]);
764+
}, [localValue, localOp, localCaseSensitive, control, config.field, onClose]);
743765

744766
return (
745767
<div
@@ -763,6 +785,18 @@ function StringFilterEditor({
763785
}}
764786
className="astw:h-8 astw:text-sm"
765787
/>
788+
<label className="astw:flex astw:items-center astw:gap-1.5 astw:text-sm">
789+
<Checkbox.Root
790+
checked={localCaseSensitive}
791+
onCheckedChange={setLocalCaseSensitive}
792+
className="astw:flex astw:size-4 astw:items-center astw:justify-center astw:rounded-sm astw:border astw:border-input data-[checked]:astw:border-primary data-[checked]:astw:bg-primary data-[checked]:astw:text-primary-foreground"
793+
>
794+
<Checkbox.Indicator className="astw:flex astw:items-center astw:justify-center">
795+
<Check className="astw:size-3" />
796+
</Checkbox.Indicator>
797+
</Checkbox.Root>
798+
{t("filterCaseSensitive")}
799+
</label>
766800
<Button size="xs" onClick={handleCommit} className="astw:self-end">
767801
{t("applyFilter")}
768802
</Button>
@@ -990,7 +1024,10 @@ function TemporalFilterEditor({
9901024
const minValid = isTemporalFilterValueValid(config.type, localValue);
9911025
const maxValid = isTemporalFilterValueValid(config.type, localValueMax);
9921026
if (!minValid || !maxValid) return;
993-
control.addFilter(config.field, localOp, { min: localValue, max: localValueMax });
1027+
control.addFilter(config.field, localOp, {
1028+
min: localValue,
1029+
max: localValueMax,
1030+
});
9941031
} else {
9951032
return;
9961033
}
@@ -1282,6 +1319,7 @@ function getChipDisplayLabel(
12821319
if (!valueLabel) return columnLabel;
12831320

12841321
const operatorLabel = getOperatorLabel(filter.operator, t);
1322+
const ciSuffix = filter.caseSensitive ? " (Aa)" : "";
12851323

12861324
if (config.type === "enum") {
12871325
return t("filterChipLabelEnum", {
@@ -1291,11 +1329,13 @@ function getChipDisplayLabel(
12911329
});
12921330
}
12931331

1294-
return t("filterChipLabel", {
1295-
column: columnLabel,
1296-
operator: operatorLabel,
1297-
value: valueLabel,
1298-
});
1332+
return (
1333+
t("filterChipLabel", {
1334+
column: columnLabel,
1335+
operator: operatorLabel,
1336+
value: valueLabel,
1337+
}) + ciSuffix
1338+
);
12991339
}
13001340

13011341
export { DataTableToolbar, DataTableFilters };

packages/core/src/hooks/use-collection-variables.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,96 @@ describe("useCollectionVariables", () => {
161161
expect(result.current.variables.pagination.after).toBeUndefined();
162162
expect(result.current.variables.pagination).toEqual({ first: 20 });
163163
});
164+
165+
it("string filter defaults to case-insensitive regex in query variables", () => {
166+
const { result } = renderHook(() => useCollectionVariables({}));
167+
168+
act(() => {
169+
result.current.control.addFilter("name", "contains", "Alice", {
170+
caseSensitive: false,
171+
});
172+
});
173+
174+
expect(result.current.control.filters[0]).toMatchObject({
175+
field: "name",
176+
operator: "contains",
177+
value: "Alice",
178+
caseSensitive: false,
179+
});
180+
expect(result.current.variables.query).toEqual({
181+
name: { regex: "(?i)Alice" },
182+
});
183+
});
184+
185+
it("converts eq operator to regex with anchors when caseSensitive is false", () => {
186+
const { result } = renderHook(() => useCollectionVariables({}));
187+
188+
act(() => {
189+
result.current.control.addFilter("name", "eq", "Alice", {
190+
caseSensitive: false,
191+
});
192+
});
193+
194+
expect(result.current.variables.query).toEqual({
195+
name: { regex: "(?i)^Alice$" },
196+
});
197+
});
198+
199+
it("converts hasPrefix operator to regex when caseSensitive is false", () => {
200+
const { result } = renderHook(() => useCollectionVariables({}));
201+
202+
act(() => {
203+
result.current.control.addFilter("name", "hasPrefix", "Al", {
204+
caseSensitive: false,
205+
});
206+
});
207+
208+
expect(result.current.variables.query).toEqual({
209+
name: { regex: "(?i)^Al" },
210+
});
211+
});
212+
213+
it("converts hasSuffix operator to regex when caseSensitive is false", () => {
214+
const { result } = renderHook(() => useCollectionVariables({}));
215+
216+
act(() => {
217+
result.current.control.addFilter("name", "hasSuffix", "ce", {
218+
caseSensitive: false,
219+
});
220+
});
221+
222+
expect(result.current.variables.query).toEqual({
223+
name: { regex: "(?i)ce$" },
224+
});
225+
});
226+
227+
it("escapes regex special characters in case-insensitive filter value", () => {
228+
const { result } = renderHook(() => useCollectionVariables({}));
229+
230+
act(() => {
231+
result.current.control.addFilter("name", "contains", "a.b*c", {
232+
caseSensitive: false,
233+
});
234+
});
235+
236+
expect(result.current.variables.query).toEqual({
237+
name: { regex: "(?i)a\\.b\\*c" },
238+
});
239+
});
240+
241+
it("uses original operator when caseSensitive is true", () => {
242+
const { result } = renderHook(() => useCollectionVariables({}));
243+
244+
act(() => {
245+
result.current.control.addFilter("name", "contains", "Alice", {
246+
caseSensitive: true,
247+
});
248+
});
249+
250+
expect(result.current.variables.query).toEqual({
251+
name: { contains: "Alice" },
252+
});
253+
});
164254
});
165255

166256
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)