From 44f748d3a185b014f7fdccb68a47c78e4e5c2ff0 Mon Sep 17 00:00:00 2001 From: Ashton Lin Date: Fri, 31 Jul 2026 02:01:46 +0800 Subject: [PATCH] feat(grid): persist column layouts and improve drag-to-reorder --- .../components/document/DocumentBrowser.vue | 81 ++-- .../DocumentBrowserFieldSearch.spec.ts | 85 +++- apps/desktop/src/components/grid/DataGrid.vue | 72 ++-- .../grid/DataGridColumnLayoutPopover.vue | 408 ++++++++++++++++++ .../dataGridColumnLayoutPopover.spec.ts | 323 ++++++++++++++ .../grid/dataGridColumnLayoutPopover.ts | 63 +++ .../src/components/layout/ContentArea.vue | 70 +-- .../__tests__/useDataGridColumnLayout.spec.ts | 78 +++- .../composables/useDataGridColumnLayout.ts | 156 +++++-- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../dataGridColumnLayoutStorage.spec.ts | 66 ++- ...ocumentGridColumnVisibilityStorage.spec.ts | 83 ++++ .../dataGrid/dataGridColumnLayoutStorage.ts | 76 +++- .../src/lib/dataGrid/dataGridColumnLookup.ts | 2 +- .../src/lib/dataGrid/dataGridColumnOrder.ts | 42 ++ .../documentGridColumnVisibilityStorage.ts | 50 +++ .../app-tests/dataGridColumnOrder.test.ts | 137 +++--- 24 files changed, 1532 insertions(+), 276 deletions(-) create mode 100644 apps/desktop/src/components/grid/DataGridColumnLayoutPopover.vue create mode 100644 apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts create mode 100644 apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts create mode 100644 apps/desktop/src/lib/__tests__/document/documentGridColumnVisibilityStorage.spec.ts create mode 100644 apps/desktop/src/lib/document/documentGridColumnVisibilityStorage.ts 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 @@ + + + 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); });