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:
parent
72f90a064f
commit
9d329920b9
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -611,7 +611,9 @@ export default {
|
|||
columnVisibility: "列",
|
||||
columnVisibilityHint: "少なくとも1列は表示されます。",
|
||||
searchColumns: "列を検索...",
|
||||
dragColumnToReorder: "列ヘッダーをドラッグして並べ替え",
|
||||
invertColumnVisibility: "反転",
|
||||
resetColumnOrder: "順序をリセット",
|
||||
showAllColumns: "すべて表示",
|
||||
viewOptions: "表示オプション",
|
||||
hideNullColumns: "NULL列を非表示",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -616,7 +616,9 @@ export default {
|
|||
columnVisibility: "字段筛选",
|
||||
columnVisibilityHint: "至少保留一列可见。",
|
||||
searchColumns: "搜索字段...",
|
||||
dragColumnToReorder: "拖拽列头调整顺序",
|
||||
invertColumnVisibility: "反选",
|
||||
resetColumnOrder: "重置顺序",
|
||||
showAllColumns: "显示全部",
|
||||
viewOptions: "视图选项",
|
||||
hideNullColumns: "隐藏 NULL 列",
|
||||
|
|
|
|||
|
|
@ -562,7 +562,9 @@ export default {
|
|||
columnVisibility: "欄位篩選",
|
||||
columnVisibilityHint: "至少保留一欄可見。",
|
||||
searchColumns: "搜尋欄位……",
|
||||
dragColumnToReorder: "拖曳欄位標題調整順序",
|
||||
invertColumnVisibility: "反選",
|
||||
resetColumnOrder: "重設順序",
|
||||
showAllColumns: "顯示全部",
|
||||
viewOptions: "檢視選項",
|
||||
hideNullColumns: "隱藏 NULL 欄",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue