feat(grid): add persistent SQL filter views

This commit is contained in:
zipg authored and GitHub committed 2026-08-15 14:45:52 +08:00
1 parent 8bf4485a75
commit 9e7d80f467
27 files changed
+1428 -107

No files matched your search

@@ -36,9 +36,11 @@ vi.mock("@lucide/vue", async () => {
Copy: icon,
Eye: icon,
EyeOff: icon,
GripVertical: icon,
Info: icon,
Pencil: icon,
Plus: icon,
RotateCcw: icon,
Trash2: icon,
};
});
@@ -90,6 +92,8 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
@@ -435,6 +439,48 @@ describe("DataGridColumnHeader", () => {
});
describe("DataGridFilterBuilder", () => {
it("renders a compact text rule without framed form controls", async () => {
const updateRule = vi.fn();
const add = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "account_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" }],
columns: ["account_id"],
filteredColumns: ["account_id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
layout: "text",
showHeader: false,
showFooter: false,
onUpdateRule: updateRule,
onAdd: add,
});
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[18px_22px_var(--filter-builder-column-width)_92px_minmax(140px,1fr)_auto]"));
const enabledCheckbox = findOne(mounted.root, (node) => node.props.role === "checkbox");
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
const addButton = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.filterBuilderAddRule");
expect(enabledCheckbox.props["aria-checked"]).toBe(true);
expect(triggers.every((trigger) => String(trigger.props.class).includes("border-0"))).toBe(true);
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderTextValue");
expect(String(ruleGrid.props.class)).toContain("border-b");
dispatch(enabledCheckbox, "click");
dispatch(addButton, "click");
dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: true });
await mounted.setProps({
rules: [
{ id: "r1", columnName: "account_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" },
],
});
await nextTick();
expect(updateRule).toHaveBeenCalledWith("r1", { disabled: true });
expect(add).toHaveBeenCalledTimes(2);
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[2].props.open).toBe(false);
});
it("opens the first empty rule column search on request", async () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
@@ -464,7 +510,7 @@ describe("DataGridFilterBuilder", () => {
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
const filterBuilder = findOne(mounted.root, (node) => String(node.props.class).includes("w-fit max-w-full"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
@@ -479,7 +525,7 @@ describe("DataGridFilterBuilder", () => {
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "178px", "--filter-builder-value-width": "178px" });
expect(String(ruleGrid.props.class)).toContain("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]");
expect(String(ruleGrid.props.class)).toContain("justify-start");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
@@ -502,6 +548,31 @@ describe("DataGridFilterBuilder", () => {
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "88px", "--filter-builder-value-width": "178px" });
});
it("renders reorder handles and supports keyboard condition moves", () => {
const move = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [
{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "name", mode: "equals", rawValue: "Alice", rawEndValue: "", conjunction: "AND", disabled: true },
],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onMove: move,
});
const handles = findAll(mounted.root, (node) => node.props["data-filter-drag-handle"] === "");
expect(handles).toHaveLength(2);
expect(handles.every((handle) => handle.props.draggable === true)).toBe(true);
expect(handles[0].props["aria-label"]).toBe("grid.filterBuilderReorderRule");
const arrowDown = dispatch(handles[0], "keydown", { key: "ArrowDown" });
expect(arrowDown.defaultPrevented).toBe(true);
expect(move).toHaveBeenCalledWith("r1", 1);
dispatch(handles[1], "keydown", { key: "ArrowUp" });
expect(move).toHaveBeenLastCalledWith("r2", 0);
});
it("keeps search focus while navigating and selecting filtered columns", async () => {
const onUpdateRule = vi.fn();
const onAdd = vi.fn();
@@ -730,6 +801,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: false,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
@@ -772,6 +844,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: false,
filterEditorView: "quick",
filterButtonActive: true,
filterButtonCount: 1,
hasLocalColumnFilters: false,
@@ -807,6 +880,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
@@ -824,6 +898,10 @@ describe("DataGridQueryControls", () => {
expect(String(popoverContent.props.class)).toContain("w-fit");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-16px)]");
expect(String(popoverContent.props.class)).toContain("max-h-[var(--reka-popover-content-available-height)]");
expect(String(popoverContent.props.class)).toContain("overflow-y-auto");
expect(popoverContent.props["data-filter-rules-scroll"]).toBe("");
expect(popoverContent.props["collision-padding"]).toBe(8);
});
it("keeps filter actions available in the popover", () => {
@@ -841,6 +919,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: true,
filterButtonCount: 1,
hasLocalColumnFilters: false,
@@ -886,6 +965,205 @@ describe("DataGridQueryControls", () => {
expect(resetFilters).toHaveBeenCalledOnce();
expect(applyFilters).toHaveBeenCalledOnce();
});
it("keeps view selection out of the data grid controls", async () => {
const mounted = mountComponent(DataGridQueryControls, {
whereInput: "",
orderByInput: "",
columns: ["id"],
conditionColumns: ["id"],
historyScope: {},
canUseWhereSearch: true,
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
});
expect(hostText(mounted.root)).not.toContain("grid.filterQuickView");
expect(hostText(mounted.root)).not.toContain("grid.filterConditionView");
expect(findAll(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"))).toHaveLength(1);
await mounted.setProps({ filterEditorView: "conditions", filterBuilderOpen: false });
await nextTick();
expect(findOne(mounted.root, (node) => node.type === "textarea" && node.props.placeholder === "WHERE")).toBeTruthy();
expect(findAll(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"))).toHaveLength(0);
});
});
describe("DataGridFilterWorkbench", () => {
it("scrolls to the newest rule when a condition is added", async () => {
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
const firstRule = { id: "r1", columnName: "id", mode: "equals" as const, rawValue: "1", rawEndValue: "", conjunction: "AND" as const };
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "WHERE id = 1",
rules: [firstRule],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
const scroller = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "") as any;
scroller.scrollTop = 0;
scroller.scrollHeight = 480;
await mounted.setProps({ rules: [firstRule, { ...firstRule, id: "r2", columnName: "name" }] });
await nextTick();
expect(scroller.scrollTop).toBe(480);
requestAnimationFrame.mockRestore();
});
it("does not auto-open field search in the conditions view", async () => {
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "",
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
await nextTick();
expect(findOne(mounted.root, (node) => node.props["data-stub"] === "Select").props.open).toBe(false);
});
it("exposes persistent condition editing, SQL preview, and explicit actions", async () => {
const ensureRule = vi.fn();
const addRule = vi.fn();
const apply = vi.fn();
const reset = vi.fn();
const clear = vi.fn();
const copySql = vi.fn();
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "WHERE (tenant_id = 7) AND (status = 'open')",
rules: [
{ id: "r1", columnName: "tenant_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "status", mode: "equals", rawValue: "open", rawEndValue: "", conjunction: "AND" },
],
columns: ["id", "tenant_id", "status"],
filteredColumns: ["id", "tenant_id", "status"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onEnsureRule: ensureRule,
onAddRule: addRule,
onApply: apply,
onReset: reset,
onClear: clear,
onCopySql: copySql,
});
await nextTick();
expect(ensureRule).toHaveBeenCalledOnce();
expect(hostText(mounted.root)).not.toContain("grid.filterQuickView");
expect(hostText(mounted.root)).not.toContain("grid.filterConditionView");
expect(hostText(mounted.root)).toContain("grid.clearFilter");
expect(hostText(mounted.root)).toContain("WHERE (tenant_id = 7) AND (status = 'open')");
const ruleScroller = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "");
expect(String(ruleScroller.props.class)).toContain("overflow-auto");
expect(findAll(mounted.root, (node) => node.type === "button" && hostText(node) === "AND")).toHaveLength(0);
dispatch(
findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copyFilterSql"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.filterBuilderAddRule"),
"click",
);
const resetButton = findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.resetFilterBuilder");
expect(resetButton.props.variant).toBe("outline");
dispatch(resetButton, "click");
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.applyFilter"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.clearFilter"),
"click",
);
expect(copySql).toHaveBeenCalledOnce();
expect(addRule).toHaveBeenCalledOnce();
expect(reset).toHaveBeenCalledOnce();
expect(apply).toHaveBeenCalledOnce();
expect(clear).toHaveBeenCalledOnce();
});
it("shows the empty preview state without enabling copy", () => {
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "",
rules: [],
columns: [],
filteredColumns: [],
modeOptions: [],
columnSearch: "",
});
expect(hostText(mounted.root)).toContain("grid.filterSqlPreviewEmpty");
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copyFilterSql").props.disabled).toBe(true);
});
});
describe("DataGridTextFilterWorkbench", () => {
it("exposes a persisted keyboard-resizable text filter panel", async () => {
const ensureRule = vi.fn();
const updateHeight = vi.fn();
const addRule = vi.fn();
const mounted = mountComponent(DataGridTextFilterWorkbench, {
height: 168,
sqlPreview: "WHERE account_id = 7",
rules: [{ id: "r1", columnName: "account_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" }],
columns: ["account_id"],
filteredColumns: ["account_id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onEnsureRule: ensureRule,
onAddRule: addRule,
"onUpdate:height": updateHeight,
});
await nextTick();
const panel = findOne(mounted.root, (node) => node.props["data-grid-text-filter-workbench"] === "");
const rulesArea = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "") as any;
const resizeHandle = findOne(mounted.root, (node) => node.props.role === "separator");
const resetButton = findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.resetFilterBuilder");
expect(ensureRule).toHaveBeenCalledOnce();
expect(panel.props.style).toEqual({ height: "168px", maxHeight: "55vh" });
expect(resizeHandle.props["aria-valuenow"]).toBe(168);
const addTooltip = findOne(mounted.root, (node) => node.props["data-stub"] === "Tooltip");
expect(addTooltip.props.delayDuration ?? addTooltip.props["delay-duration"]).toBe(800);
expect(hostText(addTooltip)).toContain("Shift+Enter");
expect(resetButton.props.variant).toBe("outline");
rulesArea.focus = vi.fn();
dispatch(rulesArea, "pointerdown");
const addShortcut = dispatch(rulesArea, "keydown", { key: "Enter", shiftKey: true });
dispatch(rulesArea, "keydown", { key: "Enter", shiftKey: false });
expect(rulesArea.focus).toHaveBeenCalledOnce();
expect(addShortcut.defaultPrevented).toBe(true);
expect(addShortcut.propagationStopped).toBe(true);
expect(addRule).toHaveBeenCalledOnce();
dispatch(resizeHandle, "keydown", { key: "ArrowDown" });
await nextTick();
expect(updateHeight).toHaveBeenCalledWith(176);
});
});
describe("cell detail surfaces", () => {