/* Copyright 2026 Marimo. All rights reserved. */ import type { Column } from "@tanstack/react-table"; import { fireEvent, render, screen, within } from "@testing-library/react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { DateFilterMenu, NumberFilterMenu, TextFilterMenu, } from "../column-header"; import { Filter } from "../filters"; beforeAll(() => { global.HTMLElement.prototype.scrollIntoView = () => { // jsdom does not implement scrollIntoView; Radix calls it on open. }; // Radix Select gates pointer interactions on hasPointerCapture; jsdom omits it. if (!global.HTMLElement.prototype.hasPointerCapture) { global.HTMLElement.prototype.hasPointerCapture = () => false; } if (!global.HTMLElement.prototype.releasePointerCapture) { global.HTMLElement.prototype.releasePointerCapture = () => { // no-op }; } }); function mockColumn(initial?: ReturnType): Column< unknown, unknown > & { setFilterValue: ReturnType; } { let filterValue = initial; const setFilterValue = vi.fn((next) => { filterValue = next; }); return { id: "age", columnDef: { meta: { dataType: "number", filterType: "number" } }, getFilterValue: () => filterValue, setFilterValue, } as unknown as Column & { setFilterValue: ReturnType; }; } describe("NumberFilterMenu", () => { it("shows all expected operators in the dropdown", () => { const column = mockColumn(); render(); const trigger = screen.getByRole("combobox"); fireEvent.click(trigger); const listbox = screen.getByRole("listbox"); const labels = within(listbox) .getAllByRole("option") .map((o) => o.textContent); expect(labels).toEqual([ "Between", "Equals", "Doesn't equal", "Greater than", "Greater than or equal", "Less than", "Less than or equal", "Is null", "Is not null", ]); }); it("between mode disables Apply until both min and max are defined", () => { const column = mockColumn(); render(); const apply = screen.getByRole("button", { name: /apply/i }); expect(apply).toBeDisabled(); const min = screen.getByLabelText("min"); fireEvent.change(min, { target: { value: "1" } }); fireEvent.blur(min); expect(apply).toBeDisabled(); const max = screen.getByLabelText("max"); fireEvent.change(max, { target: { value: "10" } }); fireEvent.blur(max); expect(apply).not.toBeDisabled(); }); it("comparison mode shows a single value field seeded from current filter", () => { const column = mockColumn(Filter.number({ operator: ">", value: 18 })); render(); const value = screen.getByLabelText("value") as HTMLInputElement; expect(value).toBeInTheDocument(); expect(value.value).toBe("18"); expect(screen.queryByLabelText("min")).not.toBeInTheDocument(); expect(screen.queryByLabelText("max")).not.toBeInTheDocument(); }); it("selecting a nullish operator hides value inputs and commits on Apply", () => { const column = mockColumn(); render(); fireEvent.click(screen.getByRole("combobox")); const listbox = screen.getByRole("listbox"); fireEvent.click(within(listbox).getByText("Is null")); expect(column.setFilterValue).not.toHaveBeenCalled(); expect(screen.queryByLabelText("min")).not.toBeInTheDocument(); expect(screen.queryByLabelText("max")).not.toBeInTheDocument(); expect(screen.queryByLabelText("value")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /apply/i })); expect(column.setFilterValue).toHaveBeenCalledWith( Filter.number({ operator: "is_null" }), ); }); }); function mockTextColumn(initial?: ReturnType): Column< unknown, unknown > & { setFilterValue: ReturnType; } { let filterValue = initial; const setFilterValue = vi.fn((next) => { filterValue = next; }); return { id: "name", columnDef: { meta: { dataType: "string", filterType: "text" } }, getFilterValue: () => filterValue, setFilterValue, } as unknown as Column & { setFilterValue: ReturnType; }; } describe("TextFilterMenu", () => { it("shows all 11 text operators in the dropdown", () => { const column = mockTextColumn(); render(); fireEvent.click(screen.getByRole("combobox")); const listbox = screen.getByRole("listbox"); const labels = within(listbox) .getAllByRole("option") .map((o) => o.textContent); expect(labels).toEqual([ "Contains", "Equals", "Doesn't equal", "Matches regex", "Starts with", "Ends with", "Is in", "Not in", "Is empty", "Is null", "Is not null", ]); }); it("single-string operator renders a text input seeded from current filter", () => { const column = mockTextColumn( Filter.text({ operator: "equals", text: "alice" }), ); render(); const input = screen.getByPlaceholderText("Text...") as HTMLInputElement; expect(input).toBeInTheDocument(); expect(input.value).toBe("alice"); }); it("'in' operator renders the creatable values picker", async () => { const column = mockTextColumn( Filter.text({ operator: "in", values: ["a", "b"] }), ); const calculateTopKRows = vi.fn(async () => ({ data: [["a", 1] as [unknown, number]], })); render( , ); expect( await screen.findByPlaceholderText(/Search or add a value/i), ).toBeInTheDocument(); expect(screen.queryByPlaceholderText("Text...")).not.toBeInTheDocument(); }); it("selecting is_empty hides the value UI and commits on Apply", () => { const column = mockTextColumn(); render(); fireEvent.click(screen.getByRole("combobox")); const listbox = screen.getByRole("listbox"); fireEvent.click(within(listbox).getByText("Is empty")); expect(column.setFilterValue).not.toHaveBeenCalled(); expect(screen.queryByPlaceholderText("Text...")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /apply/i })); expect(column.setFilterValue).toHaveBeenCalledWith( Filter.text({ operator: "is_empty" }), ); }); it("apply is disabled when scalar text is empty", () => { const column = mockTextColumn(); render(); expect(screen.getByRole("button", { name: /apply/i })).toBeDisabled(); fireEvent.change(screen.getByPlaceholderText("Text..."), { target: { value: "x" }, }); expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled(); }); }); type DateFilterValue = ReturnType; function mockDateColumn( filterType: "date" | "datetime" | "time" = "date", initial?: DateFilterValue, ): Column & { setFilterValue: ReturnType; } { let filterValue = initial; const setFilterValue = vi.fn((next) => { filterValue = next; }); return { id: "when", columnDef: { meta: { dataType: filterType, filterType } }, getFilterValue: () => filterValue, setFilterValue, } as unknown as Column & { setFilterValue: ReturnType; }; } describe("DateFilterMenu", () => { it("shows all expected operators in the dropdown", () => { const column = mockDateColumn("date"); render(); fireEvent.click(screen.getByRole("combobox")); const listbox = screen.getByRole("listbox"); const labels = within(listbox) .getAllByRole("option") .map((o) => o.textContent); expect(labels).toEqual([ "Between", "Equals", "Doesn't equal", "Greater than", "Greater than or equal", "Less than", "Less than or equal", "Is null", "Is not null", ]); }); it("defaults to between mode and disables Apply until both bounds set", () => { const column = mockDateColumn("date"); render(); expect(screen.getByLabelText("range")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /apply/i })).toBeDisabled(); expect(screen.queryByLabelText("value")).not.toBeInTheDocument(); }); it("seeds between min/max from current filter", () => { const column = mockDateColumn( "date", Filter.date({ operator: "between", min: new Date("2024-01-01T00:00:00Z"), max: new Date("2024-06-01T00:00:00Z"), }), ); render(); expect(screen.getByLabelText("range")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled(); }); it("comparison operator swaps range for a single value picker", () => { const column = mockDateColumn( "date", Filter.date({ operator: ">", value: new Date("2024-01-01T00:00:00Z"), }), ); render(); expect(screen.getByLabelText("value")).toBeInTheDocument(); expect(screen.queryByLabelText("range")).not.toBeInTheDocument(); }); it("time filter type renders two TimeFields for between", () => { const column = mockDateColumn("time"); render(); expect(screen.getByLabelText("min")).toBeInTheDocument(); expect(screen.getByLabelText("max")).toBeInTheDocument(); }); it("selecting a nullish operator hides value inputs and commits on Apply", () => { const column = mockDateColumn("date"); render(); fireEvent.click(screen.getByRole("combobox")); const listbox = screen.getByRole("listbox"); fireEvent.click(within(listbox).getByText("Is null")); expect(screen.queryByLabelText("range")).not.toBeInTheDocument(); expect(screen.queryByLabelText("value")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /apply/i })); expect(column.setFilterValue).toHaveBeenCalledWith( Filter.date({ operator: "is_null" }), ); }); });