feat(grid): decompose data grid interaction surfaces

This commit is contained in:
t8y2 committed 2026-07-16 14:00:02 +08:00
1 parent 71497f0851
commit 8825b6335f
56 files changed
+6446 -4051

No files matched your search

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { claimDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
function target(nativeClipboard: boolean): EventTarget {
return {
@@ -49,3 +49,26 @@ describe("claimDataGridPaste", () => {
expect(event.stopPropagation).toHaveBeenCalledOnce();
});
});
describe("planDataGridPaste", () => {
it("flattens a clipboard matrix within grid bounds", () => {
expect(
planDataGridPaste(
[
["a", "b"],
["c", "d"],
],
2,
1,
),
).toEqual([
{ rowOffset: 0, columnOffset: 0, value: "a" },
{ rowOffset: 1, columnOffset: 0, value: "c" },
]);
});
it("returns no cells for empty bounds", () => {
expect(planDataGridPaste([["a"]], 0, 1)).toEqual([]);
expect(planDataGridPaste([["a"]], 1, 0)).toEqual([]);
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { createDataGridCellContextMenuItems, createDataGridColumnContextMenuItems, createDataGridCompactColumnActionItems, createDataGridFilterSubmenu, createDataGridRowContextMenuItems, createDataGridSortMenuItems, dataGridSelectedSortMenuValue } from "@/lib/dataGrid/dataGridContextMenu";
const icon = {};
describe("dataGridContextMenu", () => {
it("marks only the active sort action and enables clear", () => {
const state = { column: "id", columnIndex: 0, direction: "desc" as const, mode: "local" as const };
const items = createDataGridSortMenuItems({
column: "id",
columnIndex: 0,
state,
labels: { databaseAscending: "db asc", databaseDescending: "db desc", currentPageAscending: "page asc", currentPageDescending: "page desc", clear: "clear" },
icons: { database: icon, ascending: icon, descending: icon, clear: icon },
});
expect(items.map((item) => item.checked)).toEqual([false, false, false, true, undefined]);
expect(items.at(-1)?.disabled).toBe(false);
expect(dataGridSelectedSortMenuValue(state, "id", 0)).toBe("local-desc");
});
it("omits unavailable server actions and disables unavailable formatter", () => {
const items = createDataGridCompactColumnActionItems({
labels: { formatter: "formatter", localFilter: "local", serverFilter: "server" },
icons: { formatter: icon, filter: icon, database: icon },
formatterAvailable: false,
serverFilterAvailable: false,
});
expect(items.map((item) => item.value)).toEqual(["formatter", "localFilter"]);
expect(items[0]?.disabled).toBe(true);
});
it("builds typed column, cell, and row capability groups", () => {
const action = vi.fn();
const filter = createDataGridFilterSubmenu({
label: "filter",
icon,
labels: { equals: "equals", notEquals: "not equals", like: "like", notLike: "not like", lessThan: "less", greaterThan: "greater", isNull: "null", isNotNull: "not null", clear: "clear" },
apply: action,
clear: action,
});
const columnItems = createDataGridColumnContextMenuItems({
headerColumn: true,
contextColumn: true,
canCopyAlterSql: true,
canFilter: true,
hasSort: true,
sortMode: "database",
labels: { copyName: "copy name", copyNames: "copy names", details: "details", copyAlterSql: "alter", databaseAscending: "db asc", databaseDescending: "db desc", localAscending: "local asc", localDescending: "local desc", clearSort: "clear sort" },
icons: { copy: icon, columnDetails: icon, database: icon, ascending: icon, descending: icon, clearSort: icon },
actions: { copyName: action, copyNames: action, details: action, copyAlterSql: action, sort: action },
filterSubmenu: filter,
});
expect(columnItems.map((item) => item.label)).toContain("filter");
const cellItems = createDataGridCellContextMenuItems({
hasCell: true,
hasColumn: true,
headerColumn: false,
editable: true,
hasCellSelection: true,
hasEditableSelection: false,
hasSelection: true,
labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "null", bulkEdit: "bulk", transpose: "transpose" },
icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon },
actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action },
copySubmenu: { label: "copy" },
selectionSubmenu: { label: "selection" },
});
expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true);
const rowItems = createDataGridRowContextMenuItems({
editable: true,
hasRow: true,
canClone: true,
deleted: false,
canDelete: true,
labels: { clone: "clone", restore: "restore", delete: "delete" },
icons: { clone: icon, restore: icon, delete: icon },
actions: { clone: action, restore: action, delete: action },
});
expect(rowItems.find((item) => item.label === "delete")?.variant).toBe("destructive");
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { dataGridEditAdapterKind } from "@/lib/dataGrid/dataGridEditAdapter";
describe("dataGridEditAdapter", () => {
it("prioritizes custom save handlers", () => {
expect(dataGridEditAdapterKind({ databaseType: "mongodb", custom: true, editable: true })).toBe("custom");
});
it("distinguishes document and relational editing", () => {
expect(dataGridEditAdapterKind({ databaseType: "mongodb", custom: false, editable: true })).toBe("document");
expect(dataGridEditAdapterKind({ databaseType: "postgresql", custom: false, editable: true })).toBe("relational");
});
it("keeps read-only and unknown contexts unsupported", () => {
expect(dataGridEditAdapterKind({ databaseType: "postgresql", custom: false, editable: false })).toBe("unsupported");
expect(dataGridEditAdapterKind({ custom: false, editable: true })).toBe("unsupported");
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
describe("data grid filter builder persistence", () => {
it("isolates scopes and returns cloned state", () => {
saveDataGridStructuredFilterState("cache", {
scopeKey: "scope-a",
manualWhereInput: "status = 1",
rules: [{ id: "rule-1", columnName: "status", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
appliedWhereInput: '"status" = 1',
serverColumnFilters: { 0: { condition: '"status" = 1', keys: ["1"], labels: ["1"] } },
});
expect(loadDataGridStructuredFilterState("cache", "scope-b")).toBeUndefined();
const restored = loadDataGridStructuredFilterState("cache", "scope-a")!;
restored.rules[0].rawValue = "2";
restored.serverColumnFilters[0].keys.push("2");
expect(loadDataGridStructuredFilterState("cache", "scope-a")).toMatchObject({
rules: [{ rawValue: "1" }],
serverColumnFilters: { 0: { keys: ["1"] } },
});
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { moveDataGridCell, navigateDataGridCell } from "@/lib/dataGrid/dataGridNavigation";
const bounds = { rowCount: 3, visibleColumnCount: 4 };
describe("dataGridNavigation", () => {
it("clamps directional movement to grid boundaries", () => {
expect(moveDataGridCell({ rowIndex: 0, colIndex: 0 }, -1, -1, bounds)).toEqual({ rowIndex: 0, colIndex: 0 });
expect(moveDataGridCell({ rowIndex: 2, colIndex: 3 }, 1, 1, bounds)).toEqual({ rowIndex: 2, colIndex: 3 });
expect(moveDataGridCell({ rowIndex: 1, colIndex: 2 }, -1, 1, bounds)).toEqual({ rowIndex: 0, colIndex: 3 });
});
it("supports home and end navigation without changing the row", () => {
expect(navigateDataGridCell({ rowIndex: 2, colIndex: 2 }, "home", bounds)).toEqual({ rowIndex: 2, colIndex: 0 });
expect(navigateDataGridCell({ rowIndex: 2, colIndex: 0 }, "end", bounds)).toEqual({ rowIndex: 2, colIndex: 3 });
});
it("returns no target for an empty grid", () => {
expect(navigateDataGridCell({ rowIndex: 0, colIndex: 0 }, "down", { rowCount: 0, visibleColumnCount: 4 })).toBeNull();
});
});
@@ -0,0 +1,46 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
describe("dataGridRuntime", () => {
it("disposes registered resources in reverse order and only once", () => {
const scope = createDataGridRuntimeScope();
const calls: string[] = [];
const removeFirst = scope.addCleanup(() => calls.push("first"));
scope.addCleanup(() => calls.push("second"));
removeFirst();
scope.dispose();
scope.dispose();
expect(calls).toEqual(["second"]);
expect(scope.disposed).toBe(true);
});
it("runs cleanup immediately when registered after disposal", () => {
const scope = createDataGridRuntimeScope();
const cleanup = vi.fn();
scope.dispose();
const unregister = scope.addCleanup(cleanup);
unregister();
expect(cleanup).toHaveBeenCalledOnce();
});
it("keeps runtime modules independent from the DataGrid component", () => {
const sourceRoots = [join(process.cwd(), "apps/desktop/src/lib/dataGrid"), join(process.cwd(), "apps/desktop/src/composables")];
const sourceFiles: string[] = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) visit(path);
else if (entry.name.endsWith(".ts") && (!directory.endsWith("/composables") || entry.name.startsWith("useDataGrid"))) sourceFiles.push(path);
}
};
sourceRoots.forEach(visit);
expect(sourceFiles.filter((path) => readFileSync(path, "utf8").includes("DataGrid.vue"))).toEqual([]);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { dataGridToolbarIntervalOptions, selectDataGridToolbarAutoRefreshInterval, selectDataGridToolbarExportItem, toggleDataGridToolbarAutoRefresh, triggerDataGridToolbarAction, type DataGridToolbarAutoRefreshCapability } from "@/lib/dataGrid/dataGridToolbar";
function autoRefreshCapability(overrides: Partial<DataGridToolbarAutoRefreshCapability> = {}): DataGridToolbarAutoRefreshCapability {
return {
label: "Auto-refresh",
shortLabel: "Auto",
startLabel: "Start auto-refresh",
stopLabel: "Stop auto-refresh",
enabled: false,
intervalSeconds: 10,
intervalOptions: [5, 10, 30],
intervalLabel: (seconds) => `${seconds}s`,
onToggle: vi.fn(),
onSelectInterval: vi.fn(),
...overrides,
};
}
describe("data grid toolbar capabilities", () => {
it("does not invoke hidden or disabled actions", async () => {
const onTrigger = vi.fn();
await expect(triggerDataGridToolbarAction({ label: "Save", visible: false, onTrigger })).resolves.toBe(false);
await expect(triggerDataGridToolbarAction({ label: "Save", disabled: true, onTrigger })).resolves.toBe(false);
expect(onTrigger).not.toHaveBeenCalled();
});
it("invokes enabled save and rollback callbacks independently", async () => {
const save = vi.fn();
const rollback = vi.fn();
await expect(triggerDataGridToolbarAction({ label: "Save", onTrigger: save })).resolves.toBe(true);
await expect(triggerDataGridToolbarAction({ label: "Rollback", onTrigger: rollback })).resolves.toBe(true);
expect(save).toHaveBeenCalledOnce();
expect(rollback).toHaveBeenCalledOnce();
});
it("keeps preset intervals ordered and preserves a persisted custom interval", () => {
expect(dataGridToolbarIntervalOptions([30, 5, 10, 10, 0, -1, 2.5], 15)).toEqual([5, 10, 15, 30]);
});
it("toggles auto-refresh and selects valid intervals", async () => {
const onToggle = vi.fn();
const onSelectInterval = vi.fn();
const capability = autoRefreshCapability({ onToggle, onSelectInterval });
await expect(toggleDataGridToolbarAutoRefresh(capability)).resolves.toBe(true);
await expect(selectDataGridToolbarAutoRefreshInterval(capability, 30)).resolves.toBe(true);
await expect(selectDataGridToolbarAutoRefreshInterval(capability, 99)).resolves.toBe(false);
expect(onToggle).toHaveBeenCalledOnce();
expect(onSelectInterval).toHaveBeenCalledOnce();
expect(onSelectInterval).toHaveBeenCalledWith(30);
});
it("blocks auto-refresh changes while the capability is disabled", async () => {
const onToggle = vi.fn();
const onSelectInterval = vi.fn();
const capability = autoRefreshCapability({ disabled: true, onToggle, onSelectInterval });
await expect(toggleDataGridToolbarAutoRefresh(capability)).resolves.toBe(false);
await expect(selectDataGridToolbarAutoRefreshInterval(capability, 10)).resolves.toBe(false);
expect(onToggle).not.toHaveBeenCalled();
expect(onSelectInterval).not.toHaveBeenCalled();
});
it("rejects disabled or unknown export items", async () => {
const onSelect = vi.fn();
const capability = {
label: "Export",
items: [
{ value: "csv", label: "CSV" },
{ value: "sql", label: "SQL", disabled: true },
],
onSelect,
};
await expect(selectDataGridToolbarExportItem(capability, "csv")).resolves.toBe(true);
await expect(selectDataGridToolbarExportItem(capability, "sql")).resolves.toBe(false);
await expect(selectDataGridToolbarExportItem(capability, "xlsx")).resolves.toBe(false);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith("csv");
});
});
@@ -2,6 +2,12 @@ import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard";
export type DataGridPasteIntent = "native" | "block" | "paste";
export interface DataGridPasteCell {
rowOffset: number;
columnOffset: number;
value: string;
}
interface DataGridPasteEvent {
target?: EventTarget | null;
preventDefault(): void;
@@ -14,3 +20,14 @@ export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean,
event.stopPropagation();
return editable && hasSelection ? "paste" : "block";
}
export function planDataGridPaste(rows: readonly (readonly string[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] {
if (maxRows <= 0 || maxColumns <= 0) return [];
const cells: DataGridPasteCell[] = [];
rows.slice(0, maxRows).forEach((row, rowOffset) => {
row.slice(0, maxColumns).forEach((value, columnOffset) => {
cells.push({ rowOffset, columnOffset, value });
});
});
return cells;
}
@@ -0,0 +1,216 @@
import type { Component } from "vue";
export type DataGridColumnMenuItem = {
label: string;
value: string;
icon?: Component;
disabled?: boolean;
separatorBefore?: boolean;
checked?: boolean;
};
export type DataGridContextMenuItem = {
label: string;
action?: () => void;
disabled?: boolean;
separator?: boolean;
icon?: Component;
iconClass?: string;
shortcut?: string;
variant?: "default" | "destructive";
visible?: boolean;
children?: DataGridContextMenuItem[];
};
export type DataGridContextMenuIcons = {
copy: Component;
filter: Component;
selection: Component;
export: Component;
database: Component;
ascending: Component;
descending: Component;
clearSort: Component;
cellDetails: Component;
rowDetails: Component;
columnDetails: Component;
setNull: Component;
bulkEdit: Component;
transpose: Component;
clone: Component;
restore: Component;
delete: Component;
};
export function createDataGridFilterSubmenu(options: {
label: string;
icon: Component;
labels: Record<"equals" | "notEquals" | "like" | "notLike" | "lessThan" | "greaterThan" | "isNull" | "isNotNull" | "clear", string>;
apply: (mode: "equals" | "not-equals" | "like" | "not-like" | "less-than" | "greater-than" | "is-null" | "is-not-null") => void;
clear: () => void;
}): DataGridContextMenuItem {
const { labels } = options;
return {
label: options.label,
icon: options.icon,
children: [
{ label: labels.equals, action: () => options.apply("equals") },
{ label: labels.notEquals, action: () => options.apply("not-equals") },
{ label: labels.like, action: () => options.apply("like") },
{ label: labels.notLike, action: () => options.apply("not-like") },
{ label: labels.lessThan, action: () => options.apply("less-than") },
{ label: labels.greaterThan, action: () => options.apply("greater-than") },
{ label: "", separator: true },
{ label: labels.isNull, action: () => options.apply("is-null") },
{ label: labels.isNotNull, action: () => options.apply("is-not-null") },
{ label: "", separator: true },
{ label: labels.clear, action: options.clear },
],
};
}
export function createDataGridColumnContextMenuItems(options: {
headerColumn: boolean;
contextColumn: boolean;
canCopyAlterSql: boolean;
canFilter: boolean;
hasSort: boolean;
sortMode: "database" | "local";
labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort", string>;
icons: Pick<DataGridContextMenuIcons, "copy" | "columnDetails" | "database" | "ascending" | "descending" | "clearSort">;
actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void };
filterSubmenu: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
if (options.headerColumn) {
items.push({ label: options.labels.copyName, action: options.actions.copyName, icon: options.icons.copy });
items.push({ label: options.labels.copyNames, action: options.actions.copyNames, icon: options.icons.copy });
items.push({ label: options.labels.details, action: options.actions.details, icon: options.icons.columnDetails });
if (options.canCopyAlterSql) items.push({ label: options.labels.copyAlterSql, action: options.actions.copyAlterSql, icon: options.icons.copy });
}
if (!options.contextColumn) return items;
items.push(
{ label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database },
{ label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database },
{ label: "", separator: true },
{ label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending },
{ label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending },
);
if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort });
if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu);
items.push({ label: "", separator: true });
return items;
}
export function createDataGridCellContextMenuItems(options: {
hasCell: boolean;
hasColumn: boolean;
headerColumn: boolean;
editable: boolean;
hasCellSelection: boolean;
hasEditableSelection: boolean;
hasSelection: boolean;
labels: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", string>;
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
downloadItem?: DataGridContextMenuItem | null;
copySubmenu: DataGridContextMenuItem;
selectionSubmenu: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
if (options.hasCell) {
if (options.hasColumn) {
items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails });
if (options.downloadItem) items.push(options.downloadItem);
items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails });
}
items.push({ label: options.labels.rowDetails, action: options.actions.rowDetails, icon: options.icons.rowDetails }, { label: "", separator: true });
}
if (!options.headerColumn) items.push(options.copySubmenu);
if (options.editable && options.hasCellSelection) {
if (!options.headerColumn) items.push({ label: options.labels.setNull, action: options.actions.setNull, disabled: !options.hasEditableSelection, icon: options.icons.setNull });
items.push({ label: options.labels.bulkEdit, action: options.actions.bulkEdit, disabled: !options.hasEditableSelection, icon: options.icons.bulkEdit });
}
if (options.hasCell) items.push({ label: options.labels.transpose, action: options.actions.transpose, icon: options.icons.transpose });
if (options.hasSelection) items.push(options.selectionSubmenu);
return items;
}
export function createDataGridRowContextMenuItems(options: {
editable: boolean;
hasRow: boolean;
canClone: boolean;
deleted: boolean;
canDelete: boolean;
labels: Record<"clone" | "restore" | "delete", string>;
icons: Pick<DataGridContextMenuIcons, "clone" | "restore" | "delete">;
actions: Record<"clone" | "restore" | "delete", () => void>;
}): DataGridContextMenuItem[] {
if (!options.editable || !options.hasRow) return [];
const items: DataGridContextMenuItem[] = [{ label: "", separator: true }];
if (options.canClone) items.push({ label: options.labels.clone, action: options.actions.clone, icon: options.icons.clone });
if (options.deleted) items.push({ label: options.labels.restore, action: options.actions.restore, icon: options.icons.restore });
else if (options.canDelete) items.push({ label: options.labels.delete, action: options.actions.delete, icon: options.icons.delete, variant: "destructive" });
items.push({ label: "", separator: true });
return items;
}
export function createDataGridContextMenuItems(...groups: Array<readonly DataGridContextMenuItem[]>): DataGridContextMenuItem[] {
return groups.flat();
}
export type DataGridColumnSortState = {
column: string | null;
columnIndex: number | null;
direction: "asc" | "desc";
mode: "database" | "local";
};
type SortMenuLabels = {
databaseAscending: string;
databaseDescending: string;
currentPageAscending: string;
currentPageDescending: string;
clear: string;
};
type SortMenuIcons = {
database: Component;
ascending: Component;
descending: Component;
clear: Component;
};
export function dataGridColumnIsSorted(state: DataGridColumnSortState, column: string, columnIndex: number): boolean {
return state.column === column && state.columnIndex === columnIndex;
}
export function dataGridSelectedSortMenuValue(state: DataGridColumnSortState, column: string, columnIndex: number): string | undefined {
return dataGridColumnIsSorted(state, column, columnIndex) ? `${state.mode}-${state.direction}` : undefined;
}
export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons }): DataGridColumnMenuItem[] {
const { column, columnIndex, state, labels, icons } = options;
const sorted = dataGridColumnIsSorted(state, column, columnIndex);
return [
{ label: labels.databaseAscending, value: "database-asc", icon: icons.database, checked: sorted && state.direction === "asc" && state.mode === "database" },
{ label: labels.databaseDescending, value: "database-desc", icon: icons.database, checked: sorted && state.direction === "desc" && state.mode === "database" },
{ label: labels.currentPageAscending, value: "local-asc", icon: icons.ascending, checked: sorted && state.direction === "asc" && state.mode === "local", separatorBefore: true },
{ label: labels.currentPageDescending, value: "local-desc", icon: icons.descending, checked: sorted && state.direction === "desc" && state.mode === "local" },
{ label: labels.clear, value: "clear", icon: icons.clear, disabled: !sorted, separatorBefore: true },
];
}
export function createDataGridCompactColumnActionItems(options: {
labels: { formatter: string; localFilter: string; serverFilter: string };
icons: { formatter: Component; filter: Component; database: Component };
formatterAvailable: boolean;
serverFilterAvailable: boolean;
}): DataGridColumnMenuItem[] {
const { labels, icons } = options;
return [
{ label: labels.formatter, value: "formatter", icon: icons.formatter, disabled: !options.formatterAvailable },
{ label: labels.localFilter, value: "localFilter", icon: icons.filter },
...(options.serverFilterAvailable ? [{ label: labels.serverFilter, value: "serverFilter", icon: icons.database }] : []),
];
}
@@ -0,0 +1,20 @@
import type { DatabaseType } from "@/types/database";
import type { DataGridEditAdapter } from "@/lib/dataGrid/dataGridRuntime";
export type DataGridEditAdapterKind = "relational" | "document" | "custom" | "unsupported";
export interface DataGridEditAdapterContext {
databaseType?: DatabaseType;
custom: boolean;
editable: boolean;
}
export function dataGridEditAdapterKind(context: DataGridEditAdapterContext): DataGridEditAdapterKind {
if (context.custom) return "custom";
if (!context.editable) return "unsupported";
if (context.databaseType === "mongodb") return "document";
if (context.databaseType) return "relational";
return "unsupported";
}
export type { DataGridEditAdapter };
@@ -0,0 +1,39 @@
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
export type DataGridCachedServerColumnFilter = {
condition: string;
keys: string[];
labels: string[];
};
export type DataGridStructuredFilterCacheState = {
scopeKey: string;
manualWhereInput: string;
rules: DataGridStructuredFilterRule[];
appliedWhereInput: string;
serverColumnFilters: Record<number, DataGridCachedServerColumnFilter>;
};
const structuredFilterStateCache = new Map<string, DataGridStructuredFilterCacheState>();
export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStructuredFilterRule[]): DataGridStructuredFilterRule[] {
return rules.map((rule) => ({ ...rule }));
}
export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: string): DataGridStructuredFilterCacheState | undefined {
const cached = structuredFilterStateCache.get(cacheKey);
if (!cached || cached.scopeKey !== scopeKey) return undefined;
return {
...cached,
rules: cloneDataGridStructuredFilterRules(cached.rules),
serverColumnFilters: structuredClone(cached.serverColumnFilters),
};
}
export function saveDataGridStructuredFilterState(cacheKey: string, state: DataGridStructuredFilterCacheState) {
structuredFilterStateCache.set(cacheKey, {
...state,
rules: cloneDataGridStructuredFilterRules(state.rules),
serverColumnFilters: structuredClone(state.serverColumnFilters),
});
}
@@ -0,0 +1,38 @@
import type { CellPosition } from "@/lib/dataGrid/gridSelection";
export type DataGridNavigationDirection = "up" | "down" | "left" | "right" | "home" | "end";
export interface DataGridNavigationBounds {
rowCount: number;
visibleColumnCount: number;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
export function moveDataGridCell(position: CellPosition, rowDelta: number, columnDelta: number, bounds: DataGridNavigationBounds): CellPosition | null {
if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null;
return {
rowIndex: clamp(position.rowIndex + rowDelta, 0, bounds.rowCount - 1),
colIndex: clamp(position.colIndex + columnDelta, 0, bounds.visibleColumnCount - 1),
};
}
export function navigateDataGridCell(position: CellPosition, direction: DataGridNavigationDirection, bounds: DataGridNavigationBounds): CellPosition | null {
if (bounds.rowCount <= 0 || bounds.visibleColumnCount <= 0) return null;
switch (direction) {
case "up":
return moveDataGridCell(position, -1, 0, bounds);
case "down":
return moveDataGridCell(position, 1, 0, bounds);
case "left":
return moveDataGridCell(position, 0, -1, bounds);
case "right":
return moveDataGridCell(position, 0, 1, bounds);
case "home":
return { rowIndex: position.rowIndex, colIndex: 0 };
case "end":
return { rowIndex: position.rowIndex, colIndex: bounds.visibleColumnCount - 1 };
}
}
@@ -0,0 +1,84 @@
import type { CellPosition, CellSelectionRange } from "@/lib/dataGrid/gridSelection";
export type GridCoordinate = CellPosition;
export type GridRange = CellSelectionRange;
export interface GridEditPatch<Row = unknown, Value = unknown> {
rowIndex: number;
columnIndex: number;
previousValue: Value;
nextValue: Value;
row: Row;
}
export interface GridColumnLayout {
order: readonly string[];
hidden: ReadonlySet<string>;
pinned: ReadonlySet<string>;
widths: Readonly<Record<string, number>>;
}
export interface DataGridRuntimeScope {
readonly disposed: boolean;
addCleanup(cleanup: () => void): () => void;
dispose(): void;
}
export interface DataGridCapability {
readonly name: string;
dispose?(): void;
}
export interface DataGridSelectionCapability extends DataGridCapability {
getRange(): GridRange | null;
setRange(range: GridRange | null): void;
isSelected(position: GridCoordinate): boolean;
}
export interface DataGridNavigationCapability extends DataGridCapability {
moveFocus(position: GridCoordinate, direction: "up" | "down" | "left" | "right" | "home" | "end"): GridCoordinate;
}
export interface DataGridEditAdapter<Row = unknown, Value = unknown, Patch = GridEditPatch<Row, Value>> {
canEditCell(row: Row, columnIndex: number): boolean;
createPatch(input: GridEditPatch<Row, Value>): Patch | null;
applyPatch(patch: Patch): Promise<void> | void;
}
export interface DataGridRuntimeDependencies<Result = unknown> {
readonly result: Result;
readonly rowCount: number;
readonly columnCount: number;
readonly registerCleanup: DataGridRuntimeScope["addCleanup"];
}
export function createDataGridRuntimeScope(): DataGridRuntimeScope {
const cleanups: Array<() => void> = [];
let disposed = false;
return {
get disposed() {
return disposed;
},
addCleanup(cleanup) {
if (disposed) {
cleanup();
return () => undefined;
}
cleanups.push(cleanup);
let registered = true;
return () => {
if (!registered) return;
registered = false;
const index = cleanups.indexOf(cleanup);
if (index >= 0) cleanups.splice(index, 1);
};
},
dispose() {
if (disposed) return;
disposed = true;
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
},
};
}
@@ -1,3 +1,88 @@
export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900;
export type DataGridReloadIntent = "refresh";
export interface DataGridToolbarActionCapability {
label: string;
tooltip?: string;
visible?: boolean;
disabled?: boolean;
active?: boolean;
loading?: boolean;
onTrigger: () => void | Promise<void>;
}
export interface DataGridToolbarSaveCapability extends DataGridToolbarActionCapability {
pendingCount: number;
shortcutLabel?: string;
}
export interface DataGridToolbarMenuItem {
value: string;
label: string;
disabled?: boolean;
separatorBefore?: boolean;
}
export interface DataGridToolbarExportCapability {
label: string;
visible?: boolean;
disabled?: boolean;
items: readonly DataGridToolbarMenuItem[];
onSelect: (value: string) => void | Promise<void>;
}
export interface DataGridToolbarAutoRefreshCapability {
label: string;
shortLabel: string;
startLabel: string;
stopLabel: string;
visible?: boolean;
disabled?: boolean;
enabled: boolean;
intervalSeconds: number;
intervalOptions: readonly number[];
intervalLabel: (seconds: number) => string;
onToggle: () => void | Promise<void>;
onSelectInterval: (seconds: number) => void | Promise<void>;
}
export function isDataGridToolbarCapabilityVisible(capability: { visible?: boolean } | undefined): boolean {
return !!capability && capability.visible !== false;
}
export function isDataGridToolbarCapabilityDisabled(capability: { disabled?: boolean; loading?: boolean } | undefined): boolean {
return !capability || capability.disabled === true;
}
export async function triggerDataGridToolbarAction(capability: DataGridToolbarActionCapability | undefined): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false;
await capability.onTrigger();
return true;
}
export function dataGridToolbarIntervalOptions(intervalOptions: readonly number[], currentIntervalSeconds: number): number[] {
// Keep a persisted custom interval selectable even when it is not in today's preset list.
return [...new Set([...intervalOptions, currentIntervalSeconds].filter((seconds) => Number.isInteger(seconds) && seconds > 0))].sort((left, right) => left - right);
}
export async function toggleDataGridToolbarAutoRefresh(capability: DataGridToolbarAutoRefreshCapability | undefined): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false;
await capability.onToggle();
return true;
}
export async function selectDataGridToolbarAutoRefreshInterval(capability: DataGridToolbarAutoRefreshCapability | undefined, seconds: number): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || isDataGridToolbarCapabilityDisabled(capability)) return false;
if (!dataGridToolbarIntervalOptions(capability.intervalOptions, capability.intervalSeconds).includes(seconds)) return false;
await capability.onSelectInterval(seconds);
return true;
}
export async function selectDataGridToolbarExportItem(capability: DataGridToolbarExportCapability | undefined, value: string): Promise<boolean> {
if (!capability || !isDataGridToolbarCapabilityVisible(capability) || capability.disabled) return false;
const item = capability.items.find((candidate) => candidate.value === value);
if (!item || item.disabled) return false;
await capability.onSelect(value);
return true;
}