feat(grid): decompose data grid interaction surfaces
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
+24
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user