fix(grid): preserve result column widths
This commit is contained in:
parent
0f02e216aa
commit
97e3bf912e
|
|
@ -174,6 +174,7 @@ import { useDataGridExport } from "@/composables/useDataGridExport";
|
|||
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
|
||||
import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars";
|
||||
|
|
@ -1774,6 +1775,9 @@ function scrollToColumnIndex(columnIndex: number) {
|
|||
|
||||
// --- Column resize composable ---
|
||||
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
|
||||
const columnWidthCacheKey = computed(() => props.cacheKey?.trim() || undefined);
|
||||
const columnStructureSignature = computed(() => createDataGridColumnStructureSignature(props.result.columns, props.result.column_types));
|
||||
const columnHeaderMeasurementKey = computed(() => [tableFontSize.value, settingsStore.editorSettings.fontFamily]);
|
||||
let columnHeaderMeasureContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
function measureColumnHeaderText(text: string): number | undefined {
|
||||
|
|
@ -1792,8 +1796,10 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to
|
|||
columnIndexes: visibleColumnIndexes,
|
||||
density: columnWidthDensity,
|
||||
compactColumnHeaderActions,
|
||||
cacheKey: columnWidthCacheKey,
|
||||
columnStructureSignature,
|
||||
measureHeaderText: measureColumnHeaderText,
|
||||
headerMeasurementKey: tableFontSize,
|
||||
headerMeasurementKey: columnHeaderMeasurementKey,
|
||||
});
|
||||
const gridStyle = computed(() => ({
|
||||
...columnVars.value,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
|
||||
import { clearDataGridColumnWidthStates, createDataGridColumnMeasurementSignature, createDataGridColumnStructureSignature, DATA_GRID_COLUMN_WIDTH_STATE_LIMIT, dataGridColumnWidthStateCount, loadDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
|
||||
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) {
|
||||
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; columnTypes?: string[]; cacheKey?: string; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) {
|
||||
const compact = ref(options.compactColumnHeaderActions ?? true);
|
||||
const headerTextWidth = ref(options.headerTextWidth);
|
||||
const headerMeasurementKey = ref(0);
|
||||
const density = ref(options.density ?? "standard");
|
||||
const state = useDataGridColumnResize({
|
||||
columns: computed(() => options.columns),
|
||||
sourceRows: computed(() => options.rows),
|
||||
columnIndexes: computed(() => options.columnIndexes ?? options.columns.map((_, index) => index)),
|
||||
density: ref(options.density ?? "standard"),
|
||||
density,
|
||||
compactColumnHeaderActions: computed(() => compact.value),
|
||||
cacheKey: computed(() => options.cacheKey),
|
||||
columnStructureSignature: computed(() => createDataGridColumnStructureSignature(options.columns, options.columnTypes)),
|
||||
measureHeaderText: () => headerTextWidth.value,
|
||||
headerMeasurementKey,
|
||||
});
|
||||
|
|
@ -21,6 +27,9 @@ function createResizeState(options: { columns: string[]; rows: Array<Array<strin
|
|||
setCompact(v: boolean) {
|
||||
compact.value = v;
|
||||
},
|
||||
setDensity(value: "compact" | "standard" | "comfortable") {
|
||||
density.value = value;
|
||||
},
|
||||
setHeaderTextWidth(width: number) {
|
||||
headerTextWidth.value = width;
|
||||
headerMeasurementKey.value += 1;
|
||||
|
|
@ -29,6 +38,10 @@ function createResizeState(options: { columns: string[]; rows: Array<Array<strin
|
|||
}
|
||||
|
||||
describe("useDataGridColumnResize", () => {
|
||||
beforeEach(() => {
|
||||
clearDataGridColumnWidthStates();
|
||||
});
|
||||
|
||||
it("keeps compact query result columns at content width instead of filling the viewport", () => {
|
||||
const state = createResizeState({
|
||||
columns: ["id", "user_id"],
|
||||
|
|
@ -97,6 +110,84 @@ describe("useDataGridColumnResize", () => {
|
|||
expect(state.renderedColumnWidths.value[1]).toBe(before[1] + 40);
|
||||
});
|
||||
|
||||
it("restores manually resized widths after a keyed result remount", () => {
|
||||
const first = createResizeState({
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Alice"]],
|
||||
cacheKey: "result-a",
|
||||
});
|
||||
first.initColumnWidths();
|
||||
const originalWidth = first.columnWidths.value[1];
|
||||
|
||||
first.onResizeStart(1, new MouseEvent("mousedown", { clientX: 100, cancelable: true }));
|
||||
document.dispatchEvent(new MouseEvent("mouseup", { clientX: 160 }));
|
||||
expect(first.columnWidths.value[1]).toBe(originalWidth + 60);
|
||||
|
||||
const remounted = createResizeState({
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Alice"]],
|
||||
cacheKey: "result-a",
|
||||
});
|
||||
remounted.initColumnWidths();
|
||||
|
||||
expect(remounted.columnWidths.value).toEqual(first.columnWidths.value);
|
||||
});
|
||||
|
||||
it("isolates widths by result cache key", () => {
|
||||
const first = createResizeState({ columns: ["id"], rows: [["x".repeat(120)]], cacheKey: "result-a" });
|
||||
first.initColumnWidths();
|
||||
first.autoFitColumn(0);
|
||||
|
||||
const other = createResizeState({ columns: ["id"], rows: [[1]], cacheKey: "result-b" });
|
||||
other.initColumnWidths();
|
||||
|
||||
expect(other.columnWidths.value[0]).not.toBe(first.columnWidths.value[0]);
|
||||
});
|
||||
|
||||
it("rejects cached widths when the result column structure changes", () => {
|
||||
const first = createResizeState({ columns: ["id"], columnTypes: ["INT"], rows: [["x".repeat(120)]], cacheKey: "result-a" });
|
||||
first.initColumnWidths();
|
||||
first.autoFitColumn(0);
|
||||
|
||||
const changed = createResizeState({ columns: ["id"], columnTypes: ["VARCHAR"], rows: [[1]], cacheKey: "result-a" });
|
||||
changed.initColumnWidths();
|
||||
|
||||
expect(changed.columnWidths.value[0]).not.toBe(first.columnWidths.value[0]);
|
||||
});
|
||||
|
||||
it("invalidates cached widths when density or font metrics change", async () => {
|
||||
const state = createResizeState({ columns: ["description"], rows: [["x".repeat(120)]], cacheKey: "result-a" });
|
||||
state.initColumnWidths();
|
||||
state.autoFitColumn(0);
|
||||
const fittedWidth = state.columnWidths.value[0];
|
||||
|
||||
state.setDensity("compact");
|
||||
await nextTick();
|
||||
expect(state.columnWidths.value[0]).not.toBe(fittedWidth);
|
||||
|
||||
state.autoFitColumn(0);
|
||||
state.setHeaderTextWidth(200);
|
||||
await nextTick();
|
||||
const remounted = createResizeState({ columns: ["description"], rows: [[1]], cacheKey: "result-a", density: "compact", headerTextWidth: 200 });
|
||||
remounted.initColumnWidths();
|
||||
expect(remounted.columnWidths.value[0]).not.toBe(fittedWidth);
|
||||
});
|
||||
|
||||
it("evicts the least recently used width states at the cache limit", () => {
|
||||
const structureSignature = createDataGridColumnStructureSignature(["id"]);
|
||||
const measurementSignature = createDataGridColumnMeasurementSignature("standard", true, 14);
|
||||
for (let index = 0; index < DATA_GRID_COLUMN_WIDTH_STATE_LIMIT; index++) {
|
||||
saveDataGridColumnWidthState({ cacheKey: `result-${index}`, structureSignature, measurementSignature }, [0], [100 + index]);
|
||||
}
|
||||
expect(loadDataGridColumnWidthState({ cacheKey: "result-0", structureSignature, measurementSignature }, [0])).toEqual([100]);
|
||||
saveDataGridColumnWidthState({ cacheKey: `result-${DATA_GRID_COLUMN_WIDTH_STATE_LIMIT}`, structureSignature, measurementSignature }, [0], [100 + DATA_GRID_COLUMN_WIDTH_STATE_LIMIT]);
|
||||
|
||||
expect(dataGridColumnWidthStateCount()).toBe(DATA_GRID_COLUMN_WIDTH_STATE_LIMIT);
|
||||
expect(loadDataGridColumnWidthState({ cacheKey: "result-1", structureSignature, measurementSignature }, [0])).toBeUndefined();
|
||||
expect(loadDataGridColumnWidthState({ cacheKey: "result-0", structureSignature, measurementSignature }, [0])).toEqual([100]);
|
||||
expect(loadDataGridColumnWidthState({ cacheKey: `result-${DATA_GRID_COLUMN_WIDTH_STATE_LIMIT}`, structureSignature, measurementSignature }, [0])).toEqual([100 + DATA_GRID_COLUMN_WIDTH_STATE_LIMIT]);
|
||||
});
|
||||
|
||||
it("recalculates column widths when compactColumnHeaderActions changes", async () => {
|
||||
const state = createResizeState({
|
||||
columns: ["some_column_name_here"],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
||||
import { calculateDataGridColumnWidth, DATA_GRID_AUTO_FIT_VALUE_TEXT_LIMIT, DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH, COLUMN_WIDTH_DENSITY_PRESETS } from "@/lib/dataGrid/dataGridColumnWidth";
|
||||
import { createDataGridColumnMeasurementSignature, loadDataGridColumnWidthState, removeDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
import type { ColumnWidthDensity } from "@/stores/settingsStore";
|
||||
|
||||
type CellValue = string | number | boolean | null;
|
||||
|
|
@ -16,6 +17,8 @@ export interface UseDataGridColumnResizeOptions {
|
|||
columnIndexes: ComputedRef<number[]>;
|
||||
density: Ref<ColumnWidthDensity>;
|
||||
compactColumnHeaderActions: ComputedRef<boolean>;
|
||||
cacheKey?: ComputedRef<string | undefined>;
|
||||
columnStructureSignature: ComputedRef<string>;
|
||||
measureHeaderText?: (text: string) => number | undefined;
|
||||
headerMeasurementKey?: Ref<unknown>;
|
||||
}
|
||||
|
|
@ -27,6 +30,18 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||
let isResizing = false;
|
||||
let previousColumnIndexes: number[] = [];
|
||||
|
||||
function columnWidthStateIdentity() {
|
||||
return {
|
||||
cacheKey: options.cacheKey?.value,
|
||||
structureSignature: options.columnStructureSignature.value,
|
||||
measurementSignature: createDataGridColumnMeasurementSignature(density.value, compactColumnHeaderActions.value, options.headerMeasurementKey?.value),
|
||||
};
|
||||
}
|
||||
|
||||
function persistColumnWidths() {
|
||||
saveDataGridColumnWidthState(columnWidthStateIdentity(), previousColumnIndexes, columnWidths.value);
|
||||
}
|
||||
|
||||
function sampleColumnValues(visibleColIdx: number): CellValue[] {
|
||||
const actualColIdx = columnIndexes.value[visibleColIdx];
|
||||
const rows = sourceRows.value;
|
||||
|
|
@ -46,11 +61,14 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||
if (width !== undefined) previousWidthsByColumnIndex.set(columnIndex, width);
|
||||
});
|
||||
const nextColumnIndexes = [...columnIndexes.value];
|
||||
const cachedWidths = !force && previousColumnIndexes.length === 0 ? loadDataGridColumnWidthState(columnWidthStateIdentity(), nextColumnIndexes) : undefined;
|
||||
if (force || columnWidths.value.length !== columns.value.length || previousColumnIndexes.join("\0") !== nextColumnIndexes.join("\0")) {
|
||||
columnWidths.value = columns.value.map((colName, colIdx) => {
|
||||
if (!force) {
|
||||
const existingWidth = previousWidthsByColumnIndex.get(nextColumnIndexes[colIdx]);
|
||||
if (existingWidth !== undefined) return existingWidth;
|
||||
const cachedWidth = cachedWidths?.[colIdx];
|
||||
if (cachedWidth !== undefined) return cachedWidth;
|
||||
}
|
||||
return calculateDataGridColumnWidth({
|
||||
columnName: colName,
|
||||
|
|
@ -98,6 +116,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||
cancelPendingFrame();
|
||||
pendingClientX = e.clientX;
|
||||
applyPendingWidth();
|
||||
persistColumnWidths();
|
||||
requestAnimationFrame(() => {
|
||||
isResizing = false;
|
||||
});
|
||||
|
|
@ -119,6 +138,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||
includeValues: true,
|
||||
headerTextWidth: measureHeaderText?.(colName),
|
||||
});
|
||||
persistColumnWidths();
|
||||
}
|
||||
|
||||
const renderedColumnWidths = computed(() => columnWidths.value.slice());
|
||||
|
|
@ -143,7 +163,16 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||
() => columnIndexes.value.join("\0"),
|
||||
() => initColumnWidths(),
|
||||
);
|
||||
watch([density, compactColumnHeaderActions, () => options.headerMeasurementKey?.value], () => initColumnWidths(true));
|
||||
watch([() => options.cacheKey?.value, options.columnStructureSignature], () => {
|
||||
columnWidths.value = [];
|
||||
previousColumnIndexes = [];
|
||||
initColumnWidths();
|
||||
});
|
||||
watch([density, compactColumnHeaderActions, () => options.headerMeasurementKey?.value], () => {
|
||||
// Widths measured with different density or font metrics are unsafe to reuse.
|
||||
removeDataGridColumnWidthState(options.cacheKey?.value);
|
||||
initColumnWidths(true);
|
||||
});
|
||||
|
||||
return {
|
||||
columnWidths,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { ColumnWidthDensity } from "@/stores/settingsStore";
|
||||
|
||||
export const DATA_GRID_COLUMN_WIDTH_STATE_LIMIT = 128;
|
||||
|
||||
interface DataGridColumnWidthState {
|
||||
structureSignature: string;
|
||||
measurementSignature: string;
|
||||
widthsByColumnIndex: Map<number, number>;
|
||||
}
|
||||
|
||||
interface DataGridColumnWidthStateIdentity {
|
||||
cacheKey?: string;
|
||||
structureSignature: string;
|
||||
measurementSignature: string;
|
||||
}
|
||||
|
||||
const columnWidthStates = new Map<string, DataGridColumnWidthState>();
|
||||
|
||||
function touchColumnWidthState(cacheKey: string, state: DataGridColumnWidthState) {
|
||||
columnWidthStates.delete(cacheKey);
|
||||
columnWidthStates.set(cacheKey, state);
|
||||
}
|
||||
|
||||
export function createDataGridColumnStructureSignature(columns: readonly string[], columnTypes?: readonly (string | undefined)[]): string {
|
||||
return JSON.stringify(columns.map((column, index) => [column, columnTypes?.[index] ?? ""]));
|
||||
}
|
||||
|
||||
export function createDataGridColumnMeasurementSignature(density: ColumnWidthDensity, compactColumnHeaderActions: boolean, headerMeasurementKey: unknown): string {
|
||||
return JSON.stringify([density, compactColumnHeaderActions, headerMeasurementKey ?? null]);
|
||||
}
|
||||
|
||||
export function loadDataGridColumnWidthState(identity: DataGridColumnWidthStateIdentity, columnIndexes: readonly number[]): Array<number | undefined> | undefined {
|
||||
const cacheKey = identity.cacheKey?.trim();
|
||||
if (!cacheKey) return undefined;
|
||||
const state = columnWidthStates.get(cacheKey);
|
||||
if (!state) return undefined;
|
||||
if (state.structureSignature !== identity.structureSignature || state.measurementSignature !== identity.measurementSignature) {
|
||||
columnWidthStates.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
touchColumnWidthState(cacheKey, state);
|
||||
return columnIndexes.map((columnIndex) => state.widthsByColumnIndex.get(columnIndex));
|
||||
}
|
||||
|
||||
export function saveDataGridColumnWidthState(identity: DataGridColumnWidthStateIdentity, columnIndexes: readonly number[], widths: readonly number[]) {
|
||||
const cacheKey = identity.cacheKey?.trim();
|
||||
if (!cacheKey || columnIndexes.length !== widths.length) return;
|
||||
const existing = columnWidthStates.get(cacheKey);
|
||||
const widthsByColumnIndex = existing?.structureSignature === identity.structureSignature && existing.measurementSignature === identity.measurementSignature ? new Map(existing.widthsByColumnIndex) : new Map<number, number>();
|
||||
columnIndexes.forEach((columnIndex, visibleIndex) => {
|
||||
const width = widths[visibleIndex];
|
||||
if (Number.isFinite(width)) widthsByColumnIndex.set(columnIndex, width);
|
||||
});
|
||||
touchColumnWidthState(cacheKey, {
|
||||
structureSignature: identity.structureSignature,
|
||||
measurementSignature: identity.measurementSignature,
|
||||
widthsByColumnIndex,
|
||||
});
|
||||
// Query result keys are session-scoped, so bound the in-memory cache instead of relying on store cleanup paths.
|
||||
while (columnWidthStates.size > DATA_GRID_COLUMN_WIDTH_STATE_LIMIT) {
|
||||
const oldestCacheKey = columnWidthStates.keys().next().value;
|
||||
if (oldestCacheKey === undefined) break;
|
||||
columnWidthStates.delete(oldestCacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDataGridColumnWidthState(cacheKey?: string) {
|
||||
const normalizedCacheKey = cacheKey?.trim();
|
||||
if (normalizedCacheKey) columnWidthStates.delete(normalizedCacheKey);
|
||||
}
|
||||
|
||||
export function clearDataGridColumnWidthStates() {
|
||||
columnWidthStates.clear();
|
||||
}
|
||||
|
||||
export function dataGridColumnWidthStateCount(): number {
|
||||
return columnWidthStates.size;
|
||||
}
|
||||
Loading…
Reference in New Issue