Skip to content

Commit c2b8856

Browse files
authored
feat(DataTable): support between filter in DataTable.Filters UI (#223)
* Add temporal filters to data table demo * Inline demo product schedule data * Support between filter in DataTable.Filters UI * Simplify * Update jp labels * Format * fix: use async findAllByDisplayValue in between filter tests Replace synchronous getAllByDisplayValue with findAllByDisplayValue to avoid flaky tests when popover content renders via portal on next tick. * fix: add aria-label to BetweenInputGroup inputs Add aria-label attributes to each input so screen readers can distinguish the min/max (from/to) fields. * fix: require both min and max for between filter submission Return undefined when either bound is missing in toAddFilterSubmittedValue, preventing partial range objects that would cause GraphQL validation errors. * fix: add canCommit guard to NumericFilterEditor between mode Add validity checks and disable Apply button when either bound is invalid, consistent with TemporalFilterEditor behavior. Update test to expect no addFilter call when only one bound is set. * fix: require both bounds in between filter validation - Fix TemporalFilterEditor.canCommit to reject partial ranges (matching NumericFilterEditor) - Fix TemporalFilterEditor.handleCommit to use || guard and build complete {min, max} object - Fix isAddFilterDraftValueValid to require both bounds non-empty for between operator
1 parent 536dc86 commit c2b8856

3 files changed

Lines changed: 472 additions & 43 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export const dataTableLabels = defineI18nLabels({
5050
filterOperator_between: "between",
5151
filterOperator_in: "in",
5252
filterOperator_nin: "not in",
53+
filterBetweenFrom: "From",
54+
filterBetweenTo: "To",
55+
filterBetweenMin: "Min",
56+
filterBetweenMax: "Max",
5357

5458
// Filter chip label templates
5559
filterChipLabel: (props: { column: string; operator: string; value: string }) =>
@@ -97,6 +101,10 @@ export const dataTableLabels = defineI18nLabels({
97101
filterOperator_between: "の範囲内",
98102
filterOperator_in: "次のいずれか",
99103
filterOperator_nin: "次のいずれでもない",
104+
filterBetweenFrom: "開始",
105+
filterBetweenTo: "終了",
106+
filterBetweenMin: "最小",
107+
filterBetweenMax: "最大",
100108

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

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

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,3 +653,184 @@ describe("BooleanFilterEditor", () => {
653653
expect(control.addFilter).toHaveBeenCalledWith("enabled", "ne", true);
654654
});
655655
});
656+
657+
// ---------------------------------------------------------------------------
658+
// NumericFilterEditor — between operator (two inputs)
659+
// ---------------------------------------------------------------------------
660+
661+
describe("NumericFilterEditor (between)", () => {
662+
it("shows two number inputs when filter operator is between", async () => {
663+
const user = userEvent.setup();
664+
const control = makeControl({
665+
filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }],
666+
});
667+
render(<TestFilters control={control} columns={[numberColumn]} />, {
668+
wrapper,
669+
});
670+
671+
await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ }));
672+
673+
const inputs = await screen.findAllByRole("spinbutton");
674+
expect(inputs.length).toBe(2);
675+
expect((inputs[0] as HTMLInputElement).value).toBe("10");
676+
expect((inputs[1] as HTMLInputElement).value).toBe("50");
677+
});
678+
679+
it("Apply button calls addFilter with min/max range object", async () => {
680+
const user = userEvent.setup();
681+
const control = makeControl({
682+
filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }],
683+
});
684+
render(<TestFilters control={control} columns={[numberColumn]} />, {
685+
wrapper,
686+
});
687+
688+
await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ }));
689+
690+
const inputs = await screen.findAllByRole("spinbutton");
691+
await user.clear(inputs[0]);
692+
await user.type(inputs[0], "5");
693+
await user.clear(inputs[1]);
694+
await user.type(inputs[1], "100");
695+
await user.click(screen.getByRole("button", { name: "Apply" }));
696+
697+
expect(control.addFilter).toHaveBeenCalledWith("count", "between", {
698+
min: 5,
699+
max: 100,
700+
});
701+
});
702+
703+
it("Apply button does not call addFilter when only min is set and max is empty", async () => {
704+
const user = userEvent.setup();
705+
const control = makeControl({
706+
filters: [{ field: "count", operator: "between", value: { min: 10 } }],
707+
});
708+
render(<TestFilters control={control} columns={[numberColumn]} />, {
709+
wrapper,
710+
});
711+
712+
await user.click(screen.getByRole("button", { name: /Count between 10/ }));
713+
714+
const inputs = await screen.findAllByRole("spinbutton");
715+
// min should already be "10", max should be empty
716+
expect((inputs[0] as HTMLInputElement).value).toBe("10");
717+
expect((inputs[1] as HTMLInputElement).value).toBe("");
718+
719+
await user.click(screen.getByRole("button", { name: "Apply" }));
720+
721+
expect(control.addFilter).not.toHaveBeenCalled();
722+
});
723+
724+
it("Apply button calls removeFilter when both inputs are empty", async () => {
725+
const user = userEvent.setup();
726+
const control = makeControl({
727+
filters: [{ field: "count", operator: "between", value: { min: 10, max: 50 } }],
728+
});
729+
render(<TestFilters control={control} columns={[numberColumn]} />, {
730+
wrapper,
731+
});
732+
733+
await user.click(screen.getByRole("button", { name: /Count between 10 - 50/ }));
734+
735+
const inputs = await screen.findAllByRole("spinbutton");
736+
await user.clear(inputs[0]);
737+
await user.clear(inputs[1]);
738+
await user.click(screen.getByRole("button", { name: "Apply" }));
739+
740+
expect(control.removeFilter).toHaveBeenCalledWith("count");
741+
});
742+
});
743+
744+
// ---------------------------------------------------------------------------
745+
// TemporalFilterEditor — between operator (two inputs)
746+
// ---------------------------------------------------------------------------
747+
748+
describe("TemporalFilterEditor (between)", () => {
749+
it("shows two date inputs when filter operator is between", async () => {
750+
const user = userEvent.setup();
751+
const control = makeControl({
752+
filters: [
753+
{
754+
field: "createdAt",
755+
operator: "between",
756+
value: { min: "2025-01-01", max: "2025-12-31" },
757+
},
758+
],
759+
});
760+
render(<TestFilters control={control} columns={[dateColumn]} />, {
761+
wrapper,
762+
});
763+
764+
await user.click(
765+
screen.getByRole("button", {
766+
name: /Created At between 2025-01-01 - 2025-12-31/,
767+
}),
768+
);
769+
770+
const inputs = await screen.findAllByDisplayValue(/2025/);
771+
expect(inputs.length).toBe(2);
772+
});
773+
774+
it("Apply button calls addFilter with min/max range for date between", async () => {
775+
const user = userEvent.setup();
776+
const control = makeControl({
777+
filters: [
778+
{
779+
field: "createdAt",
780+
operator: "between",
781+
value: { min: "2025-01-01", max: "2025-12-31" },
782+
},
783+
],
784+
});
785+
render(<TestFilters control={control} columns={[dateColumn]} />, {
786+
wrapper,
787+
});
788+
789+
await user.click(
790+
screen.getByRole("button", {
791+
name: /Created At between 2025-01-01 - 2025-12-31/,
792+
}),
793+
);
794+
795+
const inputs = await screen.findAllByDisplayValue(/2025/);
796+
await user.clear(inputs[0]);
797+
await user.type(inputs[0], "2026-03-01");
798+
await user.clear(inputs[1]);
799+
await user.type(inputs[1], "2026-06-30");
800+
await user.click(screen.getByRole("button", { name: "Apply" }));
801+
802+
expect(control.addFilter).toHaveBeenCalledWith("createdAt", "between", {
803+
min: "2026-03-01",
804+
max: "2026-06-30",
805+
});
806+
});
807+
808+
it("Apply button calls removeFilter when both temporal inputs are empty", async () => {
809+
const user = userEvent.setup();
810+
const control = makeControl({
811+
filters: [
812+
{
813+
field: "createdAt",
814+
operator: "between",
815+
value: { min: "2025-01-01", max: "2025-12-31" },
816+
},
817+
],
818+
});
819+
render(<TestFilters control={control} columns={[dateColumn]} />, {
820+
wrapper,
821+
});
822+
823+
await user.click(
824+
screen.getByRole("button", {
825+
name: /Created At between 2025-01-01 - 2025-12-31/,
826+
}),
827+
);
828+
829+
const inputs = await screen.findAllByDisplayValue(/2025/);
830+
await user.clear(inputs[0]);
831+
await user.clear(inputs[1]);
832+
await user.click(screen.getByRole("button", { name: "Apply" }));
833+
834+
expect(control.removeFilter).toHaveBeenCalledWith("createdAt");
835+
});
836+
});

0 commit comments

Comments
 (0)