feat(grid): add drag-and-drop column reordering with persistence

* feat: support data grid column reordering

* fix: improve column visibility footer layout
This commit is contained in:
林剑凛 2026-06-20 21:50:59 +08:00 committed by GitHub
parent 72f90a064f
commit 9d329920b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 468 additions and 12 deletions

View File

@ -832,12 +832,15 @@ function resetTableSearchSplitWidth() {
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center gap-1">
<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>

View File

@ -123,6 +123,8 @@ import { appendColumnValueFilterCondition, buildColumnValueFilterCondition, comb
import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit";
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/paginationPageSize";
import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGridColumnVisibility";
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGridColumnOrder";
import { dataGridColumnLayoutScopeKey, loadDataGridColumnOrder, removeDataGridColumnOrder, saveDataGridColumnOrder } from "@/lib/dataGridColumnLayoutStorage";
import { parseClipboardTable } from "@/lib/gridSelection";
import { useToast } from "@/composables/useToast";
@ -1558,13 +1560,35 @@ const nullColumnsHidden = ref(false);
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const highlightedColumnIndex = ref<number | null>(null);
let highlightedColumnTimer = 0;
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 persistedColumnOrderKeys = ref<string[]>([]);
const displayableColumnIndexes = computed(() =>
props.result.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => !isHiddenGridColumn(props.databaseType, column, props.tableMeta?.primaryKeys ?? []))
.map(({ index }) => index),
);
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(displayableColumnIndexes.value, hiddenColumnIndexes.value));
const orderedDisplayableColumnIndexes = computed(() =>
orderedColumnIndexes({
availableIndexes: displayableColumnIndexes.value,
columnKeys: columnOrderKeys.value,
orderedKeys: persistedColumnOrderKeys.value,
}),
);
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const visibleColumns = computed(() => visibleColumnIndexes.value.map((index) => props.result.columns[index]));
const visibleSourceColumns = computed(() => {
if (!props.sourceColumns || props.sourceColumns.length !== props.result.columns.length) return undefined;
@ -1593,6 +1617,7 @@ const previewActions = computed(() => {
});
const displayableColumnCount = computed(() => displayableColumnIndexes.value.length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value);
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(displayableColumnIndexes.value, orderedDisplayableColumnIndexes.value));
const allNullColumnIndexesForResult = computed(() => allNullColumnIndexes(props.result.rows, displayableColumnIndexes.value));
const allNullColumnCount = computed(() => allNullColumnIndexesForResult.value.length);
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (allNullColumnCount.value > 0 && displayableColumnCount.value > 1));
@ -1616,6 +1641,24 @@ function showAllColumns() {
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes(displayableColumnIndexes.value, hiddenColumnIndexes.value);
}
function loadColumnOrder() {
persistedColumnOrderKeys.value = loadDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value);
}
function persistColumnOrder(indexes: number[]) {
if (isDefaultColumnOrder(displayableColumnIndexes.value, indexes)) {
removeDataGridColumnOrder(columnLayoutScopeKey.value);
persistedColumnOrderKeys.value = [];
return;
}
const keys = columnOrderKeysForIndexes(indexes, columnOrderKeys.value);
persistedColumnOrderKeys.value = keys;
saveDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value, keys);
}
function resetColumnOrder() {
removeDataGridColumnOrder(columnLayoutScopeKey.value);
persistedColumnOrderKeys.value = [];
nextTick(refreshGridScrollerMetrics);
}
function showAllNullColumns() {
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value);
autoHiddenNullColumnIndexes.value = new Set();
@ -1644,6 +1687,7 @@ watch(allNullColumnIndexesForResult, () => {
autoHiddenNullColumnIndexes.value = new Set();
hideAllNullColumns();
});
watch(() => columnLayoutScopeKey.value, loadColumnOrder, { immediate: true });
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
function actualColumnIndex(visibleColumnIndex: number): number {
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
@ -2002,6 +2046,144 @@ function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
}
type ColumnHeaderDragState = {
sourceVisibleIndex: number;
targetVisibleIndex: number;
startX: number;
startY: number;
currentX: number;
columnRects: { visibleIndex: number; left: number; width: number }[];
dragging: boolean;
};
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
let columnHeaderDragClickGuardUntil = 0;
function columnHeaderInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof HTMLElement && !!target.closest("button, input, textarea, select, [contenteditable='true'], [role='button'], [data-column-resize-handle]");
}
function columnHeaderDropTargetVisibleIndex(clientX: number): number {
const state = columnHeaderDragState.value;
if (!state || state.columnRects.length === 0) return state?.sourceVisibleIndex ?? 0;
for (const rect of state.columnRects) {
const visibleIndex = rect.visibleIndex;
if (!Number.isFinite(visibleIndex)) continue;
if (clientX < rect.left + rect.width / 2) return visibleIndex;
}
return visibleColumnIndexes.value.length;
}
function columnHeaderLayoutRects(): { visibleIndex: number; left: number; width: number }[] {
const header = headerRef.value;
return Array.from(header?.querySelectorAll<HTMLElement>("[data-visible-col-index]") ?? [])
.map((element) => {
const rect = element.getBoundingClientRect();
return {
visibleIndex: Number(element.dataset.visibleColIndex),
left: rect.left,
width: rect.width,
};
})
.filter((rect) => Number.isFinite(rect.visibleIndex));
}
function stopColumnHeaderDrag(commit: boolean) {
const state = columnHeaderDragState.value;
if (!state) return;
window.removeEventListener("pointermove", onColumnHeaderPointerMove, true);
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
document.body.style.userSelect = "";
columnHeaderDragState.value = null;
if (state.dragging) columnHeaderDragClickGuardUntil = Date.now() + 250;
if (!commit || !state.dragging || state.sourceVisibleIndex === state.targetVisibleIndex) return;
const next = moveVisibleColumnIndex({
orderedIndexes: orderedDisplayableColumnIndexes.value,
hiddenIndexes: hiddenColumnIndexes.value,
fromVisibleIndex: state.sourceVisibleIndex,
toVisibleIndex: state.targetVisibleIndex,
});
persistColumnOrder(next);
nextTick(refreshGridScrollerMetrics);
}
function onColumnHeaderPointerMove(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (!state) return;
const moved = Math.abs(event.clientX - state.startX) > 5 || Math.abs(event.clientY - state.startY) > 5;
if (!state.dragging && moved) {
state.dragging = true;
document.body.style.userSelect = "none";
}
if (!state.dragging) return;
event.preventDefault();
state.currentX = event.clientX;
state.targetVisibleIndex = columnHeaderDropTargetVisibleIndex(event.clientX);
}
function onColumnHeaderPointerUp() {
stopColumnHeaderDrag(true);
}
function onColumnHeaderPointerCancel() {
stopColumnHeaderDrag(false);
}
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || getIsResizing() || columnHeaderInteractiveTarget(event.target)) return;
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
columnRects: columnHeaderLayoutRects(),
dragging: false,
};
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
window.addEventListener("pointerup", onColumnHeaderPointerUp, true);
window.addEventListener("pointercancel", onColumnHeaderPointerCancel, true);
}
function onHeaderClick(visibleColIdx: number, event: MouseEvent) {
if (Date.now() < columnHeaderDragClickGuardUntil) {
event.preventDefault();
event.stopPropagation();
return;
}
selectColumn(visibleColIdx, event);
}
function columnHeaderDragClass(visibleColIdx: number) {
const state = columnHeaderDragState.value;
return {
"z-30 shadow-lg ring-1 ring-primary/40 bg-background dark:bg-muted pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx,
};
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state?.dragging) return 0;
const sourceIndex = state.sourceVisibleIndex;
if (visibleColIdx === sourceIndex) return state.currentX - state.startX;
const targetIndex = state.targetVisibleIndex;
const sourceWidth = renderedColumnWidths.value[sourceIndex] ?? 0;
if (targetIndex < sourceIndex && visibleColIdx >= targetIndex && visibleColIdx < sourceIndex) return sourceWidth;
if (targetIndex > sourceIndex && visibleColIdx > sourceIndex && visibleColIdx <= targetIndex) return -sourceWidth;
return 0;
}
function columnHeaderStyle(visibleColIdx: number) {
const style = renderedColumnStyle(visibleColIdx);
const offset = columnHeaderPreviewOffset(visibleColIdx);
if (!offset) return style;
return {
...style,
transform: `translateX(${offset}px)`,
transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out",
};
}
function columnContentOffsetLeft(visibleColIdx: number): number {
return DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
}
@ -4265,6 +4447,7 @@ onDeactivated(pauseCanvasGridWork);
onUnmounted(() => {
pauseCanvasGridWork();
gridHorizontalScrollbarResizeObserver?.disconnect();
stopColumnHeaderDrag(false);
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
if (gridHorizontalScrollbarFrame && typeof cancelAnimationFrame === "function") {
@ -6016,6 +6199,8 @@ defineExpose({
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
hasCustomColumnOrder,
resetColumnOrder,
nullColumnsHidden,
allNullColumnCount,
canToggleAllNullColumns,
@ -6808,10 +6993,13 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:class="{
'!bg-gray-300 dark:!bg-gray-900 outline outline-primary -outline-offset-1': highlightedColumnIndex === col.actualColIdx || columnIsSelected(col.visibleColIdx),
'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40': currentSearchMatch?.kind === 'column' && currentSearchMatch.col === col.actualColIdx,
...columnHeaderDragClass(col.visibleColIdx),
}"
:style="renderedColumnStyle(col.visibleColIdx)"
:style="columnHeaderStyle(col.visibleColIdx)"
:data-grid-column-index="col.actualColIdx"
@click="selectColumn(col.visibleColIdx, $event)"
:data-visible-col-index="col.visibleColIdx"
@pointerdown="startColumnHeaderDrag(col.visibleColIdx, $event)"
@click="onHeaderClick(col.visibleColIdx, $event)"
@contextmenu="onHeaderContext(col.name, col.actualColIdx)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
@ -7097,7 +7285,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</PopoverContent>
</Popover>
</span>
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onResizeStart(col.visibleColIdx, $event)" @dblclick.stop="autoFitColumn(col.visibleColIdx)" />
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onResizeStart(col.visibleColIdx, $event)" @dblclick.stop="autoFitColumn(col.visibleColIdx)" />
</div>
<template #content>
<div class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 px-3 py-2">

View File

@ -72,6 +72,8 @@ type DataGridHandle = {
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
hasCustomColumnOrder: boolean;
resetColumnOrder: () => void;
nullColumnsHidden: boolean;
allNullColumnCount: number;
canToggleAllNullColumns: boolean;
@ -844,12 +846,15 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center gap-1">
<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>

View File

@ -1,4 +1,4 @@
import { ref, computed, type ComputedRef, type Ref } from "vue";
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
import { useElementSize } from "@vueuse/core";
import { calculateDataGridColumnWidth, DATA_GRID_COL_MIN_WIDTH, DATA_GRID_SAMPLE_ROWS } from "@/lib/dataGridColumnWidth";
@ -20,6 +20,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
const columnWidths = ref<number[]>([]);
const { width: gridWidth } = useElementSize(gridRef);
let isResizing = false;
let previousColumnIndexes: number[] = [];
function sampleColumnValues(visibleColIdx: number): CellValue[] {
const actualColIdx = columnIndexes.value[visibleColIdx];
@ -33,14 +34,23 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
}
function initColumnWidths() {
if (columnWidths.value.length !== columns.value.length) {
const previousWidthsByColumnIndex = new Map<number, number>();
previousColumnIndexes.forEach((columnIndex, visibleIndex) => {
const width = columnWidths.value[visibleIndex];
if (width !== undefined) previousWidthsByColumnIndex.set(columnIndex, width);
});
const nextColumnIndexes = [...columnIndexes.value];
if (columnWidths.value.length !== columns.value.length || previousColumnIndexes.join("\0") !== nextColumnIndexes.join("\0")) {
columnWidths.value = columns.value.map((colName, colIdx) => {
const existingWidth = previousWidthsByColumnIndex.get(nextColumnIndexes[colIdx]);
if (existingWidth !== undefined) return existingWidth;
return calculateDataGridColumnWidth({
columnName: colName,
sampleValues: sampleColumnValues(colIdx),
});
});
}
previousColumnIndexes = nextColumnIndexes;
}
function onResizeStart(colIdx: number, event: MouseEvent) {
@ -101,6 +111,8 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
return isResizing;
}
watch(() => columnIndexes.value.join("\0"), initColumnWidths);
return {
columnWidths,
initColumnWidths,

View File

@ -615,7 +615,9 @@ export default {
columnVisibility: "Columns",
columnVisibilityHint: "At least one column stays visible.",
searchColumns: "Search columns...",
dragColumnToReorder: "Drag column header to reorder",
invertColumnVisibility: "Invert",
resetColumnOrder: "Reset order",
showAllColumns: "Show all",
viewOptions: "View options",
hideNullColumns: "Hide NULL",

View File

@ -532,7 +532,9 @@ export default {
columnVisibility: "Columnas",
columnVisibilityHint: "Al menos una columna permanece visible.",
searchColumns: "Buscar columnas...",
dragColumnToReorder: "Arrastra el encabezado para reordenar",
invertColumnVisibility: "Invertir",
resetColumnOrder: "Restablecer orden",
showAllColumns: "Mostrar todo",
viewOptions: "Opciones de vista",
hideNullColumns: "Ocultar NULL",

View File

@ -561,7 +561,9 @@ export default {
columnVisibility: "Colonne",
columnVisibilityHint: "Almeno una colonna deve rimanere visibile.",
searchColumns: "Cerca colonne...",
dragColumnToReorder: "Trascina l'intestazione per riordinare",
invertColumnVisibility: "Inverti",
resetColumnOrder: "Reimposta ordine",
showAllColumns: "Mostra tutte",
viewOptions: "Opzioni visualizzazione",
hideNullColumns: "Nascondi NULL",

View File

@ -611,7 +611,9 @@ export default {
columnVisibility: "列",
columnVisibilityHint: "少なくとも1列は表示されます。",
searchColumns: "列を検索...",
dragColumnToReorder: "列ヘッダーをドラッグして並べ替え",
invertColumnVisibility: "反転",
resetColumnOrder: "順序をリセット",
showAllColumns: "すべて表示",
viewOptions: "表示オプション",
hideNullColumns: "NULL列を非表示",

View File

@ -561,7 +561,9 @@ export default {
columnVisibility: "Colunas",
columnVisibilityHint: "Pelo menos uma coluna permanece visível.",
searchColumns: "Pesquisar colunas...",
dragColumnToReorder: "Arraste o cabeçalho para reordenar",
invertColumnVisibility: "Inverter",
resetColumnOrder: "Redefinir ordem",
showAllColumns: "Mostrar todas",
viewOptions: "Opções de visualização",
hideNullColumns: "Ocultar NULL",

View File

@ -616,7 +616,9 @@ export default {
columnVisibility: "字段筛选",
columnVisibilityHint: "至少保留一列可见。",
searchColumns: "搜索字段...",
dragColumnToReorder: "拖拽列头调整顺序",
invertColumnVisibility: "反选",
resetColumnOrder: "重置顺序",
showAllColumns: "显示全部",
viewOptions: "视图选项",
hideNullColumns: "隐藏 NULL 列",

View File

@ -562,7 +562,9 @@ export default {
columnVisibility: "欄位篩選",
columnVisibilityHint: "至少保留一欄可見。",
searchColumns: "搜尋欄位……",
dragColumnToReorder: "拖曳欄位標題調整順序",
invertColumnVisibility: "反選",
resetColumnOrder: "重設順序",
showAllColumns: "顯示全部",
viewOptions: "檢視選項",
hideNullColumns: "隱藏 NULL 欄",

View File

@ -0,0 +1,68 @@
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/safeStorage";
const STORAGE_PREFIX = "dbx-data-grid-column-layout:";
const STORAGE_VERSION = 1;
export interface DataGridColumnLayoutScope {
connectionId?: string;
database?: string;
schema?: string;
context?: string;
tableSchema?: string;
tableName?: string;
sql?: string;
columns: readonly string[];
sourceColumns?: readonly (string | undefined)[];
}
interface StoredDataGridColumnLayout {
version: number;
columnSignature: string;
order: string[];
}
function normalizeSql(sql?: string): string {
return (sql ?? "").replace(/\s+/g, " ").trim();
}
export function dataGridColumnLayoutScopeKey(scope: DataGridColumnLayoutScope): string {
const columnSignature = scope.columns.join("\0");
const sourceSignature = (scope.sourceColumns ?? []).map((column) => column ?? "").join("\0");
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[] {
const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
if (!raw) return [];
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 [];
}
}
export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
const payload: StoredDataGridColumnLayout = {
version: STORAGE_VERSION,
columnSignature: columnKeys.join("\0"),
order: [...order],
};
safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload));
}
export function removeDataGridColumnOrder(scopeKey: string) {
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
}

View File

@ -0,0 +1,67 @@
export function uniqueDataGridColumnOrderKeys(columns: readonly string[], sourceColumns?: readonly (string | undefined)[]): string[] {
const counts = new Map<string, number>();
return columns.map((column, index) => {
const base = sourceColumns?.[index] || column || `#${index + 1}`;
const count = counts.get(base) ?? 0;
counts.set(base, count + 1);
return `${base}\u0000${count}`;
});
}
export function orderedColumnIndexes(options: { availableIndexes: readonly number[]; columnKeys: readonly string[]; orderedKeys: readonly string[] }): number[] {
const available = new Set(options.availableIndexes);
const indexByKey = new Map<string, number>();
for (const index of options.availableIndexes) {
const key = options.columnKeys[index];
if (key) indexByKey.set(key, index);
}
const used = new Set<number>();
const ordered: number[] = [];
for (const key of options.orderedKeys) {
const index = indexByKey.get(key);
if (index === undefined || !available.has(index) || used.has(index)) continue;
ordered.push(index);
used.add(index);
}
for (const index of options.availableIndexes) {
if (!used.has(index)) ordered.push(index);
}
return ordered;
}
export function moveVisibleColumnIndex(options: { orderedIndexes: readonly number[]; hiddenIndexes: ReadonlySet<number>; fromVisibleIndex: number; toVisibleIndex: number }): number[] {
const visibleIndexes = options.orderedIndexes.filter((index) => !options.hiddenIndexes.has(index));
const fromActualIndex = visibleIndexes[options.fromVisibleIndex];
if (fromActualIndex === undefined) return [...options.orderedIndexes];
const withoutSource = options.orderedIndexes.filter((index) => index !== fromActualIndex);
const visibleWithoutSource = visibleIndexes.filter((index) => index !== fromActualIndex);
const targetVisibleIndex = Math.max(0, Math.min(options.toVisibleIndex, visibleWithoutSource.length));
if (options.fromVisibleIndex === targetVisibleIndex) return [...options.orderedIndexes];
const next = [...withoutSource];
if (targetVisibleIndex >= visibleWithoutSource.length) {
const lastVisibleIndex = visibleWithoutSource[visibleWithoutSource.length - 1];
const insertAfterIndex = lastVisibleIndex === undefined ? next.length - 1 : next.indexOf(lastVisibleIndex);
next.splice(insertAfterIndex + 1, 0, fromActualIndex);
return next;
}
const targetActualIndex = visibleWithoutSource[targetVisibleIndex];
const insertIndex = targetActualIndex === undefined ? next.length : next.indexOf(targetActualIndex);
if (insertIndex < 0) return [...options.orderedIndexes];
next.splice(insertIndex, 0, fromActualIndex);
return next;
}
export function columnOrderKeysForIndexes(indexes: readonly number[], columnKeys: readonly string[]): string[] {
return indexes.map((index) => columnKeys[index]).filter((key): key is string => !!key);
}
export function isDefaultColumnOrder(availableIndexes: readonly number[], orderedIndexes: readonly number[]): boolean {
if (availableIndexes.length !== orderedIndexes.length) return false;
return availableIndexes.every((index, position) => orderedIndexes[position] === index);
}

View File

@ -0,0 +1,99 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "../../apps/desktop/src/lib/dataGridColumnOrder";
test("creates stable keys for duplicate column names", () => {
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`]);
});
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],
);
});
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],
);
});
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],
);
});
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],
);
});
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],
);
});
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],
);
});
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);
});
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]]);
});
test("detects default order", () => {
assert.equal(isDefaultColumnOrder([0, 1, 2], [0, 1, 2]), true);
assert.equal(isDefaultColumnOrder([0, 1, 2], [1, 0, 2]), false);
});