diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue
index 008f8ed2e..739d64feb 100644
--- a/apps/desktop/src/components/document/DocumentBrowser.vue
+++ b/apps/desktop/src/components/document/DocumentBrowser.vue
@@ -2,7 +2,7 @@
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
-import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Columns3, Check, Search, Wrench, Filter } from "@lucide/vue";
+import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
+import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
@@ -64,6 +65,8 @@ import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/com
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch";
+import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
+import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
import { useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
@@ -126,8 +129,6 @@ const documentSearchQuery = ref("");
const documentSearchMatchIndex = ref(0);
const documentSearchHasNavigated = ref(false);
const documentViewerSearchActive = ref(false);
-const columnVisibilitySearch = ref("");
-const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
function openDataGridExtractorConfiguration() {
viewOptionsOpen.value = false;
@@ -144,6 +145,30 @@ let elasticsearchPaginationLowerBound: number | undefined;
let elasticsearchCountExecutionId = "";
let elasticsearchCountGeneration = 0;
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
+const documentColumnLayoutScopeKey = computed(() =>
+ documentDataGridColumnLayoutScopeKey({
+ databaseType: props.databaseType ?? "mongodb",
+ connectionId: props.connectionId,
+ database: props.database,
+ collection: props.collection,
+ }),
+);
+const legacyDocumentColumnVisibilityScopeKey = computed(() =>
+ documentGridColumnVisibilityScopeKey({
+ databaseType: props.databaseType,
+ connectionId: props.connectionId,
+ database: props.database,
+ collection: props.collection,
+ }),
+);
+
+watch(
+ [legacyDocumentColumnVisibilityScopeKey, documentColumnLayoutScopeKey],
+ ([legacyScopeKey, layoutScopeKey]) => {
+ migrateDocumentGridColumnVisibilityToLayout(legacyScopeKey, layoutScopeKey);
+ },
+ { immediate: true },
+);
const pageTotal = computed(() => paginationTotal.value);
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
@@ -1531,52 +1556,7 @@ defineExpose({ focusSearch });
-
-
-
-
-
-
-
-
{{ t("grid.columnVisibility") }}
-
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
-
-
-
-
-
-
-
-
-
- {{ t("grid.noSearchResults") }}
-
-
-
-
{{ t("grid.columnVisibilityHint") }}
-
-
-
-
-
-
-
-
+
@@ -1622,6 +1602,9 @@ defineExpose({ focusSearch });
ref="dataGridRef"
class="flex-1 min-h-0"
:result="gridResult"
+ :connection-id="props.connectionId"
+ :database="props.database"
+ :column-layout-scope-key="documentColumnLayoutScopeKey"
context="results"
:database-type="props.databaseType"
:mongo-update-target="mongoUpdateTarget"
diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts
index f44475d06..3d002024a 100644
--- a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts
+++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts
@@ -43,8 +43,25 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
default: defineComponent({
name: "DataGridStub",
inheritAttrs: false,
- setup(_, { expose, slots }) {
+ props: {
+ result: { type: Object, required: true },
+ connectionId: { type: String, default: "" },
+ database: { type: String, default: "" },
+ columnLayoutScopeKey: { type: String, default: "" },
+ },
+ setup(props, { expose, slots }) {
expose({
+ visibleColumnCount: 2,
+ displayableColumnCount: 2,
+ hiddenColumnCount: 0,
+ orderedColumnLayoutOptions: [],
+ filteredColumnLayoutOptions: () => [],
+ toggleColumnVisibility: vi.fn(),
+ showAllColumns: vi.fn(),
+ invertColumnVisibility: vi.fn(),
+ hasCustomColumnOrder: false,
+ moveDisplayableColumn: vi.fn(),
+ resetColumnOrder: vi.fn(),
nullColumnsHidden: false,
canToggleAllNullColumns: false,
allNullColumnCount: 0,
@@ -53,13 +70,21 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
return () =>
h(
"div",
- { "data-testid": "data-grid" },
- slots["search-bar"]?.({
- localFilterCount: 0,
- hasLocalColumnFilters: false,
- localFilterSummaries: [],
- clearLocalFilter: vi.fn(),
- }),
+ {
+ "data-testid": "data-grid",
+ "data-connection-id": props.connectionId,
+ "data-database": props.database,
+ "data-column-layout-scope-key": props.columnLayoutScopeKey,
+ "data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
+ },
+ [
+ slots["search-bar"]?.({
+ localFilterCount: 0,
+ hasLocalColumnFilters: false,
+ localFilterSummaries: [],
+ clearLocalFilter: vi.fn(),
+ }),
+ ],
);
},
}),
@@ -145,9 +170,12 @@ vi.mock("@/components/ui/select", async () => {
});
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
+import { documentDataGridColumnLayoutScopeKey, loadDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
+import { documentGridColumnVisibilityScopeKey } from "@/lib/document/documentGridColumnVisibilityStorage";
let app: App | null = null;
let root: HTMLDivElement | null = null;
+let storedValues: Map;
async function flushUi() {
for (let index = 0; index < 4; index++) {
@@ -182,6 +210,12 @@ async function setSearchInput(value: string) {
}
beforeEach(async () => {
+ storedValues = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => storedValues.get(key) ?? null,
+ setItem: (key: string, value: string) => storedValues.set(key, value),
+ removeItem: (key: string) => storedValues.delete(key),
+ });
backend.getColumns.mockReset();
backend.documentFindDocuments.mockReset();
backend.cancelQuery.mockReset();
@@ -219,9 +253,44 @@ afterEach(() => {
root?.remove();
root = null;
document.body.innerHTML = "";
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
describe("DocumentBrowser Elasticsearch field search", () => {
+ it("migrates hidden columns and passes a stable index layout scope without changing the query result", async () => {
+ app?.unmount();
+ const legacyScopeKey = documentGridColumnVisibilityScopeKey({
+ databaseType: "elasticsearch",
+ connectionId: "connection-1",
+ database: "",
+ collection: "orders",
+ });
+ const layoutScopeKey = documentDataGridColumnLayoutScopeKey({
+ databaseType: "elasticsearch",
+ connectionId: "connection-1",
+ database: "",
+ collection: "orders",
+ });
+ storedValues.set(`dbx-document-grid-column-visibility:v1:${legacyScopeKey}`, JSON.stringify(["title"]));
+
+ app = createApp(DocumentBrowser, {
+ connectionId: "connection-1",
+ database: "",
+ collection: "orders",
+ databaseType: "elasticsearch",
+ });
+ app.mount(root!);
+ await flushUi();
+
+ const dataGrid = root!.querySelector('[data-testid="data-grid"]')!;
+ expect(dataGrid.dataset.connectionId).toBe("connection-1");
+ expect(dataGrid.dataset.database).toBe("");
+ expect(dataGrid.dataset.columnLayoutScopeKey).toBe(layoutScopeKey);
+ expect(dataGrid.dataset.resultHiddenColumnKeys).toBe("[]");
+ expect(loadDataGridColumnLayout(layoutScopeKey)).toEqual({ orderKeys: [], hiddenKeys: ["title"] });
+ });
+
it("searches, selects, updates the query type, and clears the search when closed", async () => {
root!.querySelector('[data-testid="data-grid"] button')!.click();
await flushUi();
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index 6d757022a..38fa78946 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -275,6 +275,7 @@ interface DataGridProps {
database?: string;
executionDatabase?: string;
schema?: string;
+ columnLayoutScopeKey?: string;
context?: "results" | "table-data";
autoTransposeSingleRow?: boolean;
sourceColumns?: Array;
@@ -351,7 +352,6 @@ const emit = defineEmits<{
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
"local-column-filters-change": [value: Record];
- "hidden-column-keys-change": [value: string[]];
}>();
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => !isSaving.value && !props.loading), refresh: onToolbarRefresh });
@@ -1688,18 +1688,20 @@ let highlightedColumnTimer = 0;
const goToColumnOpen = ref(false);
const goToColumnSearch = ref("");
const columnOrderKeys = computed(() => uniqueDataGridColumnOrderKeys(props.result.columns, props.sourceColumns));
-const columnLayoutScopeKey = computed(() =>
- dataGridColumnLayoutScopeKey({
- connectionId: props.connectionId,
- database: props.database,
- schema: props.schema,
- context: props.context,
- tableSchema: props.tableMeta?.schema,
- tableName: props.tableMeta?.tableName,
- sql: props.sql,
- columns: props.result.columns,
- sourceColumns: props.sourceColumns,
- }),
+const resolvedColumnLayoutScopeKey = computed(
+ () =>
+ props.columnLayoutScopeKey ??
+ dataGridColumnLayoutScopeKey({
+ connectionId: props.connectionId,
+ database: props.database,
+ schema: props.schema,
+ context: props.context,
+ tableSchema: props.tableMeta?.schema,
+ tableName: props.tableMeta?.tableName,
+ sql: props.sql,
+ columns: props.result.columns,
+ sourceColumns: props.sourceColumns,
+ }),
);
const tableColumnOrderScopeKey = computed(() => {
if (props.context !== "table-data" || !props.connectionId || !props.database || !props.tableMeta?.tableName) return "";
@@ -1727,13 +1729,15 @@ const {
allNullColumnCount,
hasCustomColumnOrder,
canToggleAllNullColumns,
- filteredColumnVisibilityOptions,
+ orderedColumnLayoutOptions,
+ filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
showColumn,
persistColumnOrder,
+ moveDisplayableColumn,
resetColumnOrder,
toggleAllNullColumns,
resetColumnVisibility,
@@ -1749,13 +1753,13 @@ const {
displayableColumnIndexes,
allNullColumnIndexes: allNullColumnIndexesForResult,
columnOrderKeys,
- layoutScopeKey: columnLayoutScopeKey,
+ layoutScopeKey: resolvedColumnLayoutScopeKey,
tableScopeKey: tableColumnOrderScopeKey,
+ // Existing tab snapshots may still carry this field; new changes persist in the internal layout store.
initialHiddenColumnKeys: computed(() => props.result.local_hidden_column_keys),
hideNullColumns,
- onHiddenColumnKeysChange: (keys) => emit("hidden-column-keys-change", keys),
onHideNullColumnsChange: (value) => settingsStore.updateEditorSettings({ dataGridHideNullColumns: value }),
- onRefreshMetrics: refreshGridScrollerMetrics,
+ onRefreshMetrics: scheduleColumnLayoutRefresh,
});
const goToColumnItems = computed(() =>
buildDataGridColumnLookupItems({
@@ -1935,7 +1939,7 @@ const {
onResizeStart,
onCanvasMouseLeave,
onCanvasDrawSchedule: scheduleCanvasDraw,
- onRefreshMetrics: () => nextTick(refreshGridScrollerMetrics),
+ onRefreshMetrics: scheduleColumnLayoutRefresh,
onPersistColumnOrder: persistColumnOrder,
frozenColumnCount,
});
@@ -2249,6 +2253,19 @@ function refreshGridScrollerMetrics() {
observeGridHorizontalScrollbarScroller();
}
+let columnLayoutRefreshFrame = 0;
+function scheduleColumnLayoutRefresh() {
+ if (columnLayoutRefreshFrame) return;
+ columnLayoutRefreshFrame = requestAnimationFrame(() => {
+ try {
+ initColumnWidths();
+ refreshGridScrollerMetrics();
+ } finally {
+ columnLayoutRefreshFrame = 0;
+ }
+ });
+}
+
function syncHeaderScroll(e: Event) {
const target = e.target as HTMLElement;
updateGridScrollbarGutter(target);
@@ -2321,16 +2338,7 @@ watch(isScrolling, (scrolling) => {
});
initColumnWidths();
-watch(
- () => visibleColumns.value.length,
- () => initColumnWidths(),
-);
-watch(
- () => [visibleColumnCount.value, renderedColumnWidths.value.length],
- () => {
- nextTick(refreshGridScrollerMetrics);
- },
-);
+watch([visibleColumnIndexes, () => renderedColumnWidths.value.length], () => scheduleColumnLayoutRefresh());
const localFilterScopeKey = computed(() =>
[
props.connectionId ?? "",
@@ -2348,7 +2356,7 @@ watch(
() => localFilterScopeKey.value,
() => {
localColumnFilters.value = {};
- resetColumnVisibility(props.result.local_hidden_column_keys);
+ resetColumnVisibility();
closeLocalFilter();
},
);
@@ -5140,6 +5148,8 @@ onUnmounted(() => {
disconnectCellEditResizeObserver();
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
+ if (columnLayoutRefreshFrame) cancelAnimationFrame(columnLayoutRefreshFrame);
+ columnLayoutRefreshFrame = 0;
if (typeof window === "undefined") return;
window.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
window.visualViewport?.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
@@ -7585,12 +7595,14 @@ defineExpose({
visibleColumnCount,
displayableColumnCount,
hiddenColumnCount,
- filteredColumnVisibilityOptions,
+ orderedColumnLayoutOptions,
+ filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
hasCustomColumnOrder,
+ moveDisplayableColumn,
resetColumnOrder,
nullColumnsHidden,
allNullColumnCount,
diff --git a/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue b/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue
new file mode 100644
index 000000000..b5bd76a90
--- /dev/null
+++ b/apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+
+
+
+
+
{{ t("grid.columnVisibility") }}
+
{{ grid?.visibleColumnCount ?? 0 }}/{{ grid?.displayableColumnCount ?? 0 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("grid.noSearchResults") }}
+
+
+
+
+ {{ t("grid.columnVisibilityHint") }}
+ {{ columnReorderEnabled ? t("grid.columnReorderHint") : t("grid.columnReorderSearchHint") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dragPreviewOption.column }}
+ {{ dragPreviewOption.comment }}
+
+
+
+
diff --git a/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts b/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
new file mode 100644
index 000000000..d69aedb41
--- /dev/null
+++ b/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
@@ -0,0 +1,323 @@
+// @vitest-environment happy-dom
+
+import { createApp, nextTick, type App } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import i18n from "@/i18n";
+import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
+import type { DataGridColumnLayoutHandle } from "../dataGridColumnLayoutPopover";
+import { DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT, DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT, dataGridColumnLayoutDropTarget, dataGridColumnLayoutVirtualWindow } from "../dataGridColumnLayoutPopover";
+
+vi.mock("@/components/ui/popover", async () => {
+ const { defineComponent, h, onMounted } = await import("vue");
+ const passthrough = defineComponent({
+ setup(_props, { slots }) {
+ return () => h("div", slots.default?.());
+ },
+ });
+ const popover = defineComponent({
+ props: { open: Boolean },
+ emits: ["update:open"],
+ setup(_props, { emit, slots }) {
+ onMounted(() => emit("update:open", true));
+ return () => h("div", { "data-test-popover-root": "" }, [h("button", { class: "test-close-popover", onClick: () => emit("update:open", false) }), slots.default?.()]);
+ },
+ });
+ return { Popover: popover, PopoverContent: passthrough, PopoverTrigger: passthrough };
+});
+
+vi.mock("@/components/ui/button", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ Button: defineComponent({
+ inheritAttrs: false,
+ setup(_props, { attrs, slots }) {
+ return () => h("button", attrs, slots.default?.());
+ },
+ }),
+ };
+});
+
+import DataGridColumnLayoutPopover from "../DataGridColumnLayoutPopover.vue";
+
+const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
+
+function columnLayoutOptions(itemCount: number): DataGridColumnLayoutOption[] {
+ return Array.from({ length: itemCount }, (_, index) => ({
+ key: `column-${index}`,
+ column: `column_${index}`,
+ name: `column_${index}`,
+ index,
+ visible: true,
+ displayPosition: index,
+ }));
+}
+
+function createGrid(itemCount: number) {
+ const options = columnLayoutOptions(itemCount);
+ const toggleColumnVisibility = vi.fn();
+ const moveDisplayableColumn = vi.fn();
+ const grid: DataGridColumnLayoutHandle = {
+ visibleColumnCount: itemCount,
+ displayableColumnCount: itemCount,
+ hiddenColumnCount: 0,
+ orderedColumnLayoutOptions: options,
+ filteredColumnLayoutOptions: (search) => {
+ const normalizedSearch = search.trim().toLowerCase();
+ return normalizedSearch ? options.filter((option) => option.column.toLowerCase().includes(normalizedSearch)) : options;
+ },
+ toggleColumnVisibility,
+ showAllColumns: vi.fn(),
+ invertColumnVisibility: vi.fn(),
+ hasCustomColumnOrder: false,
+ moveDisplayableColumn,
+ resetColumnOrder: vi.fn(),
+ };
+ return { grid, moveDisplayableColumn, toggleColumnVisibility };
+}
+
+async function mountPopover(itemCount = 4) {
+ const gridState = createGrid(itemCount);
+ const host = document.createElement("div");
+ document.body.append(host);
+ const app = createApp(DataGridColumnLayoutPopover, { grid: gridState.grid });
+ app.use(i18n);
+ app.mount(host);
+ mountedApps.push({ app, host });
+ await nextTick();
+ await nextTick();
+ return { ...gridState, host, app };
+}
+
+function configureList(host: HTMLElement, itemCount: number) {
+ const list = host.querySelector("[data-column-layout-list]")!;
+ Object.defineProperties(list, {
+ clientHeight: { configurable: true, value: 288 },
+ scrollHeight: { configurable: true, value: itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT },
+ });
+ list.getBoundingClientRect = () => ({
+ left: 0,
+ right: 288,
+ top: 0,
+ bottom: 288,
+ width: 288,
+ height: 288,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ });
+ return list;
+}
+
+function dispatchPointer(target: EventTarget, type: string, options: { pointerId?: number; clientX?: number; clientY: number }) {
+ target.dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: options.pointerId ?? 1,
+ clientX: options.clientX ?? 20,
+ clientY: options.clientY,
+ }),
+ );
+}
+
+beforeEach(() => {
+ i18n.global.locale.value = "en";
+});
+
+afterEach(() => {
+ for (const { app, host } of mountedApps.splice(0)) {
+ app.unmount();
+ host.remove();
+ }
+ document.body.innerHTML = "";
+ document.body.style.userSelect = "";
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe("data grid column layout popover", () => {
+ it("renders only the visible field window plus a bounded buffer", () => {
+ const window = dataGridColumnLayoutVirtualWindow({
+ itemCount: 500,
+ scrollTop: 1400,
+ viewportHeight: 280,
+ });
+
+ expect(window).toEqual({
+ start: 44,
+ end: 66,
+ offsetTop: 44 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
+ totalHeight: 500 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
+ });
+ });
+
+ it("clamps the virtual field window at the list boundaries", () => {
+ expect(dataGridColumnLayoutVirtualWindow({ itemCount: 4, scrollTop: 9999 })).toMatchObject({
+ start: 0,
+ end: 4,
+ totalHeight: 4 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
+ });
+ });
+
+ it("maps row halves to symmetric insertion positions", () => {
+ expect(dataGridColumnLayoutDropTarget({ clientY: 2, listTop: 0, scrollTop: 0, itemCount: 4, fromDisplayPosition: 2 })).toEqual({
+ insertionIndex: 0,
+ toDisplayPosition: 0,
+ });
+ expect(dataGridColumnLayoutDropTarget({ clientY: 50, listTop: 0, scrollTop: 0, itemCount: 4, fromDisplayPosition: 0 })).toEqual({
+ insertionIndex: 2,
+ toDisplayPosition: 1,
+ });
+ });
+
+ it("separates the drag handle from the visibility toggle", async () => {
+ const { host, toggleColumnVisibility } = await mountPopover(3);
+ const handles = host.querySelectorAll("[data-column-drag-handle]");
+ const visibilityToggles = host.querySelectorAll("[data-column-visibility-toggle]");
+ const rows = host.querySelectorAll("[data-column-layout-row]");
+
+ expect(handles[0]?.className).toContain("cursor-move");
+ expect(rows[0]?.className).not.toContain("cursor-grab");
+ handles[1]?.click();
+ expect(toggleColumnVisibility).not.toHaveBeenCalled();
+
+ visibilityToggles[1]?.click();
+ expect(toggleColumnVisibility).toHaveBeenCalledOnce();
+ expect(toggleColumnVisibility).toHaveBeenCalledWith(1);
+ });
+
+ it("starts after five pixels, shows an insertion line, and commits once without toggling visibility", async () => {
+ const { host, moveDisplayableColumn, toggleColumnVisibility } = await mountPopover(4);
+ configureList(host, 4);
+ const handle = host.querySelector("[data-column-drag-handle]")!;
+
+ dispatchPointer(handle, "pointerdown", { clientY: 14 });
+ dispatchPointer(window, "pointermove", { clientY: 18 });
+ await nextTick();
+ expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
+ expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
+
+ dispatchPointer(window, "pointermove", { clientY: 50 });
+ await nextTick();
+ const dragPreview = document.body.querySelector("[data-column-drag-preview]")!;
+ const initialPreviewTransform = dragPreview.style.transform;
+ expect(host.querySelector("[data-column-drop-indicator]")?.style.top).toBe(`${2 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px`);
+ expect(dragPreview.textContent).toContain("column_0");
+ expect(initialPreviewTransform).toBe("translate3d(0px, 36px, 0)");
+ expect(host.querySelector('[data-display-position="1"]')?.style.transform).toBe(`translateY(-${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px)`);
+ expect(document.body.style.userSelect).toBe("none");
+
+ dispatchPointer(window, "pointermove", { clientX: 36, clientY: 52 });
+ await nextTick();
+ expect(dragPreview.style.transform).not.toBe(initialPreviewTransform);
+ expect(dragPreview.style.transform).toBe("translate3d(16px, 38px, 0)");
+
+ dispatchPointer(window, "pointerup", { clientX: 36, clientY: 52 });
+ dispatchPointer(window, "pointerup", { clientX: 36, clientY: 52 });
+ await nextTick();
+ expect(moveDisplayableColumn).toHaveBeenCalledOnce();
+ expect(moveDisplayableColumn).toHaveBeenCalledWith(0, 1);
+ expect(toggleColumnVisibility).not.toHaveBeenCalled();
+ expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
+ expect(document.body.style.userSelect).toBe("");
+ });
+
+ it("cancels without committing and disables handles while searching", async () => {
+ const { host, moveDisplayableColumn } = await mountPopover(4);
+ configureList(host, 4);
+ const handle = host.querySelector("[data-column-drag-handle]")!;
+
+ dispatchPointer(handle, "pointerdown", { clientY: 14 });
+ dispatchPointer(window, "pointermove", { clientY: 50 });
+ dispatchPointer(window, "pointercancel", { clientY: 50 });
+ dispatchPointer(window, "pointerup", { clientY: 50 });
+ expect(moveDisplayableColumn).not.toHaveBeenCalled();
+
+ const search = host.querySelector("input")!;
+ search.value = "column_1";
+ search.dispatchEvent(new Event("input", { bubbles: true }));
+ await nextTick();
+ const filteredHandle = host.querySelector("[data-column-drag-handle]")!;
+ expect(filteredHandle.disabled).toBe(true);
+ dispatchPointer(filteredHandle, "pointerdown", { clientY: 14 });
+ dispatchPointer(window, "pointermove", { clientY: 50 });
+ dispatchPointer(window, "pointerup", { clientY: 50 });
+ expect(moveDisplayableColumn).not.toHaveBeenCalled();
+ });
+
+ it("clears active drag state when the popover closes", async () => {
+ const { host, moveDisplayableColumn } = await mountPopover(4);
+ configureList(host, 4);
+ const handle = host.querySelector("[data-column-drag-handle]")!;
+
+ dispatchPointer(handle, "pointerdown", { clientY: 14 });
+ dispatchPointer(window, "pointermove", { clientY: 50 });
+ await nextTick();
+ expect(host.querySelector("[data-column-drop-indicator]")).not.toBeNull();
+
+ host.querySelector(".test-close-popover")?.click();
+ await nextTick();
+ expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
+ expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
+ dispatchPointer(window, "pointerup", { clientY: 50 });
+ expect(moveDisplayableColumn).not.toHaveBeenCalled();
+ });
+
+ it("cancels active dragging on Escape and window blur", async () => {
+ const { host, moveDisplayableColumn } = await mountPopover(4);
+ configureList(host, 4);
+ const handle = host.querySelector("[data-column-drag-handle]")!;
+
+ dispatchPointer(handle, "pointerdown", { pointerId: 3, clientY: 14 });
+ dispatchPointer(window, "pointermove", { pointerId: 3, clientY: 50 });
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
+ dispatchPointer(window, "pointerup", { pointerId: 3, clientY: 50 });
+
+ dispatchPointer(handle, "pointerdown", { pointerId: 4, clientY: 14 });
+ dispatchPointer(window, "pointermove", { pointerId: 4, clientY: 50 });
+ window.dispatchEvent(new Event("blur"));
+ dispatchPointer(window, "pointerup", { pointerId: 4, clientY: 50 });
+
+ expect(moveDisplayableColumn).not.toHaveBeenCalled();
+ expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
+ expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
+ expect(document.body.style.userSelect).toBe("");
+ });
+
+ it("auto-scrolls virtualized fields and commits beyond the initial viewport", async () => {
+ const frames = new Map();
+ let nextFrameId = 1;
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ vi.fn((callback: FrameRequestCallback) => {
+ const frameId = nextFrameId++;
+ frames.set(frameId, callback);
+ return frameId;
+ }),
+ );
+ vi.stubGlobal(
+ "cancelAnimationFrame",
+ vi.fn((frameId: number) => frames.delete(frameId)),
+ );
+ const { host, moveDisplayableColumn } = await mountPopover(120);
+ const list = configureList(host, 120);
+ const handle = host.querySelector("[data-column-drag-handle]")!;
+
+ dispatchPointer(handle, "pointerdown", { pointerId: 7, clientY: 14 });
+ dispatchPointer(window, "pointermove", { pointerId: 7, clientY: 286 });
+ for (let frameIndex = 0; frameIndex < 30; frameIndex += 1) {
+ const callbacks = [...frames.values()];
+ frames.clear();
+ callbacks.forEach((callback) => callback(frameIndex));
+ }
+ await nextTick();
+
+ expect(list.scrollTop).toBeGreaterThan(DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT);
+ expect(host.querySelector('[data-display-position="0"]')).toBeNull();
+ expect(document.body.querySelector("[data-column-drag-preview]")?.textContent).toContain("column_0");
+ dispatchPointer(window, "pointerup", { pointerId: 7, clientY: 286 });
+ expect(moveDisplayableColumn).toHaveBeenCalledOnce();
+ expect(moveDisplayableColumn.mock.calls[0]?.[1]).toBeGreaterThan(10);
+ });
+});
diff --git a/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts b/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts
new file mode 100644
index 000000000..e72957ce8
--- /dev/null
+++ b/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts
@@ -0,0 +1,63 @@
+import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
+
+export const DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT = 28;
+export const DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT = 288;
+export const DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD = 80;
+export const DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD = 5;
+const DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS = 6;
+
+export interface DataGridColumnLayoutHandle {
+ visibleColumnCount: number;
+ displayableColumnCount: number;
+ hiddenColumnCount: number;
+ orderedColumnLayoutOptions: readonly DataGridColumnLayoutOption[];
+ filteredColumnLayoutOptions: (search: string) => DataGridColumnLayoutOption[];
+ toggleColumnVisibility: (columnIndex: number) => void;
+ showAllColumns: () => void;
+ invertColumnVisibility: () => void;
+ hasCustomColumnOrder: boolean;
+ moveDisplayableColumn: (fromDisplayableIndex: number, toDisplayableIndex: number) => void;
+ resetColumnOrder: () => void;
+}
+
+export interface DataGridColumnLayoutVirtualWindow {
+ start: number;
+ end: number;
+ offsetTop: number;
+ totalHeight: number;
+}
+
+export interface DataGridColumnLayoutDropTarget {
+ insertionIndex: number;
+ toDisplayPosition: number;
+}
+
+export function dataGridColumnLayoutDropTarget(options: { clientY: number; listTop: number; scrollTop: number; itemCount: number; fromDisplayPosition: number }): DataGridColumnLayoutDropTarget {
+ const itemCount = Math.max(0, options.itemCount);
+ const totalHeight = itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
+ const relativeY = Math.min(totalHeight, Math.max(0, options.clientY - options.listTop + options.scrollTop));
+ const rowIndex = Math.min(Math.max(0, itemCount - 1), Math.floor(relativeY / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT));
+ const offsetInRow = relativeY - rowIndex * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
+ const insertionIndex = relativeY >= totalHeight || offsetInRow >= DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT / 2 ? Math.min(itemCount, rowIndex + 1) : rowIndex;
+ const targetBeforeRemoval = insertionIndex > options.fromDisplayPosition ? insertionIndex - 1 : insertionIndex;
+ return {
+ insertionIndex,
+ toDisplayPosition: Math.min(Math.max(0, itemCount - 1), Math.max(0, targetBeforeRemoval)),
+ };
+}
+
+export function dataGridColumnLayoutVirtualWindow(options: { itemCount: number; scrollTop: number; viewportHeight?: number }): DataGridColumnLayoutVirtualWindow {
+ const itemCount = Math.max(0, options.itemCount);
+ const viewportHeight = options.viewportHeight ?? DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT;
+ const requestedFirstVisibleRow = Math.floor(Math.max(0, options.scrollTop) / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT);
+ const firstVisibleRow = Math.min(Math.max(0, itemCount - 1), requestedFirstVisibleRow);
+ const visibleRowCount = Math.ceil(viewportHeight / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT);
+ const start = Math.max(0, firstVisibleRow - DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS);
+ const end = Math.min(itemCount, firstVisibleRow + visibleRowCount + DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS);
+ return {
+ start,
+ end,
+ offsetTop: start * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
+ totalHeight: itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
+ };
+}
diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue
index 8525413e8..5a5916390 100644
--- a/apps/desktop/src/components/layout/ContentArea.vue
+++ b/apps/desktop/src/components/layout/ContentArea.vue
@@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
-import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
+import { Check, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -21,6 +21,8 @@ import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarAct
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
+import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
+import type { DataGridColumnLayoutHandle } from "@/components/grid/dataGridColumnLayoutPopover";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
let dataGridComponentPromise: Promise | undefined;
function loadDataGridComponent() {
@@ -96,20 +98,10 @@ import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
-type DataGridHandle = {
+type DataGridHandle = DataGridColumnLayoutHandle & {
onToolbarRefresh: () => Promise | void;
focusSearch: () => boolean;
openCellDetailSearch: () => boolean;
- visibleColumnCount: number;
- displayableColumnCount: number;
- hiddenColumnCount: number;
- filteredColumnVisibilityOptions: (search: string) => Array<{ index: number; column: string; comment?: string }>;
- isColumnVisible: (columnIndex: number) => boolean;
- toggleColumnVisibility: (columnIndex: number) => void;
- showAllColumns: () => void;
- invertColumnVisibility: () => void;
- hasCustomColumnOrder: boolean;
- resetColumnOrder: () => void;
nullColumnsHidden: boolean;
allNullColumnCount: number;
canToggleAllNullColumns: boolean;
@@ -217,9 +209,7 @@ const tableStructureEditorRef = ref<{ applyChanges: () => Promise }>();
const standaloneResultToolbarRef = ref(null);
const standaloneResultToolbarWidth = ref(0);
const resultTabsScrollerRef = ref(null);
-const columnVisibilitySearch = ref("");
const dataGridViewOptionsOpen = ref(false);
-const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
const resultRunDisplayMode = computed(() => settingsStore.editorSettings.resultRunDisplayMode);
@@ -1450,7 +1440,6 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
:export-file-base-name="activeTab.title"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
- @hidden-column-keys-change="(keys: string[]) => queryStore.updateDataGridHiddenColumnKeys(activeTab.id, keys)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
@@ -1526,55 +1515,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
-
-
-
-
-
-
-
-
{{ t("grid.columnVisibility") }}
-
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
-
-
-
-
-
-
-
-
-
- {{ t("grid.noSearchResults") }}
-
-
-
-
{{ t("grid.columnVisibilityHint") }}
-
-
-
-
-
-
-
-
+
@@ -1822,7 +1763,6 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@update:where-input="(v: string) => (activeTab.whereInput = v)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
- @hidden-column-keys-change="(keys: string[]) => queryStore.updateDataGridHiddenColumnKeys(activeTab.id, keys)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts
index 197cbee26..f3d450d86 100644
--- a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts
@@ -1,11 +1,21 @@
// @vitest-environment happy-dom
import { effectScope, nextTick, ref } from "vue";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
+import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
describe("useDataGridColumnLayout", () => {
- beforeEach(() => localStorage.clear());
+ beforeEach(() => {
+ const values = new Map();
+ vi.stubGlobal("localStorage", {
+ clear: () => values.clear(),
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ removeItem: (key: string) => values.delete(key),
+ });
+ });
+ afterEach(() => vi.unstubAllGlobals());
it("builds cumulative offsets", () => {
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
});
@@ -91,7 +101,7 @@ describe("useDataGridColumnLayout", () => {
allNullColumnIndexes.value = [2];
await nextTick();
- state.resetColumnVisibility();
+ state.resetColumnVisibility([]);
expect(state.visibleColumnIndexes.value).toEqual([0, 1]);
state.toggleAllNullColumns();
@@ -101,7 +111,6 @@ describe("useDataGridColumnLayout", () => {
});
it("restores manually hidden columns after the grid scope is recreated", () => {
- const hiddenColumnKeys = ref([]);
const options = {
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
@@ -111,28 +120,49 @@ describe("useDataGridColumnLayout", () => {
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("visibility-recreated-layout"),
tableScopeKey: ref(""),
- initialHiddenColumnKeys: hiddenColumnKeys,
- onHiddenColumnKeysChange: (keys: string[]) => {
- hiddenColumnKeys.value = keys;
- },
};
const firstScope = effectScope();
const firstState = firstScope.run(() => useDataGridColumnLayoutState(options))!;
firstState.toggleColumnVisibility(1);
- expect(hiddenColumnKeys.value).toEqual(["name\0\0"]);
firstScope.stop();
const recreatedScope = effectScope();
const recreatedState = recreatedScope.run(() => useDataGridColumnLayoutState(options))!;
expect(recreatedState.visibleColumnIndexes.value).toEqual([0, 2]);
recreatedState.showAllColumns();
- expect(hiddenColumnKeys.value).toEqual([]);
recreatedScope.stop();
+
+ expect(JSON.parse(localStorage.getItem("dbx-data-grid-column-layout:visibility-recreated-layout")!)).toMatchObject({ hiddenKeys: [] });
+ });
+
+ it("show all clears hidden keys for fields missing from the current page", () => {
+ const layoutScopeKey = "visibility-missing-field-layout";
+ saveDataGridColumnLayout(layoutScopeKey, {
+ orderKeys: [],
+ hiddenKeys: ["goodsList\0\0"],
+ });
+ const scope = effectScope();
+ const state = scope.run(() =>
+ useDataGridColumnLayoutState({
+ columns: ref(["id", "status"]),
+ sourceColumns: ref(undefined),
+ commentByColumn: ref(new Map()),
+ displayableColumnIndexes: ref([0, 1]),
+ allNullColumnIndexes: ref([]),
+ columnOrderKeys: ref(["id\0\0", "status\0\0"]),
+ layoutScopeKey: ref(layoutScopeKey),
+ tableScopeKey: ref(""),
+ }),
+ )!;
+
+ state.showAllColumns();
+ scope.stop();
+
+ expect(loadDataGridColumnLayout(layoutScopeKey)?.hiddenKeys).toEqual([]);
});
it("persists a null column when it is manually hidden after showing all columns", () => {
- const onHiddenColumnKeysChange = vi.fn();
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
@@ -145,7 +175,6 @@ describe("useDataGridColumnLayout", () => {
layoutScopeKey: ref("visibility-null-column-layout"),
tableScopeKey: ref(""),
hideNullColumns: ref(true),
- onHiddenColumnKeysChange,
}),
)!;
@@ -153,7 +182,30 @@ describe("useDataGridColumnLayout", () => {
state.showAllColumns();
state.toggleColumnVisibility(1);
- expect(onHiddenColumnKeysChange).toHaveBeenLastCalledWith(["empty\0\0"]);
+ scope.stop();
+ expect(JSON.parse(localStorage.getItem("dbx-data-grid-column-layout:visibility-null-column-layout")!)).toMatchObject({ hiddenKeys: ["empty\0\0"] });
+ });
+
+ it("returns ordered layout options with visibility state and reorders hidden fields", () => {
+ const scope = effectScope();
+ const state = scope.run(() =>
+ useDataGridColumnLayoutState({
+ columns: ref(["id", "status", "goodsList"]),
+ sourceColumns: ref(undefined),
+ commentByColumn: ref(new Map([["goodsList", "Line items"]])),
+ displayableColumnIndexes: ref([0, 1, 2]),
+ allNullColumnIndexes: ref([]),
+ columnOrderKeys: ref(["id\0\0", "status\0\0", "goodsList\0\0"]),
+ layoutScopeKey: ref("layout-options"),
+ tableScopeKey: ref(""),
+ }),
+ )!;
+
+ state.toggleColumnVisibility(2);
+ state.moveDisplayableColumn(2, 1);
+
+ expect(state.filteredColumnLayoutOptions("line")).toMatchObject([{ column: "goodsList", visible: false, displayPosition: 1 }]);
+ expect(state.orderedDisplayableColumnIndexes.value).toEqual([0, 2, 1]);
scope.stop();
});
diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts
index 8fecb94dc..e48cb3ee3 100644
--- a/apps/desktop/src/composables/useDataGridColumnLayout.ts
+++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts
@@ -1,20 +1,20 @@
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
-import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
+import { columnOrderKeysForIndexes, isDefaultColumnOrder, mergeUnavailableColumnOrderKeys, moveDisplayableColumnIndex, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import {
- loadDataGridColumnOrder,
+ loadDataGridColumnLayout,
loadDataGridColumnFrozenState,
loadTableDataGridColumnOrder,
notifyTableDataGridColumnOrderChanged,
- removeDataGridColumnOrder,
removeDataGridColumnFrozenCount,
removeTableDataGridColumnOrder,
- saveDataGridColumnOrder,
+ saveDataGridColumnLayout,
saveDataGridColumnFrozenCount,
saveTableDataGridColumnOrder,
type TableDataGridColumnOrderChangedDetail,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
-import { filterColumnVisibilityOptions, hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
+import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems, type DataGridColumnLookupItem } from "@/lib/dataGrid/dataGridColumnLookup";
+import { hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
export type RenderedDataGridColumn = {
visibleColIdx: number;
@@ -29,6 +29,13 @@ export type DataGridHorizontalColumnWindow = {
afterWidth: number;
};
+export interface DataGridColumnLayoutOption extends DataGridColumnLookupItem {
+ key: string;
+ column: string;
+ visible: boolean;
+ displayPosition: number;
+}
+
type ColumnHeaderDragState = {
sourceVisibleIndex: number;
targetVisibleIndex: number;
@@ -78,7 +85,6 @@ export function useDataGridColumnLayoutState(options: {
tableScopeKey: MaybeRefOrGetter;
initialHiddenColumnKeys?: MaybeRefOrGetter;
hideNullColumns?: MaybeRefOrGetter;
- onHiddenColumnKeysChange?: (keys: string[]) => void;
onHideNullColumnsChange?: (value: boolean) => void;
onRefreshMetrics?: () => void;
}) {
@@ -87,9 +93,12 @@ export function useDataGridColumnLayoutState(options: {
const nullColumnsHidden = computed(() => (options.hideNullColumns === undefined ? localNullColumnsHidden.value : toValue(options.hideNullColumns)));
const autoHiddenNullColumnIndexes = ref>(new Set());
const persistedColumnOrderKeys = ref([]);
+ const persistedHiddenColumnKeys = ref([...(toValue(options.initialHiddenColumnKeys) ?? [])]);
const frozenColumnCount = ref(0);
- // 冻结选中列前的列序快照,取消冻结时还原
const columnOrderSnapshotBeforeFreeze = ref(null);
+ let columnLayoutPersistTimer: ReturnType | undefined;
+ let columnLayoutPersistPending = false;
+ let pendingColumnLayoutScopeKey = "";
const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value }));
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length);
@@ -97,17 +106,77 @@ export function useDataGridColumnLayoutState(options: {
const allNullColumnCount = computed(() => toValue(options.allNullColumnIndexes).length);
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(toValue(options.displayableColumnIndexes), orderedDisplayableColumnIndexes.value));
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (toValue(options.allNullColumnIndexes).length > 0 && displayableColumnCount.value > 1));
+ const columnLookupItems = computed(() =>
+ buildDataGridColumnLookupItems({
+ columns: toValue(options.columns),
+ sourceColumns: toValue(options.sourceColumns),
+ displayableIndexes: toValue(options.displayableColumnIndexes),
+ commentByColumn: toValue(options.commentByColumn),
+ }),
+ );
+ const columnLookupItemByIndex = computed(() => new Map(columnLookupItems.value.map((item) => [item.index, item])));
+ const orderedColumnLayoutOptions = computed(() =>
+ orderedDisplayableColumnIndexes.value.flatMap((columnIndex, displayPosition) => {
+ const item = columnLookupItemByIndex.value.get(columnIndex);
+ const key = toValue(options.columnOrderKeys)[columnIndex];
+ if (!item || !key) return [];
+ return [
+ {
+ ...item,
+ key,
+ column: item.name,
+ visible: !hiddenColumnIndexes.value.has(columnIndex),
+ displayPosition,
+ },
+ ];
+ }),
+ );
- function filteredColumnVisibilityOptions(query: string) {
- const displayable = new Set(toValue(options.displayableColumnIndexes));
- return filterColumnVisibilityOptions(toValue(options.columns), query, { sourceColumns: toValue(options.sourceColumns), commentByColumn: toValue(options.commentByColumn) }).filter((option) => displayable.has(option.index));
+ function filteredColumnLayoutOptions(query: string): DataGridColumnLayoutOption[] {
+ return filterDataGridColumnLookupItems(orderedColumnLayoutOptions.value, query);
}
function isColumnVisible(columnIndex: number) {
return !hiddenColumnIndexes.value.has(columnIndex);
}
- function persistHiddenColumnKeys() {
- options.onHiddenColumnKeysChange?.(hiddenColumnKeysForIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value, toValue(options.columnOrderKeys), toValue(options.displayableColumnIndexes)));
+
+ function flushPersistColumnLayout() {
+ if (!columnLayoutPersistPending) return;
+ if (columnLayoutPersistTimer !== undefined) clearTimeout(columnLayoutPersistTimer);
+ columnLayoutPersistTimer = undefined;
+ columnLayoutPersistPending = false;
+ saveDataGridColumnLayout(pendingColumnLayoutScopeKey || toValue(options.layoutScopeKey), {
+ orderKeys: persistedColumnOrderKeys.value,
+ hiddenKeys: persistedHiddenColumnKeys.value,
+ });
}
+
+ function markColumnLayoutForPersistence() {
+ columnLayoutPersistPending = true;
+ pendingColumnLayoutScopeKey = toValue(options.layoutScopeKey);
+ }
+
+ function schedulePersistColumnLayout() {
+ markColumnLayoutForPersistence();
+ if (columnLayoutPersistTimer !== undefined) clearTimeout(columnLayoutPersistTimer);
+ columnLayoutPersistTimer = setTimeout(flushPersistColumnLayout, 100);
+ }
+
+ function persistColumnLayoutImmediately() {
+ markColumnLayoutForPersistence();
+ flushPersistColumnLayout();
+ }
+
+ function currentManualHiddenColumnKeys() {
+ return hiddenColumnKeysForIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value, toValue(options.columnOrderKeys), toValue(options.displayableColumnIndexes));
+ }
+
+ function persistHiddenColumnKeys() {
+ const currentKeys = new Set(toValue(options.displayableColumnIndexes).flatMap((index) => toValue(options.columnOrderKeys)[index] ?? []));
+ const unavailableHiddenKeys = persistedHiddenColumnKeys.value.filter((key) => !currentKeys.has(key));
+ persistedHiddenColumnKeys.value = [...new Set([...unavailableHiddenKeys, ...currentManualHiddenColumnKeys()])];
+ schedulePersistColumnLayout();
+ }
+
function toggleColumnVisibility(columnIndex: number) {
hiddenColumnIndexes.value = nextHiddenColumnIndexes({ columnIndex, hiddenIndexes: hiddenColumnIndexes.value, totalColumns: displayableColumnCount.value });
if (!hiddenColumnIndexes.value.has(columnIndex) && autoHiddenNullColumnIndexes.value.delete(columnIndex)) {
@@ -115,16 +184,20 @@ export function useDataGridColumnLayoutState(options: {
}
persistHiddenColumnKeys();
}
+
function showAllColumns() {
hiddenColumnIndexes.value = new Set();
autoHiddenNullColumnIndexes.value = new Set();
- persistHiddenColumnKeys();
+ persistedHiddenColumnKeys.value = [];
+ schedulePersistColumnLayout();
}
+
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes([...toValue(options.displayableColumnIndexes)], hiddenColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
persistHiddenColumnKeys();
}
+
function showColumn(columnIndex: number) {
if (!hiddenColumnIndexes.value.has(columnIndex)) return;
hiddenColumnIndexes.value.delete(columnIndex);
@@ -133,11 +206,18 @@ export function useDataGridColumnLayoutState(options: {
autoHiddenNullColumnIndexes.value = new Set(autoHiddenNullColumnIndexes.value);
persistHiddenColumnKeys();
}
- function loadColumnOrder() {
+
+ function loadColumnLayout() {
+ flushPersistColumnLayout();
+ const storedLayout = loadDataGridColumnLayout(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
const tableScopeKey = toValue(options.tableScopeKey);
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
- persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
+ persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : (storedLayout?.orderKeys ?? []);
+ persistedHiddenColumnKeys.value = storedLayout?.hiddenKeys ?? [...(toValue(options.initialHiddenColumnKeys) ?? [])];
+ resetColumnVisibility();
+ if (!storedLayout && persistedHiddenColumnKeys.value.length > 0) schedulePersistColumnLayout();
}
+
function loadFrozenColumnCount() {
const state = loadDataGridColumnFrozenState(toValue(options.layoutScopeKey));
frozenColumnCount.value = Math.min(state.frozenCount, visibleColumnIndexes.value.length);
@@ -159,24 +239,21 @@ export function useDataGridColumnLayoutState(options: {
if (selectedVisibleColIdxs.length === 0) return;
const sorted = [...selectedVisibleColIdxs].sort((a, b) => a - b);
const visibleIdxs = visibleColumnIndexes.value;
- // 获取选中列的 actualColIdx
const selectedActualIdxs = sorted.map((vIdx) => visibleIdxs[vIdx]).filter((idx): idx is number => idx !== undefined);
if (selectedActualIdxs.length === 0) return;
const selectedSet = new Set(selectedActualIdxs);
- // 非选中列保持原序
const currentOrder = orderedDisplayableColumnIndexes.value;
const nonSelectedActualIdxs = currentOrder.filter((idx) => !selectedSet.has(idx));
- // 保存冻结前的列序快照(仅在首次冻结时保存,避免连续冻结覆盖快照)
+ // 首次冻结时保留原序,连续冻结不能覆盖用户真正的起始顺序。
if (columnOrderSnapshotBeforeFreeze.value === null) {
columnOrderSnapshotBeforeFreeze.value = [...persistedColumnOrderKeys.value];
}
- // 新列序:选中列在前 + 非选中列在后
persistColumnOrder([...selectedActualIdxs, ...nonSelectedActualIdxs]);
setFrozenColumnCount(selectedActualIdxs.length);
}
+
function unfreezeAllColumns() {
setFrozenColumnCount(0);
- // 还原冻结前的列序
if (columnOrderSnapshotBeforeFreeze.value !== null) {
const snapshot = columnOrderSnapshotBeforeFreeze.value;
columnOrderSnapshotBeforeFreeze.value = null;
@@ -184,7 +261,7 @@ export function useDataGridColumnLayoutState(options: {
resetColumnOrder();
} else {
persistedColumnOrderKeys.value = snapshot;
- saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), snapshot);
+ persistColumnLayoutImmediately();
const tableScopeKey = toValue(options.tableScopeKey);
if (tableScopeKey) {
saveTableDataGridColumnOrder(tableScopeKey, snapshot);
@@ -193,35 +270,48 @@ export function useDataGridColumnLayoutState(options: {
}
}
}
+
function persistColumnOrder(indexes: number[]) {
const tableScopeKey = toValue(options.tableScopeKey);
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
- removeDataGridColumnOrder(toValue(options.layoutScopeKey));
+ persistedColumnOrderKeys.value = [];
+ persistColumnLayoutImmediately();
if (tableScopeKey) {
removeTableDataGridColumnOrder(tableScopeKey);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
- persistedColumnOrderKeys.value = [];
return;
}
- const keys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
+ const currentKeys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
+ const keys = mergeUnavailableColumnOrderKeys(currentKeys, persistedColumnOrderKeys.value);
persistedColumnOrderKeys.value = keys;
- saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), keys);
+ persistColumnLayoutImmediately();
if (tableScopeKey) {
saveTableDataGridColumnOrder(tableScopeKey, keys);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
}
+
+ function moveDisplayableColumn(fromDisplayableIndex: number, toDisplayableIndex: number) {
+ const next = moveDisplayableColumnIndex({
+ orderedIndexes: orderedDisplayableColumnIndexes.value,
+ fromDisplayableIndex,
+ toDisplayableIndex,
+ });
+ persistColumnOrder(next);
+ }
+
function resetColumnOrder() {
- removeDataGridColumnOrder(toValue(options.layoutScopeKey));
+ persistedColumnOrderKeys.value = [];
+ persistColumnLayoutImmediately();
const tableScopeKey = toValue(options.tableScopeKey);
if (tableScopeKey) {
removeTableDataGridColumnOrder(tableScopeKey);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
- persistedColumnOrderKeys.value = [];
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
}
+
function setNullColumnsHidden(value: boolean) {
if (options.hideNullColumns === undefined) localNullColumnsHidden.value = value;
else options.onHideNullColumnsChange?.(value);
@@ -250,16 +340,17 @@ export function useDataGridColumnLayoutState(options: {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined;
if (!detail || detail.scopeKey !== toValue(options.tableScopeKey)) return;
- loadColumnOrder();
+ persistedColumnOrderKeys.value = loadTableDataGridColumnOrder(detail.scopeKey);
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
}
- function resetColumnVisibility(hiddenColumnKeys: readonly string[] = []) {
+ function resetColumnVisibility(hiddenColumnKeys: readonly string[] = persistedHiddenColumnKeys.value) {
hiddenColumnIndexes.value = hiddenColumnIndexesForKeys(hiddenColumnKeys, toValue(options.columnOrderKeys), toValue(options.displayableColumnIndexes));
autoHiddenNullColumnIndexes.value = new Set();
applyNullColumnVisibility(nullColumnsHidden.value);
}
+ onScopeDispose(flushPersistColumnLayout);
watch([() => nullColumnsHidden.value, () => [...toValue(options.allNullColumnIndexes)], () => [...toValue(options.displayableColumnIndexes)]], ([hidden]) => applyNullColumnVisibility(hidden as boolean), { immediate: true });
watch(
() => visibleColumnIndexes.value.length,
@@ -271,11 +362,12 @@ export function useDataGridColumnLayoutState(options: {
watch(
[() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)],
() => {
- loadColumnOrder();
+ loadColumnLayout();
loadFrozenColumnCount();
},
{ immediate: true },
);
+ watch([() => [...toValue(options.columnOrderKeys)], () => [...toValue(options.displayableColumnIndexes)]], () => resetColumnVisibility(), { flush: "sync" });
return {
hiddenColumnIndexes,
@@ -287,13 +379,15 @@ export function useDataGridColumnLayoutState(options: {
allNullColumnCount,
hasCustomColumnOrder,
canToggleAllNullColumns,
- filteredColumnVisibilityOptions,
+ orderedColumnLayoutOptions,
+ filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
showColumn,
persistColumnOrder,
+ moveDisplayableColumn,
resetColumnOrder,
toggleAllNullColumns,
resetColumnVisibility,
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 9af2083c7..5cb5ddeda 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1216,6 +1216,8 @@ export default {
localFilterMoreValues: " +{count} more",
columnVisibility: "Columns",
columnVisibilityHint: "At least one column stays visible.",
+ columnReorderHint: "Drag to reorder columns.",
+ columnReorderSearchHint: "Clear the search to reorder columns.",
searchColumns: "Search columns...",
dragColumnToReorder: "Drag column header to reorder",
invertColumnVisibility: "Invert",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 1278ca14c..3ef4ae85d 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1113,6 +1113,8 @@ export default withEnglishFallback({
localFilterMoreValues: " +{count} más",
columnVisibility: "Columnas",
columnVisibilityHint: "Al menos una columna permanece visible.",
+ columnReorderHint: "Arrastra para reordenar las columnas.",
+ columnReorderSearchHint: "Borra la búsqueda para reordenar las columnas.",
searchColumns: "Buscar columnas...",
dragColumnToReorder: "Arrastra el encabezado para reordenar",
invertColumnVisibility: "Invertir",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 4dcb5c77d..fe1296a80 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1111,6 +1111,8 @@ export default withEnglishFallback({
localFilterMoreValues: " +{count} altri",
columnVisibility: "Colonne",
columnVisibilityHint: "Almeno una colonna deve rimanere visibile.",
+ columnReorderHint: "Trascina per riordinare le colonne.",
+ columnReorderSearchHint: "Cancella la ricerca per riordinare le colonne.",
searchColumns: "Cerca colonne...",
dragColumnToReorder: "Trascina l'intestazione per riordinare",
invertColumnVisibility: "Inverti",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 6af5bbd9c..634e85860 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1108,6 +1108,8 @@ export default withEnglishFallback({
localFilterMoreValues: " +{count}件以上",
columnVisibility: "列",
columnVisibilityHint: "少なくとも1列は表示されます。",
+ columnReorderHint: "ドラッグして列の順序を変更します。",
+ columnReorderSearchHint: "列の順序を変更するには検索をクリアしてください。",
searchColumns: "列を検索...",
dragColumnToReorder: "列ヘッダーをドラッグして並べ替え",
invertColumnVisibility: "反転",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 45a0ded75..3d9a6936f 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1200,6 +1200,8 @@ export default withEnglishFallback({
localFilterMoreValues: " 외 {count}개",
columnVisibility: "컬럼",
columnVisibilityHint: "최소 한 개의 컬럼은 표시 상태를 유지합니다.",
+ columnReorderHint: "드래그하여 컬럼 순서를 변경합니다.",
+ columnReorderSearchHint: "컬럼 순서를 변경하려면 검색을 지우세요.",
searchColumns: "컬럼 검색...",
dragColumnToReorder: "컬럼 머리글을 드래그하여 순서 변경",
invertColumnVisibility: "반전",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 22faca7b0..96db3293d 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1113,6 +1113,8 @@ export default withEnglishFallback({
localFilterMoreValues: " +{count} mais",
columnVisibility: "Colunas",
columnVisibilityHint: "Pelo menos uma coluna permanece visível.",
+ columnReorderHint: "Arraste para reordenar as colunas.",
+ columnReorderSearchHint: "Limpe a pesquisa para reordenar as colunas.",
searchColumns: "Pesquisar colunas...",
dragColumnToReorder: "Arraste o cabeçalho para reordenar",
invertColumnVisibility: "Inverter",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index aa0c5d98a..1acade3c7 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1217,6 +1217,8 @@ export default withEnglishFallback({
localFilterMoreValues: ",还有 {count} 个",
columnVisibility: "字段筛选",
columnVisibilityHint: "至少保留一列可见。",
+ columnReorderHint: "拖动可调整字段顺序。",
+ columnReorderSearchHint: "清除搜索后可调整字段顺序。",
searchColumns: "搜索字段...",
dragColumnToReorder: "拖拽列头调整顺序",
invertColumnVisibility: "反选",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index b5e970956..d8587ed52 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1112,6 +1112,8 @@ export default withEnglishFallback({
localFilterMoreValues: ",還有 {count} 個",
columnVisibility: "欄位篩選",
columnVisibilityHint: "至少保留一欄可見。",
+ columnReorderHint: "拖曳可調整欄位順序。",
+ columnReorderSearchHint: "清除搜尋後可調整欄位順序。",
searchColumns: "搜尋欄位……",
dragColumnToReorder: "拖曳欄位標題調整順序",
invertColumnVisibility: "反選",
diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts
index b5aa47ce5..32156c94f 100644
--- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts
+++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts
@@ -1,5 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
+import {
+ documentDataGridColumnLayoutScopeKey,
+ loadDataGridColumnLayout,
+ loadDataGridColumnOrder,
+ loadTableDataGridColumnOrder,
+ notifyTableDataGridColumnOrderChanged,
+ removeDataGridColumnOrder,
+ removeTableDataGridColumnOrder,
+ saveDataGridColumnLayout,
+ saveTableDataGridColumnOrder,
+ TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT,
+ tableDataGridColumnOrderScopeKey,
+} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
function installLocalStorage() {
const data = new Map();
@@ -57,3 +69,55 @@ describe("table data grid column order storage", () => {
expect(event.detail).toEqual({ scopeKey: "table-scope" });
});
});
+
+describe("data grid column layout storage", () => {
+ beforeEach(installLocalStorage);
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("uses a stable document scope without query or result column signatures", () => {
+ expect(
+ documentDataGridColumnLayoutScopeKey({
+ databaseType: "elasticsearch",
+ connectionId: "connection-1",
+ database: "database-name",
+ collection: "order_index_v1",
+ }),
+ ).toBe(["document", "elasticsearch", "connection-1", "database-name", "order_index_v1"].join("\u0001"));
+ });
+
+ it("stores order and hidden keys without dropping fields absent from the current page", () => {
+ const layout = {
+ orderKeys: ["_id", "status", "orderNo", "goodsList"],
+ hiddenKeys: ["goodsList"],
+ };
+
+ saveDataGridColumnLayout("document-layout", layout);
+
+ expect(loadDataGridColumnLayout("document-layout", ["_id", "orderNo", "status", "createTime"])).toEqual(layout);
+ });
+
+ it("keeps hidden keys when only the saved order is reset", () => {
+ saveDataGridColumnLayout("combined-layout", { orderKeys: ["status", "_id"], hiddenKeys: ["goodsList"] });
+
+ removeDataGridColumnOrder("combined-layout");
+
+ expect(loadDataGridColumnOrder("combined-layout", [])).toEqual([]);
+ expect(loadDataGridColumnLayout("combined-layout")).toEqual({ orderKeys: [], hiddenKeys: ["goodsList"] });
+ });
+
+ it("loads the previous order-only payload format", () => {
+ localStorage.setItem(
+ "dbx-data-grid-column-layout:legacy-layout",
+ JSON.stringify({
+ version: 1,
+ columnSignature: "id\0name",
+ order: ["name", "id"],
+ }),
+ );
+
+ expect(loadDataGridColumnLayout("legacy-layout", ["id", "name"])).toEqual({
+ orderKeys: ["name", "id"],
+ hiddenKeys: [],
+ });
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/document/documentGridColumnVisibilityStorage.spec.ts b/apps/desktop/src/lib/__tests__/document/documentGridColumnVisibilityStorage.spec.ts
new file mode 100644
index 000000000..0b3255eb9
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/document/documentGridColumnVisibilityStorage.spec.ts
@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { loadDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
+import { documentGridColumnVisibilityScopeKey, loadDocumentGridHiddenColumnKeys, migrateDocumentGridColumnVisibilityToLayout, saveDocumentGridHiddenColumnKeys } from "@/lib/document/documentGridColumnVisibilityStorage";
+
+const STORAGE_PREFIX = "dbx-document-grid-column-visibility:v1:";
+let storedValues: Map;
+
+function installLocalStorage() {
+ storedValues = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => storedValues.get(key) ?? null,
+ setItem: (key: string, value: string) => storedValues.set(key, value),
+ removeItem: (key: string) => storedValues.delete(key),
+ });
+}
+
+function scopeKey(collection: string) {
+ return documentGridColumnVisibilityScopeKey({
+ databaseType: "elasticsearch",
+ connectionId: "connection-1",
+ database: "",
+ collection,
+ });
+}
+
+describe("document grid column visibility storage", () => {
+ beforeEach(installLocalStorage);
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("stores normalized hidden field names", () => {
+ const ordersScope = scopeKey("orders");
+
+ saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList", "logisticsInfo", "goodsList"]);
+
+ expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual(["goodsList", "logisticsInfo"]);
+ });
+
+ it("isolates indexes and removes the persisted selection when all fields are shown", () => {
+ const ordersScope = scopeKey("orders");
+ const productsScope = scopeKey("products");
+ saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList"]);
+
+ expect(loadDocumentGridHiddenColumnKeys(productsScope)).toEqual([]);
+
+ saveDocumentGridHiddenColumnKeys(ordersScope, []);
+
+ expect(storedValues.has(`${STORAGE_PREFIX}${ordersScope}`)).toBe(false);
+ expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual([]);
+ });
+
+ it("ignores invalid persisted values without hiding unrelated fields", () => {
+ const ordersScope = scopeKey("orders");
+ storedValues.set(`${STORAGE_PREFIX}${ordersScope}`, JSON.stringify(["goodsList", 42, "goodsList", null]));
+
+ expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual(["goodsList"]);
+ });
+
+ it("reports malformed persisted JSON and falls back to all fields visible", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const ordersScope = scopeKey("orders");
+ storedValues.set(`${STORAGE_PREFIX}${ordersScope}`, "{");
+
+ expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual([]);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[DBX][document-grid-column-visibility:parse]"), expect.any(SyntaxError));
+ });
+
+ it("migrates legacy hidden fields into the unified layout once", () => {
+ const ordersScope = scopeKey("orders");
+ saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList"]);
+
+ migrateDocumentGridColumnVisibilityToLayout(ordersScope, "document-layout");
+ migrateDocumentGridColumnVisibilityToLayout(ordersScope, "document-layout");
+
+ expect(loadDataGridColumnLayout("document-layout")).toEqual({
+ orderKeys: [],
+ hiddenKeys: ["goodsList"],
+ });
+ expect(storedValues.has(`${STORAGE_PREFIX}${ordersScope}`)).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts
index 74dae2857..8fcde3ed8 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts
@@ -1,4 +1,5 @@
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
+import type { DatabaseType } from "@/types/database";
const STORAGE_PREFIX = "dbx-data-grid-column-layout:";
const TABLE_STORAGE_PREFIX = "dbx-data-grid-table-column-order:";
@@ -22,12 +23,28 @@ export interface DataGridColumnLayoutScope {
sourceColumns?: readonly (string | undefined)[];
}
-interface StoredDataGridColumnLayout {
+export interface DataGridColumnLayout {
+ orderKeys: string[];
+ hiddenKeys: string[];
+}
+
+interface StoredDataGridColumnLayout extends DataGridColumnLayout {
version: number;
- columnSignature: string;
+}
+
+interface LegacyStoredDataGridColumnLayout {
+ version: number;
+ columnSignature?: string;
order: string[];
}
+export interface DocumentDataGridColumnLayoutScope {
+ databaseType: DatabaseType;
+ connectionId: string;
+ database: string;
+ collection: string;
+}
+
export interface TableDataGridColumnOrderScope {
connectionId: string;
database: string;
@@ -50,30 +67,61 @@ export function dataGridColumnLayoutScopeKey(scope: DataGridColumnLayoutScope):
return [scope.connectionId ?? "", scope.database ?? "", scope.schema ?? "", scope.context ?? "", scope.tableSchema ?? "", scope.tableName ?? "", scope.tableName ? "" : normalizeSql(scope.sql), columnSignature, sourceSignature].join("\u0001");
}
-export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
+export function documentDataGridColumnLayoutScopeKey(scope: DocumentDataGridColumnLayoutScope): string {
+ return ["document", scope.databaseType, scope.connectionId, scope.database, scope.collection].join("\u0001");
+}
+
+function normalizedStringList(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return [...new Set(value.filter((key): key is string => typeof key === "string"))];
+}
+
+export function loadDataGridColumnLayout(scopeKey: string, columnKeys: readonly string[] = []): DataGridColumnLayout | null {
const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
- if (!raw) return [];
+ if (!raw) return null;
try {
- const parsed = JSON.parse(raw) as Partial;
- if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.order)) return [];
- if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return [];
- return parsed.order.filter((key): key is string => typeof key === "string");
- } catch {
- return [];
+ const parsed = JSON.parse(raw) as Partial;
+ if (parsed.version !== STORAGE_VERSION) return null;
+ if (Array.isArray(parsed.orderKeys) || Array.isArray(parsed.hiddenKeys)) {
+ return {
+ orderKeys: normalizedStringList(parsed.orderKeys),
+ hiddenKeys: normalizedStringList(parsed.hiddenKeys),
+ };
+ }
+ if (!Array.isArray(parsed.order)) return null;
+ if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return null;
+ return { orderKeys: normalizedStringList(parsed.order), hiddenKeys: [] };
+ } catch (error) {
+ console.warn(`[DBX][data-grid-column-layout:parse] ${scopeKey}`, error);
+ return null;
}
}
-export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
+export function saveDataGridColumnLayout(scopeKey: string, layout: DataGridColumnLayout) {
const payload: StoredDataGridColumnLayout = {
version: STORAGE_VERSION,
- columnSignature: columnKeys.join("\0"),
- order: [...order],
+ orderKeys: normalizedStringList(layout.orderKeys),
+ hiddenKeys: normalizedStringList(layout.hiddenKeys),
};
safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload));
}
+export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
+ return loadDataGridColumnLayout(scopeKey, columnKeys)?.orderKeys ?? [];
+}
+
+export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
+ const hiddenKeys = loadDataGridColumnLayout(scopeKey, columnKeys)?.hiddenKeys ?? [];
+ saveDataGridColumnLayout(scopeKey, { orderKeys: [...order], hiddenKeys });
+}
+
export function removeDataGridColumnOrder(scopeKey: string) {
- safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
+ const layout = loadDataGridColumnLayout(scopeKey);
+ if (!layout) {
+ safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
+ return;
+ }
+ saveDataGridColumnLayout(scopeKey, { orderKeys: [], hiddenKeys: layout.hiddenKeys });
}
export function tableDataGridColumnOrderScopeKey(scope: TableDataGridColumnOrderScope): string {
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts
index 90b91b726..f0efe7007 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts
@@ -46,7 +46,7 @@ export function buildDataGridColumnLookupItems(options: DataGridColumnLookupOpti
});
}
-export function filterDataGridColumnLookupItems(items: readonly DataGridColumnLookupItem[], query: string): DataGridColumnLookupItem[] {
+export function filterDataGridColumnLookupItems(items: readonly ColumnLookupItem[], query: string): ColumnLookupItem[] {
const normalizedQuery = normalizedSearchText(query);
if (!normalizedQuery) return [...items];
return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => value.toLocaleLowerCase().includes(normalizedQuery)));
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnOrder.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnOrder.ts
index 62c363cec..357cfb5ce 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnOrder.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnOrder.ts
@@ -57,6 +57,48 @@ export function moveVisibleColumnIndex(options: { orderedIndexes: readonly numbe
return next;
}
+export function moveDisplayableColumnIndex(options: { orderedIndexes: readonly number[]; fromDisplayableIndex: number; toDisplayableIndex: number }): number[] {
+ const next = [...options.orderedIndexes];
+ if (next.length === 0) return next;
+
+ const from = Math.max(0, Math.min(options.fromDisplayableIndex, next.length - 1));
+ const to = Math.max(0, Math.min(options.toDisplayableIndex, next.length - 1));
+ const [moved] = next.splice(from, 1);
+ if (moved === undefined) return next;
+
+ next.splice(to, 0, moved);
+ return next;
+}
+
+export function mergeUnavailableColumnOrderKeys(currentKeys: readonly string[], previousKeys: readonly string[]): string[] {
+ const normalizedCurrentKeys = [...new Set(currentKeys)];
+ const currentKeySet = new Set(normalizedCurrentKeys);
+ const missingBeforeCurrent = new Set();
+ const missingAfterCurrent = new Map>();
+ let precedingCurrentKey: string | undefined;
+
+ for (const key of previousKeys) {
+ if (currentKeySet.has(key)) {
+ precedingCurrentKey = key;
+ continue;
+ }
+ if (!precedingCurrentKey) {
+ missingBeforeCurrent.add(key);
+ continue;
+ }
+ const anchoredKeys = missingAfterCurrent.get(precedingCurrentKey) ?? new Set();
+ anchoredKeys.add(key);
+ missingAfterCurrent.set(precedingCurrentKey, anchoredKeys);
+ }
+
+ const mergedKeys = [...missingBeforeCurrent];
+ for (const key of normalizedCurrentKeys) {
+ mergedKeys.push(key);
+ mergedKeys.push(...(missingAfterCurrent.get(key) ?? []));
+ }
+ return mergedKeys;
+}
+
export function columnOrderKeysForIndexes(indexes: readonly number[], columnKeys: readonly string[]): string[] {
return indexes.map((index) => columnKeys[index]).filter((key): key is string => !!key);
}
diff --git a/apps/desktop/src/lib/document/documentGridColumnVisibilityStorage.ts b/apps/desktop/src/lib/document/documentGridColumnVisibilityStorage.ts
new file mode 100644
index 000000000..912c57152
--- /dev/null
+++ b/apps/desktop/src/lib/document/documentGridColumnVisibilityStorage.ts
@@ -0,0 +1,50 @@
+import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
+import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
+
+const STORAGE_PREFIX = "dbx-document-grid-column-visibility:v1:";
+
+export interface DocumentGridColumnVisibilityScope {
+ databaseType?: string;
+ connectionId: string;
+ database: string;
+ collection: string;
+}
+
+export function documentGridColumnVisibilityScopeKey(scope: DocumentGridColumnVisibilityScope): string {
+ return ["document-column-visibility", scope.databaseType ?? "", scope.connectionId, scope.database, scope.collection].join("\u0001");
+}
+
+export function loadDocumentGridHiddenColumnKeys(scopeKey: string): string[] {
+ const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
+ if (!raw) return [];
+
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return [...new Set(parsed.filter((key): key is string => typeof key === "string"))];
+ } catch (error) {
+ console.warn(`[DBX][document-grid-column-visibility:parse] ${scopeKey}`, error);
+ return [];
+ }
+}
+
+export function saveDocumentGridHiddenColumnKeys(scopeKey: string, hiddenColumnKeys: readonly string[]) {
+ const normalizedKeys = [...new Set(hiddenColumnKeys)];
+ if (normalizedKeys.length === 0) {
+ safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
+ return;
+ }
+ safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(normalizedKeys));
+}
+
+export function migrateDocumentGridColumnVisibilityToLayout(legacyScopeKey: string, layoutScopeKey: string) {
+ const legacyStorageKey = `${STORAGE_PREFIX}${legacyScopeKey}`;
+ if (loadDataGridColumnLayout(layoutScopeKey)) {
+ safeLocalStorageRemove(legacyStorageKey);
+ return;
+ }
+ const hiddenKeys = loadDocumentGridHiddenColumnKeys(legacyScopeKey);
+ if (hiddenKeys.length === 0) return;
+ saveDataGridColumnLayout(layoutScopeKey, { orderKeys: [], hiddenKeys });
+ if (loadDataGridColumnLayout(layoutScopeKey)) safeLocalStorageRemove(legacyStorageKey);
+}
diff --git a/packages/app-tests/dataGridColumnOrder.test.ts b/packages/app-tests/dataGridColumnOrder.test.ts
index b912cf925..00972bba0 100644
--- a/packages/app-tests/dataGridColumnOrder.test.ts
+++ b/packages/app-tests/dataGridColumnOrder.test.ts
@@ -1,99 +1,108 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
-import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnOrder";
+import { columnOrderKeysForIndexes, isDefaultColumnOrder, mergeUnavailableColumnOrderKeys, moveDisplayableColumnIndex, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnOrder";
test("creates stable keys for duplicate column names", () => {
- assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "name", "name"]), [`id\u00000`, `name\u00000`, `name\u00001`]);
+ assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "name", "name"]), [`id\u00000`, `name\u00000`, `name\u00001`]);
});
test("uses source columns when available", () => {
- assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "display_name"], ["id", "name"]), [`id\u00000`, `name\u00000`]);
+ assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "display_name"], ["id", "name"]), [`id\u00000`, `name\u00000`]);
});
test("orders available indexes from persisted keys and appends new columns", () => {
- const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email", "created_at"]);
- assert.deepEqual(
- orderedColumnIndexes({
- availableIndexes: [0, 1, 2, 3],
- columnKeys: keys,
- orderedKeys: [keys[2], keys[0], "missing"],
- }),
- [2, 0, 1, 3],
- );
+ const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email", "created_at"]);
+ assert.deepEqual(
+ orderedColumnIndexes({
+ availableIndexes: [0, 1, 2, 3],
+ columnKeys: keys,
+ orderedKeys: [keys[2], keys[0], "missing"],
+ }),
+ [2, 0, 1, 3],
+ );
});
test("ignores unavailable indexes while preserving displayable columns", () => {
- const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
- assert.deepEqual(
- orderedColumnIndexes({
- availableIndexes: [1, 2],
- columnKeys: keys,
- orderedKeys: [keys[0], keys[2], keys[1]],
- }),
- [2, 1],
- );
+ const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
+ assert.deepEqual(
+ orderedColumnIndexes({
+ availableIndexes: [1, 2],
+ columnKeys: keys,
+ orderedKeys: [keys[0], keys[2], keys[1]],
+ }),
+ [2, 1],
+ );
});
test("moves a visible column forward", () => {
- assert.deepEqual(
- moveVisibleColumnIndex({
- orderedIndexes: [0, 1, 2, 3],
- hiddenIndexes: new Set(),
- fromVisibleIndex: 3,
- toVisibleIndex: 1,
- }),
- [0, 3, 1, 2],
- );
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set(),
+ fromVisibleIndex: 3,
+ toVisibleIndex: 1,
+ }),
+ [0, 3, 1, 2],
+ );
});
test("moves a visible column backward", () => {
- assert.deepEqual(
- moveVisibleColumnIndex({
- orderedIndexes: [0, 1, 2, 3],
- hiddenIndexes: new Set(),
- fromVisibleIndex: 1,
- toVisibleIndex: 3,
- }),
- [0, 2, 3, 1],
- );
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set(),
+ fromVisibleIndex: 1,
+ toVisibleIndex: 3,
+ }),
+ [0, 2, 3, 1],
+ );
});
test("moves a visible column to an adjacent later position", () => {
- assert.deepEqual(
- moveVisibleColumnIndex({
- orderedIndexes: [0, 1, 2, 3],
- hiddenIndexes: new Set(),
- fromVisibleIndex: 1,
- toVisibleIndex: 2,
- }),
- [0, 2, 1, 3],
- );
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set(),
+ fromVisibleIndex: 1,
+ toVisibleIndex: 2,
+ }),
+ [0, 2, 1, 3],
+ );
});
test("moves visible columns without disturbing hidden column identity", () => {
- assert.deepEqual(
- moveVisibleColumnIndex({
- orderedIndexes: [0, 1, 2, 3],
- hiddenIndexes: new Set([1]),
- fromVisibleIndex: 2,
- toVisibleIndex: 0,
- }),
- [3, 0, 1, 2],
- );
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set([1]),
+ fromVisibleIndex: 2,
+ toVisibleIndex: 0,
+ }),
+ [3, 0, 1, 2],
+ );
+});
+
+test("moves displayable columns including hidden fields", () => {
+ assert.deepEqual(moveDisplayableColumnIndex({ orderedIndexes: [0, 1, 2, 3], fromDisplayableIndex: 3, toDisplayableIndex: 1 }), [0, 3, 1, 2]);
+ assert.deepEqual(moveDisplayableColumnIndex({ orderedIndexes: [0, 1, 2, 3], fromDisplayableIndex: 1, toDisplayableIndex: 3 }), [0, 2, 3, 1]);
+});
+
+test("keeps unavailable fields anchored when the current page order changes", () => {
+ assert.deepEqual(mergeUnavailableColumnOrderKeys(["id", "orderNo", "status", "createTime"], ["id", "status", "orderNo", "goodsList"]), ["id", "orderNo", "goodsList", "status", "createTime"]);
});
test("returns no-op for invalid or same visible indexes", () => {
- const orderedIndexes = [0, 1, 2];
- assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: 1, toVisibleIndex: 1 }), orderedIndexes);
- assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: -1, toVisibleIndex: 1 }), orderedIndexes);
+ const orderedIndexes = [0, 1, 2];
+ assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: 1, toVisibleIndex: 1 }), orderedIndexes);
+ assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: -1, toVisibleIndex: 1 }), orderedIndexes);
});
test("converts indexes back to persisted keys", () => {
- const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
- assert.deepEqual(columnOrderKeysForIndexes([2, 0, 1], keys), [keys[2], keys[0], keys[1]]);
+ const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
+ assert.deepEqual(columnOrderKeysForIndexes([2, 0, 1], keys), [keys[2], keys[0], keys[1]]);
});
test("detects default order", () => {
- assert.equal(isDefaultColumnOrder([0, 1, 2], [0, 1, 2]), true);
- assert.equal(isDefaultColumnOrder([0, 1, 2], [1, 0, 2]), false);
+ assert.equal(isDefaultColumnOrder([0, 1, 2], [0, 1, 2]), true);
+ assert.equal(isDefaultColumnOrder([0, 1, 2], [1, 0, 2]), false);
});