import type { FieldMetadata, FilterExpression, FilterTree } from "@olenbetong/appframe-core"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { FilterEditor } from "./FilterEditor.js"; const FIELDS: FieldMetadata[] = [ { name: "PersonID", caption: "Person ID", type: "string", nullable: true }, { name: "Age", caption: "Age", type: "number" }, { name: "Created", caption: "Created", type: "datetime" }, { name: "IsActive", caption: "Is active", type: "boolean" }, { name: "Secret", caption: "Secret", type: "string", excludeFromFilter: true }, ]; /** A tree with a single root `and` group holding one expression. */ function filterWith(column: string, operator: string, value: unknown, valueType = "string"): FilterTree { return { type: "group", mode: "and", items: [{ type: "expression", column, operator, value, valueType } as never], }; } function firstExpression(onChange: ReturnType, call = 0) { let [filter] = onChange.mock.calls[call] as [FilterTree]; return filter.items[0] as FilterExpression; } describe("FilterEditor", () => { it("renders one row per expression", () => { render(); expect(screen.getAllByLabelText("Field")).toHaveLength(1); expect(screen.getByLabelText("Operator")).toHaveValue("beginswith"); expect(screen.getByLabelText("Value")).toHaveValue("silv"); }); it("wraps a bare expression in a group", () => { let filter = { type: "expression", column: "Age", operator: "equals", value: "42", valueType: "number", } as never as FilterTree; render(); expect(screen.getByLabelText("Value")).toHaveValue(42); }); it("hides fields marked as excluded from the field picker", async () => { let user = userEvent.setup(); render(); await user.click(screen.getByLabelText("Add condition")); expect(screen.queryByRole("option", { name: "Secret" })).not.toBeInTheDocument(); expect(screen.getByRole("option", { name: "Person ID" })).toBeInTheDocument(); }); it("adds an expression when a field is picked in the blank row", async () => { let user = userEvent.setup(); let onChange = vi.fn(); render(); await user.click(screen.getByLabelText("Add condition")); await user.click(screen.getByRole("option", { name: "Age" })); expect(onChange).toHaveBeenCalledTimes(1); let [filter] = onChange.mock.calls[0] as [FilterTree]; expect(filter.items).toHaveLength(2); // Numbers default to "equals", strings to "begins with". expect(filter.items[1]).toMatchObject({ column: "Age", operator: "equals", valueType: "number" }); }); it("changes the operator and resets the value when the arity changes", async () => { let user = userEvent.setup(); let onChange = vi.fn(); render(); await user.selectOptions(screen.getByLabelText("Operator"), "between"); expect(firstExpression(onChange)).toMatchObject({ operator: "between", value: ["", ""] }); }); it("renders no value editor for parameterless operators", () => { render(); expect(screen.queryByLabelText("Value")).not.toBeInTheDocument(); }); it("renders two inputs for range operators", () => { render(); expect(screen.getByLabelText("From")).toHaveValue(1); expect(screen.getByLabelText("To")).toHaveValue(10); }); it("renders one chip per value for list operators", () => { render(); expect(screen.getByRole("button", { name: "Remove a" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Remove b" })).toBeInTheDocument(); }); it("adds a list value on Enter", async () => { let user = userEvent.setup(); let onChange = vi.fn(); render(); await user.type(screen.getByLabelText("Value"), "b{Enter}"); let [filter] = onChange.mock.calls.at(-1) as [FilterTree]; expect((filter.items[0] as FilterExpression).value).toEqual(["a", "b"]); }); it("switches a date value to a relative value", async () => { let user = userEvent.setup(); let onChange = vi.fn(); render( , ); await user.click(screen.getByRole("button", { name: "Use a relative value" })); expect(firstExpression(onChange)).toMatchObject({ value: "Yesterday", valueType: "special" }); }); it("does not offer relative values for numbers", () => { render(); expect(screen.queryByRole("button", { name: "Use a relative value" })).not.toBeInTheDocument(); }); it("removes a condition", async () => { let user = userEvent.setup(); let onChange = vi.fn(); render(); await user.click(screen.getByRole("button", { name: "Remove condition" })); let [filter] = onChange.mock.calls[0] as [FilterTree]; expect(filter.items).toHaveLength(0); }); it("hides every editing affordance when read only", () => { render(); expect(screen.queryByLabelText("Add condition")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Remove condition" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Add group" })).not.toBeInTheDocument(); expect(screen.getByLabelText("Group operator")).toBeDisabled(); }); it("keeps a row editable when the field is not in the view", () => { render(); expect(screen.getByLabelText("Field")).toHaveValue("Removed"); }); it("uses supplied options as a value list", () => { render( field.name === "PersonID" ? [ { value: "a", label: "Alpha" }, { value: "b", label: "Bravo" }, ] : undefined } />, ); expect(screen.getByLabelText("Value")).toHaveValue("Alpha"); }); it("changes the mode of the root group", async () => { let user = userEvent.setup(); let onChange = vi.fn(); render(); expect(screen.getByLabelText("Group operator")).toHaveValue("and"); await user.selectOptions(screen.getByLabelText("Group operator"), "or"); let [filter] = onChange.mock.calls[0] as [FilterTree]; expect(filter.mode).toBe("or"); }); it("adds and removes nested groups", async () => { let user = userEvent.setup(); let onChange = vi.fn(); let { rerender } = render( , ); expect(screen.queryByRole("button", { name: "Remove group" })).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Add group" })); let [withGroup] = onChange.mock.calls[0] as [FilterTree]; expect(withGroup.items).toHaveLength(2); // A nested group defaults to the opposite mode of its parent. expect(withGroup.items[1]).toMatchObject({ type: "group", mode: "or", items: [] }); rerender(); expect(screen.getAllByLabelText("Group operator")).toHaveLength(2); await user.click(screen.getByRole("button", { name: "Remove group" })); let [removed] = onChange.mock.calls[1] as [FilterTree]; expect(removed.items).toHaveLength(1); }); it("edits a condition inside a nested group", async () => { let user = userEvent.setup(); let onChange = vi.fn(); let filter: FilterTree = { type: "group", mode: "and", items: [ { type: "expression", column: "Age", operator: "equals", value: "1", valueType: "number" }, { type: "group", mode: "or", items: [{ type: "expression", column: "PersonID", operator: "beginswith", value: "a", valueType: "string" }], }, ], }; render(); await user.click(screen.getAllByRole("button", { name: "Remove condition" })[1]); let [next] = onChange.mock.calls[0] as [FilterTree]; expect(next.items[0]).toMatchObject({ column: "Age" }); expect(next.items[1]).toMatchObject({ type: "group", items: [] }); }); it("keeps its own state when uncontrolled", async () => { let user = userEvent.setup(); render(); await user.clear(screen.getByLabelText("Value")); await user.type(screen.getByLabelText("Value"), "gold"); expect(screen.getByLabelText("Value")).toHaveValue("gold"); }); });