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:
14 files changed
+468
-12
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user