Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/string-filter-case-sensitive.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 9 additions & 2 deletions examples/nextjs-app/src/modules/pages/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/components/data-table/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down Expand Up @@ -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 }) =>
Expand Down
65 changes: 63 additions & 2 deletions packages/core/src/components/data-table/toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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(<TestFilters control={control} columns={[stringColumn]} />, {
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(<TestFilters control={control} columns={[stringColumn]} />, {
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(<TestFilters control={control} columns={[stringColumn]} />, {
wrapper,
});

await user.click(screen.getByRole("button", { name: /Name contains Alice/ }));

const checkbox = await screen.findByRole("checkbox");
expect((checkbox as HTMLElement).dataset.checked).toBeDefined();
});
});

// ---------------------------------------------------------------------------
Expand Down
58 changes: 49 additions & 9 deletions packages/core/src/components/data-table/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ function AddFilterPopover({
const [field, setField] = useState<string | null>(null);
const [operator, setOperator] = useState<FilterOperator>("eq");
const [value, setValue] = useState<AddFilterDraftValue>("");
const [caseSensitive, setCaseSensitive] = useState(false);
Comment thread
IzumiSy marked this conversation as resolved.

const fieldLabelMap = useMemo(
() => new Map(availableColumns.map((col) => [col.filter.field, col.label ?? col.filter.field])),
Expand All @@ -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(
Expand All @@ -230,6 +233,7 @@ function AddFilterPopover({
setField(nextField);
setOperator(DEFAULT_OPERATOR[nextColumn.filter.type]);
setValue(getInitialAddFilterDraftValue(nextColumn.filter.type));
setCaseSensitive(false);
},
[availableColumns],
);
Expand All @@ -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;
Expand Down Expand Up @@ -434,6 +439,20 @@ function AddFilterPopover({
/>
) : null}
{renderValueEditor()}
{selectedColumn?.filter.type === "string" && (
<label className="astw:flex astw:items-center astw:gap-1.5 astw:text-sm">
<Checkbox.Root
checked={caseSensitive}
onCheckedChange={setCaseSensitive}
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"
>
<Checkbox.Indicator className="astw:flex astw:items-center astw:justify-center">
<Check className="astw:size-3" />
</Checkbox.Indicator>
</Checkbox.Root>
{t("filterCaseSensitive")}
</label>
)}
<Button
size="xs"
onClick={handleSubmit}
Expand Down Expand Up @@ -731,15 +750,18 @@ function StringFilterEditor({
: "contains",
);
const [localValue, setLocalValue] = useState(String(filter.value ?? ""));
const [localCaseSensitive, setLocalCaseSensitive] = useState(filter.caseSensitive ?? false);

const handleCommit = useCallback(() => {
if (localValue.trim() === "") {
control.removeFilter(config.field);
} else {
control.addFilter(config.field, localOp, localValue);
control.addFilter(config.field, localOp, localValue, {
caseSensitive: localCaseSensitive,
});
}
onClose();
}, [localValue, localOp, control, config.field, onClose]);
}, [localValue, localOp, localCaseSensitive, control, config.field, onClose]);

return (
<div
Expand All @@ -763,6 +785,18 @@ function StringFilterEditor({
}}
className="astw:h-8 astw:text-sm"
/>
<label className="astw:flex astw:items-center astw:gap-1.5 astw:text-sm">
<Checkbox.Root
checked={localCaseSensitive}
onCheckedChange={setLocalCaseSensitive}
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"
>
<Checkbox.Indicator className="astw:flex astw:items-center astw:justify-center">
<Check className="astw:size-3" />
</Checkbox.Indicator>
</Checkbox.Root>
{t("filterCaseSensitive")}
</label>
<Button size="xs" onClick={handleCommit} className="astw:self-end">
{t("applyFilter")}
</Button>
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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", {
Expand All @@ -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 };
90 changes: 90 additions & 0 deletions packages/core/src/hooks/use-collection-variables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading