feat(grid): persist column layouts and improve drag-to-reorder

This commit is contained in:
Ashton Lin 2026-07-31 02:01:46 +08:00 committed by GitHub
parent f5c29f08f1
commit 44f748d3a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1532 additions and 276 deletions

View File

@ -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 });
<div class="flex-1" />
<Popover v-if="viewMode === 'table' && gridResult.columns.length">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }" :title="t('grid.columnVisibility')" :aria-label="t('grid.columnVisibility')">
<Columns3 class="h-3.5 w-3.5" />
{{ t("grid.columnVisibility") }}
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }} </span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[10px] text-muted-foreground tabular-nums">{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="columnVisibilitySearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
</div>
<div class="max-h-72 overflow-auto py-0.5">
<button v-for="option in columnVisibilityOptions" :key="`${option.index}:${option.column}`" type="button" class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="dataGridRef?.toggleColumnVisibility(option.index)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dataGridRef?.isColumnVisible(option.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
</button>
<div v-if="columnVisibilityOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!dataGridRef?.hasCustomColumnOrder" @click="dataGridRef?.resetColumnOrder()">
{{ t("grid.resetColumnOrder") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
{{ t("grid.showAllColumns") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<DataGridColumnLayoutPopover v-if="viewMode === 'table' && gridResult.columns.length" :grid="dataGridRef" />
<Popover v-if="viewMode === 'table' && gridResult.columns.length" v-model:open="viewOptionsOpen">
<PopoverTrigger as-child>
@ -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"

View File

@ -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<Element> | null = null;
let root: HTMLDivElement | null = null;
let storedValues: Map<string, string>;
async function flushUi() {
for (let index = 0; index < 4; index++) {
@ -182,6 +210,12 @@ async function setSearchInput(value: string) {
}
beforeEach(async () => {
storedValues = new Map<string, string>();
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<HTMLElement>('[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<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
await flushUi();

View File

@ -275,6 +275,7 @@ interface DataGridProps {
database?: string;
executionDatabase?: string;
schema?: string;
columnLayoutScopeKey?: string;
context?: "results" | "table-data";
autoTransposeSingleRow?: boolean;
sourceColumns?: Array<string | undefined>;
@ -351,7 +352,6 @@ const emit = defineEmits<{
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
"local-column-filters-change": [value: Record<string, string[]>];
"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,

View File

@ -0,0 +1,408 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch, type CSSProperties } from "vue";
import { Check, Columns3, GripVertical, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
import { dragSortAutoScrollDelta } from "@/composables/useDragSort";
import {
DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD,
DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT,
DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD,
dataGridColumnLayoutDropTarget,
dataGridColumnLayoutVirtualWindow,
type DataGridColumnLayoutHandle,
} from "./dataGridColumnLayoutPopover";
const props = withDefaults(
defineProps<{
grid?: DataGridColumnLayoutHandle;
triggerClass?: string;
}>(),
{
triggerClass: "",
},
);
const { t } = useI18n();
const popoverOpen = ref(false);
const columnSearch = ref("");
const listRef = ref<HTMLElement>();
const listScrollTop = ref(0);
const draggedDisplayPosition = ref<number | null>(null);
const dragTargetDisplayPosition = ref<number | null>(null);
const dragInsertionIndex = ref<number | null>(null);
const dragPreviewOption = ref<DataGridColumnLayoutOption | null>(null);
const dragPreviewStyle = ref<CSSProperties>();
let columnDragAutoScrollFrame = 0;
interface ColumnDragState {
pointerId: number;
fromDisplayPosition: number;
startClientX: number;
startClientY: number;
lastClientX: number;
lastClientY: number;
active: boolean;
handle: HTMLElement;
option: DataGridColumnLayoutOption;
previewOffsetX: number;
previewOffsetY: number;
previewWidth: number;
previousBodyUserSelect: string;
}
let columnDragState: ColumnDragState | null = null;
const columnLayoutOptions = computed(() => props.grid?.filteredColumnLayoutOptions(columnSearch.value) ?? []);
const columnReorderEnabled = computed(() => columnSearch.value.trim() === "");
const virtualized = computed(() => columnLayoutOptions.value.length > DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD);
const virtualWindow = computed(() =>
dataGridColumnLayoutVirtualWindow({
itemCount: columnLayoutOptions.value.length,
scrollTop: listScrollTop.value,
}),
);
const renderedOptions = computed(() => {
if (!virtualized.value) return columnLayoutOptions.value;
return columnLayoutOptions.value.slice(virtualWindow.value.start, virtualWindow.value.end);
});
const listContentStyle = computed<CSSProperties | undefined>(() => {
if (!virtualized.value) return undefined;
return { height: `${virtualWindow.value.totalHeight}px`, position: "relative" };
});
const renderedOptionsStyle = computed<CSSProperties | undefined>(() => {
if (!virtualized.value) return undefined;
return {
left: 0,
position: "absolute",
right: 0,
top: 0,
transform: `translateY(${virtualWindow.value.offsetTop}px)`,
};
});
function columnLayoutRowStyle(option: DataGridColumnLayoutOption): CSSProperties {
const style: CSSProperties = { height: `${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px` };
const sourcePosition = draggedDisplayPosition.value;
const targetPosition = dragTargetDisplayPosition.value;
if (sourcePosition === null || targetPosition === null || option.displayPosition === sourcePosition) return style;
let offset = 0;
if (targetPosition < sourcePosition && option.displayPosition >= targetPosition && option.displayPosition < sourcePosition) offset = DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
if (targetPosition > sourcePosition && option.displayPosition > sourcePosition && option.displayPosition <= targetPosition) offset = -DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
if (!offset) return style;
return { ...style, transform: `translateY(${offset}px)`, transition: "transform 120ms ease-out" };
}
function resetListScroll() {
listScrollTop.value = 0;
void nextTick(() => {
if (listRef.value) listRef.value.scrollTop = 0;
});
}
function onListScroll(event: Event) {
listScrollTop.value = (event.currentTarget as HTMLElement).scrollTop;
}
function removeColumnDragListeners() {
window.removeEventListener("pointermove", onWindowPointerMove, true);
window.removeEventListener("pointerup", onWindowPointerUp, true);
window.removeEventListener("pointercancel", onWindowPointerCancel, true);
window.removeEventListener("blur", resetColumnDragState);
window.removeEventListener("keydown", onWindowKeyDown, true);
}
function cancelColumnDragAutoScroll() {
if (!columnDragAutoScrollFrame) return;
window.cancelAnimationFrame(columnDragAutoScrollFrame);
columnDragAutoScrollFrame = 0;
}
function resetColumnDragState() {
const state = columnDragState;
columnDragState = null;
cancelColumnDragAutoScroll();
removeColumnDragListeners();
if (state?.active) document.body.style.userSelect = state.previousBodyUserSelect;
if (state?.handle.hasPointerCapture?.(state.pointerId)) state.handle.releasePointerCapture(state.pointerId);
draggedDisplayPosition.value = null;
dragTargetDisplayPosition.value = null;
dragInsertionIndex.value = null;
dragPreviewOption.value = null;
dragPreviewStyle.value = undefined;
}
function updateColumnDragTarget(clientY: number) {
const state = columnDragState;
const list = listRef.value;
if (!state?.active || !list) return;
const target = dataGridColumnLayoutDropTarget({
clientY,
listTop: list.getBoundingClientRect().top,
scrollTop: list.scrollTop,
itemCount: columnLayoutOptions.value.length,
fromDisplayPosition: state.fromDisplayPosition,
});
dragInsertionIndex.value = target.insertionIndex;
dragTargetDisplayPosition.value = target.toDisplayPosition;
}
function columnDragAutoScrollDelta(): number {
const state = columnDragState;
const list = listRef.value;
if (!state?.active || !list) return 0;
return dragSortAutoScrollDelta({
pointerX: state.lastClientX,
pointerY: state.lastClientY,
rect: list.getBoundingClientRect(),
scrollTop: list.scrollTop,
clientHeight: list.clientHeight,
scrollHeight: list.scrollHeight,
});
}
function runColumnDragAutoScroll() {
columnDragAutoScrollFrame = 0;
const state = columnDragState;
const list = listRef.value;
const delta = columnDragAutoScrollDelta();
if (!state?.active || !list || !delta) return;
const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight);
const previousScrollTop = list.scrollTop;
list.scrollTop = Math.min(maxScrollTop, Math.max(0, previousScrollTop + delta));
listScrollTop.value = list.scrollTop;
if (list.scrollTop !== previousScrollTop) updateColumnDragTarget(state.lastClientY);
scheduleColumnDragAutoScroll();
}
function scheduleColumnDragAutoScroll() {
if (columnDragAutoScrollFrame || !columnDragAutoScrollDelta()) return;
columnDragAutoScrollFrame = window.requestAnimationFrame(runColumnDragAutoScroll);
}
function updateColumnDragPreview(clientX: number, clientY: number) {
const state = columnDragState;
if (!state?.active) return;
dragPreviewStyle.value = {
height: `${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px`,
transform: `translate3d(${clientX - state.previewOffsetX}px, ${clientY - state.previewOffsetY}px, 0)`,
width: `${state.previewWidth}px`,
};
}
function activateColumnDrag(event: PointerEvent) {
const state = columnDragState;
if (!state || state.active) return;
state.active = true;
state.previousBodyUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
draggedDisplayPosition.value = state.fromDisplayPosition;
dragPreviewOption.value = state.option;
updateColumnDragPreview(event.clientX, event.clientY);
updateColumnDragTarget(event.clientY);
}
function onWindowPointerMove(event: PointerEvent) {
const state = columnDragState;
if (!state || event.pointerId !== state.pointerId) return;
state.lastClientX = event.clientX;
state.lastClientY = event.clientY;
const movedPastThreshold = Math.hypot(event.clientX - state.startClientX, event.clientY - state.startClientY) >= DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD;
if (!state.active && !movedPastThreshold) return;
event.preventDefault();
activateColumnDrag(event);
updateColumnDragPreview(event.clientX, event.clientY);
updateColumnDragTarget(event.clientY);
scheduleColumnDragAutoScroll();
}
function onWindowPointerUp(event: PointerEvent) {
const state = columnDragState;
if (!state || event.pointerId !== state.pointerId) return;
const fromDisplayPosition = state.fromDisplayPosition;
const toDisplayPosition = dragTargetDisplayPosition.value;
const shouldCommit = state.active && toDisplayPosition !== null && toDisplayPosition !== fromDisplayPosition;
if (state.active) event.preventDefault();
resetColumnDragState();
if (shouldCommit) props.grid?.moveDisplayableColumn(fromDisplayPosition, toDisplayPosition);
}
function onWindowPointerCancel(event: PointerEvent) {
if (event.pointerId === columnDragState?.pointerId) resetColumnDragState();
}
function onWindowKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || !columnDragState) return;
event.preventDefault();
resetColumnDragState();
}
function columnDragPreviewMetrics(handle: HTMLElement, event: PointerEvent) {
const rowRect = handle.closest<HTMLElement>("[data-column-layout-row]")?.getBoundingClientRect();
const listRect = listRef.value?.getBoundingClientRect();
const previewWidth = rowRect?.width || listRect?.width || 288;
const rowLeft = rowRect?.width ? rowRect.left : (listRect?.left ?? event.clientX);
const rowTop = rowRect?.height ? rowRect.top : event.clientY - DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT / 2;
return {
previewOffsetX: Math.min(previewWidth, Math.max(0, event.clientX - rowLeft)),
previewOffsetY: Math.min(DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT, Math.max(0, event.clientY - rowTop)),
previewWidth,
};
}
function startColumnDrag(option: DataGridColumnLayoutOption, event: PointerEvent) {
if (!columnReorderEnabled.value || event.button !== 0) return;
resetColumnDragState();
const handle = event.currentTarget as HTMLElement;
columnDragState = {
pointerId: event.pointerId,
fromDisplayPosition: option.displayPosition,
startClientX: event.clientX,
startClientY: event.clientY,
lastClientX: event.clientX,
lastClientY: event.clientY,
active: false,
handle,
option,
...columnDragPreviewMetrics(handle, event),
previousBodyUserSelect: "",
};
window.addEventListener("pointermove", onWindowPointerMove, true);
window.addEventListener("pointerup", onWindowPointerUp, true);
window.addEventListener("pointercancel", onWindowPointerCancel, true);
window.addEventListener("blur", resetColumnDragState);
window.addEventListener("keydown", onWindowKeyDown, true);
try {
handle.setPointerCapture?.(event.pointerId);
} catch (error) {
console.warn("[DBX][DataGridColumnLayoutPopover:pointer-capture]", error);
}
}
watch(columnSearch, () => {
resetColumnDragState();
resetListScroll();
});
watch(popoverOpen, (open) => {
if (!open) resetColumnDragState();
});
onBeforeUnmount(resetColumnDragState);
</script>
<template>
<Popover v-model:open="popoverOpen">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent"
:class="[triggerClass, { 'bg-accent text-foreground': (grid?.hiddenColumnCount ?? 0) > 0 }]"
:disabled="!grid"
:title="t('grid.columnVisibility')"
:aria-label="t('grid.columnVisibility')"
>
<Columns3 class="h-3.5 w-3.5" />
{{ t("grid.columnVisibility") }}
<span v-if="(grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[10px] text-muted-foreground tabular-nums">{{ grid?.visibleColumnCount ?? 0 }}/{{ grid?.displayableColumnCount ?? 0 }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="columnSearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
</div>
<div ref="listRef" data-column-layout-list class="max-h-72 overflow-auto" :style="{ maxHeight: `${DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT}px` }" @scroll="onListScroll">
<div class="relative" :style="listContentStyle">
<div :style="renderedOptionsStyle">
<div
v-for="option in renderedOptions"
:key="option.key"
data-column-layout-row
:data-display-position="option.displayPosition"
class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-1 text-xs hover:bg-accent"
:class="{ 'opacity-25': draggedDisplayPosition === option.displayPosition }"
:style="columnLayoutRowStyle(option)"
>
<button
type="button"
data-column-drag-handle
class="flex h-5 w-5 touch-none items-center justify-center justify-self-center rounded border border-transparent text-muted-foreground"
:class="columnReorderEnabled ? 'cursor-move hover:border-border hover:bg-background' : 'cursor-not-allowed opacity-30'"
:disabled="!columnReorderEnabled"
:aria-label="`${columnReorderEnabled ? t('grid.columnReorderHint') : t('grid.columnReorderSearchHint')} ${option.column}`"
:title="columnReorderEnabled ? t('grid.columnReorderHint') : t('grid.columnReorderSearchHint')"
@pointerdown.stop.prevent="startColumnDrag(option, $event)"
>
<GripVertical class="h-3.5 w-3.5" />
</button>
<button type="button" data-column-visibility-toggle class="grid h-full min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] items-center text-left" :aria-pressed="option.visible" @click="grid?.toggleColumnVisibility(option.index)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="option.visible ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
<span v-if="option.comment" class="truncate text-[10px] text-muted-foreground" :title="option.comment">{{ option.comment }}</span>
</span>
</button>
</div>
</div>
<div
v-if="draggedDisplayPosition !== null && dragInsertionIndex !== null"
data-column-drop-indicator
class="pointer-events-none absolute left-1 right-1 z-10 h-0.5 -translate-y-1/2 rounded-full bg-primary shadow-sm"
:style="{ top: `${dragInsertionIndex * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px` }"
/>
</div>
<div v-if="columnLayoutOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] leading-4 text-muted-foreground">
{{ t("grid.columnVisibilityHint") }}
{{ columnReorderEnabled ? t("grid.columnReorderHint") : t("grid.columnReorderSearchHint") }}
</span>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(grid?.displayableColumnCount ?? 0) <= 1" @click="grid?.invertColumnVisibility()">
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!grid?.hasCustomColumnOrder" @click="grid?.resetColumnOrder()">
{{ t("grid.resetColumnOrder") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(grid?.hiddenColumnCount ?? 0) === 0" @click="grid?.showAllColumns()">
{{ t("grid.showAllColumns") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Teleport to="body">
<div
v-if="dragPreviewOption"
data-column-drag-preview
aria-hidden="true"
class="pointer-events-none fixed left-0 top-0 z-[100] grid grid-cols-[1.5rem_1.5rem_minmax(0,1fr)] items-center rounded border bg-background px-1 text-xs shadow-lg ring-1 ring-primary/40 will-change-transform dark:bg-muted"
:style="dragPreviewStyle"
>
<span class="flex h-5 w-5 items-center justify-center text-muted-foreground">
<GripVertical class="h-3.5 w-3.5" />
</span>
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dragPreviewOption.visible ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate font-mono text-xs">{{ dragPreviewOption.column }}</span>
<span v-if="dragPreviewOption.comment" class="truncate text-[10px] text-muted-foreground">{{ dragPreviewOption.comment }}</span>
</span>
</div>
</Teleport>
</template>

View File

@ -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<HTMLElement>("[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<HTMLButtonElement>("[data-column-drag-handle]");
const visibilityToggles = host.querySelectorAll<HTMLButtonElement>("[data-column-visibility-toggle]");
const rows = host.querySelectorAll<HTMLElement>("[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<HTMLButtonElement>("[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<HTMLElement>("[data-column-drag-preview]")!;
const initialPreviewTransform = dragPreview.style.transform;
expect(host.querySelector<HTMLElement>("[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<HTMLElement>('[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<HTMLButtonElement>("[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<HTMLInputElement>("input")!;
search.value = "column_1";
search.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const filteredHandle = host.querySelector<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>(".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<HTMLButtonElement>("[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<number, FrameRequestCallback>();
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<HTMLButtonElement>("[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);
});
});

View File

@ -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,
};
}

View File

@ -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<typeof import("@/components/grid/DataGrid.vue")> | 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> | 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<boolean> }>();
const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
const standaloneResultToolbarWidth = ref(0);
const resultTabsScrollerRef = ref<HTMLElement | null>(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<string, string[]>) => 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
</span>
<span v-if="activeTab.mode === 'data' && activeTab.tableMeta" class="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums"> {{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }} </span>
<span class="ml-auto" />
<Popover v-if="activeTab.result?.columns.length">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }">
<Columns3 class="h-3.5 w-3.5" />
{{ t("grid.columnVisibility") }}
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }} </span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[10px] text-muted-foreground tabular-nums">{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="columnVisibilitySearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
</div>
<div class="max-h-72 overflow-auto py-0.5">
<button v-for="option in columnVisibilityOptions" :key="`${option.index}:${option.column}`" type="button" class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="dataGridRef?.toggleColumnVisibility(option.index)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dataGridRef?.isColumnVisible(option.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="min-w-0">
<span class="block truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
<span v-if="option.comment" class="block truncate text-[11px] leading-4 text-muted-foreground" :title="option.comment">{{ option.comment }}</span>
</span>
</button>
<div v-if="columnVisibilityOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!dataGridRef?.hasCustomColumnOrder" @click="dataGridRef?.resetColumnOrder()">
{{ t("grid.resetColumnOrder") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
{{ t("grid.showAllColumns") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<DataGridColumnLayoutPopover v-if="activeTab.result?.columns.length" :grid="dataGridRef" trigger-class="px-1.5" />
<Button v-if="activeTab.result && activeTab.tableMeta && activeTab.connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': dataGridRef?.showDdl }" @click="dataGridRef?.toggleDdl()"
><TableProperties class="h-3.5 w-3.5" />{{ t("grid.tableInfo") }}</Button
>
@ -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<string, string[]>) => 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)"

View File

@ -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<string, string>();
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<string[]>([]);
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();
});

View File

@ -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<string>;
initialHiddenColumnKeys?: MaybeRefOrGetter<readonly string[] | undefined>;
hideNullColumns?: MaybeRefOrGetter<boolean>;
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<Set<number>>(new Set());
const persistedColumnOrderKeys = ref<string[]>([]);
const persistedHiddenColumnKeys = ref<string[]>([...(toValue(options.initialHiddenColumnKeys) ?? [])]);
const frozenColumnCount = ref(0);
// 冻结选中列前的列序快照,取消冻结时还原
const columnOrderSnapshotBeforeFreeze = ref<string[] | null>(null);
let columnLayoutPersistTimer: ReturnType<typeof setTimeout> | 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<DataGridColumnLayoutOption[]>(() =>
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,

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -1108,6 +1108,8 @@ export default withEnglishFallback({
localFilterMoreValues: " +{count}件以上",
columnVisibility: "列",
columnVisibilityHint: "少なくとも1列は表示されます。",
columnReorderHint: "ドラッグして列の順序を変更します。",
columnReorderSearchHint: "列の順序を変更するには検索をクリアしてください。",
searchColumns: "列を検索...",
dragColumnToReorder: "列ヘッダーをドラッグして並べ替え",
invertColumnVisibility: "反転",

View File

@ -1200,6 +1200,8 @@ export default withEnglishFallback({
localFilterMoreValues: " 외 {count}개",
columnVisibility: "컬럼",
columnVisibilityHint: "최소 한 개의 컬럼은 표시 상태를 유지합니다.",
columnReorderHint: "드래그하여 컬럼 순서를 변경합니다.",
columnReorderSearchHint: "컬럼 순서를 변경하려면 검색을 지우세요.",
searchColumns: "컬럼 검색...",
dragColumnToReorder: "컬럼 머리글을 드래그하여 순서 변경",
invertColumnVisibility: "반전",

View File

@ -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",

View File

@ -1217,6 +1217,8 @@ export default withEnglishFallback({
localFilterMoreValues: ",还有 {count} 个",
columnVisibility: "字段筛选",
columnVisibilityHint: "至少保留一列可见。",
columnReorderHint: "拖动可调整字段顺序。",
columnReorderSearchHint: "清除搜索后可调整字段顺序。",
searchColumns: "搜索字段...",
dragColumnToReorder: "拖拽列头调整顺序",
invertColumnVisibility: "反选",

View File

@ -1112,6 +1112,8 @@ export default withEnglishFallback({
localFilterMoreValues: ",還有 {count} 個",
columnVisibility: "欄位篩選",
columnVisibilityHint: "至少保留一欄可見。",
columnReorderHint: "拖曳可調整欄位順序。",
columnReorderSearchHint: "清除搜尋後可調整欄位順序。",
searchColumns: "搜尋欄位……",
dragColumnToReorder: "拖曳欄位標題調整順序",
invertColumnVisibility: "反選",

View File

@ -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<string, string>();
@ -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: [],
});
});
});

View File

@ -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<string, string>;
function installLocalStorage() {
storedValues = new Map<string, string>();
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);
});
});

View File

@ -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<StoredDataGridColumnLayout>;
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<StoredDataGridColumnLayout & LegacyStoredDataGridColumnLayout>;
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 {

View File

@ -46,7 +46,7 @@ export function buildDataGridColumnLookupItems(options: DataGridColumnLookupOpti
});
}
export function filterDataGridColumnLookupItems(items: readonly DataGridColumnLookupItem[], query: string): DataGridColumnLookupItem[] {
export function filterDataGridColumnLookupItems<ColumnLookupItem extends DataGridColumnLookupItem>(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)));

View File

@ -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<string>();
const missingAfterCurrent = new Map<string, Set<string>>();
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<string>();
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);
}

View File

@ -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);
}

View File

@ -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);
});