fix(grid): support sorting in transposed view

Closes #10136
This commit is contained in:
t8y2
2026-09-27 18:13:29 +00:00
parent 72f8330539
commit 527b4fb09d
2 changed files with 276 additions and 1 deletions
+67 -1
View File
@@ -645,6 +645,10 @@ let preservedSelectionOnNextResult: {
selection: PersistedDataGridSelection;
sourceResult: QueryResult;
} | null = null;
let preservedTransposeRecordOnNextResult: {
selection: PersistedDataGridSelection;
sourceResult: QueryResult;
} | null = null;
let preservedDetailsOnNextResult: {
sideCell?: PersistedDataGridSelection;
sideCellHasPendingDraft?: boolean;
@@ -5334,6 +5338,20 @@ function restoreSelectionAfterRefresh(snapshot: PersistedDataGridSelection) {
});
}
function restoreTransposeRecordAfterRefresh(snapshot: PersistedDataGridSelection) {
const restored = restoreDataGridSelection({
snapshot,
columns: props.result.columns,
sourceColumns: props.sourceColumns,
rows: props.result.rows,
visibleColumnIndexes: visibleColumnIndexes.value,
displayItems: displayItems.value,
});
if (restored?.kind !== "rows") return;
transposeRowIndex.value = restored.scrollRowIndex;
nextTick(() => scrollTransposeRecordIntoView(restored.scrollRowIndex));
}
/** Bounded settling envelope for a replayed tab-switch viewport. */
const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;
let viewSnapshotRestoreFrame = 0;
@@ -6297,6 +6315,14 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
toast(t("grid.largeValueLocalSortUnavailable"), 5000);
return;
}
if (showTranspose.value) {
const selection = captureCurrentSelectionForRefresh();
preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;
const activeRecord = transposeRowIndex.value === null ? undefined : displayItemAt(transposeRowIndex.value);
const activeRecordSelection = captureRowTargetForRefresh(activeRecord?.id ?? null);
preservedTransposeRecordOnNextResult = activeRecordSelection ? { selection: activeRecordSelection, sourceResult: props.result } : null;
preserveTransposeOnNextResult.value = true;
}
if (mode === "database" && (infiniteScrollEnabled.value || loadAllRowsActive.value)) {
resetInfiniteScrollState();
} else {
@@ -10150,6 +10176,8 @@ watch(
// check has to run before the markers are consumed below.
const inPlaceRefreshPending = preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value;
preservedSelectionOnNextResult = null;
const transposeRecordSnapshot = preservedTransposeRecordOnNextResult?.selection;
preservedTransposeRecordOnNextResult = null;
const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor;
preservedViewportAnchorOnNextResult = null;
const detailsSnapshot = preservedDetailsOnNextResult;
@@ -10211,6 +10239,7 @@ watch(
}
exitTransaction();
if (selectionSnapshot) restoreSelectionAfterRefresh(selectionSnapshot);
if (transposeRecordSnapshot) restoreTransposeRecordAfterRefresh(transposeRecordSnapshot);
if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);
if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);
},
@@ -12537,6 +12566,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div
v-for="recordIndex in activeTransposeRecordIndexes"
:key="`transpose-head-${recordIndex}`"
data-grid-transpose-record-header
:data-grid-transpose-record-index="recordIndex"
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
:class="{
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
@@ -12567,6 +12598,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
<div
data-native-clipboard
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
:class="{
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
@@ -12575,11 +12607,43 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
}"
:style="{ width: `${transposePinnedWidth}px` }"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
</span>
<LightDropdownMenu
v-if="headerColumnSortable(visibleColumnIndexes[index])"
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
check-position="none"
align="end"
content-class="w-max min-w-28 p-0.5"
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
item-icon-class="h-3 w-3"
:match-trigger-width="false"
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
>
<template #trigger="{ open, toggle }">
<button
data-grid-transpose-sort
type="button"
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
:title="t('grid.sort')"
:aria-label="`${t('grid.sort')}: ${item.column}`"
:aria-expanded="open"
@mousedown.stop
@click.stop="toggle"
>
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
</button>
</template>
</LightDropdownMenu>
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
{{ item.type }}
@@ -12613,6 +12677,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div
v-for="cell in item.values"
:key="`${item.id}:${cell.recordIndex}`"
data-grid-transpose-cell
:data-grid-transpose-record-index="cell.recordIndex"
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
:class="[
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
@@ -0,0 +1,209 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
import { sortDataGridRowIndexes, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue");
return {
RecycleScroller: defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () => h("div", attrs, [slots.before?.(), props.items.map((item, index) => slots.default?.({ item, index }))]);
},
}),
};
});
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120, 120]),
totalWidth: ref(240),
columnVars: ref({ "--total-w": "240px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function menuAction(label: string): HTMLButtonElement {
const button = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find((candidate) => candidate.textContent?.trim() === label);
if (!button) throw new Error(`Sort menu action not found: ${label}`);
return button;
}
function transposeField(host: HTMLElement, columnIndex: number): HTMLElement {
const field = host.querySelector<HTMLElement>(`[data-grid-transpose-column-index="${columnIndex}"]`);
if (!field) throw new Error(`Transpose field not found: ${columnIndex}`);
return field.closest<HTMLElement>(".data-grid-transpose-row")!;
}
function transposeValues(host: HTMLElement, columnIndex: number): string[] {
return [...transposeField(host, columnIndex).querySelectorAll<HTMLElement>("[data-grid-transpose-cell]")].map((cell) => cell.textContent?.trim() ?? "");
}
function transposeRecordHeader(host: HTMLElement, recordIndex: number): HTMLElement {
const header = host.querySelector<HTMLElement>(`[data-grid-transpose-record-header][data-grid-transpose-record-index="${recordIndex}"]`);
if (!header) throw new Error(`Transpose record header not found: ${recordIndex}`);
return header;
}
function mountGrid() {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom", dataGridMultiRowTranspose: true });
const originalRows: QueryResult["rows"] = [
["b", "10"],
["a", null],
["c", "2"],
];
const result = ref<QueryResult>(
markRaw({
columns: ["name", "score"],
column_types: ["varchar", "bigint"],
rows: originalRows,
affected_rows: 0,
execution_time_ms: 0,
}),
);
const sortColumn = ref<string>();
const sortColumnIndex = ref<number>();
const sortDirection = ref<DataGridSortDirection>();
const sortMode = ref<DataGridSortMode>();
const onSort = vi.fn((column: string, columnIndex: number, direction: DataGridSortDirection | null, _whereInput?: string, mode: DataGridSortMode = "database") => {
const indexes = direction ? sortDataGridRowIndexes(originalRows, columnIndex, direction, result.value.column_types?.[columnIndex]) : originalRows.map((_, index) => index);
result.value = markRaw({ ...result.value, rows: indexes.map((index) => originalRows[index]!) });
sortColumn.value = direction ? column : undefined;
sortColumnIndex.value = direction ? columnIndex : undefined;
sortDirection.value = direction ?? undefined;
sortMode.value = direction ? mode : undefined;
});
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result: result.value,
databaseType: "mysql",
context: "table-data",
sortColumn: sortColumn.value,
sortColumnIndex: sortColumnIndex.value,
sortDirection: sortDirection.value,
sortMode: sortMode.value,
tableMeta: {
tableName: "scores",
columns: [
{ name: "name", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "score", data_type: "bigint", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["name"],
},
onSort,
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
return { host, onSort };
}
async function openTranspose(host: HTMLElement) {
const firstRowNumber = host.querySelector<HTMLElement>(".data-grid-row-number");
if (!firstRowNumber) throw new Error("Row number not found");
firstRowNumber.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
firstRowNumber.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, button: 0 }));
await settle();
}
async function selectTransposeSort(host: HTMLElement, columnIndex: number, labelKey: string) {
const trigger = transposeField(host, columnIndex).querySelector<HTMLButtonElement>("[data-grid-transpose-sort]");
if (!trigger) throw new Error(`Transpose sort trigger not found: ${columnIndex}`);
trigger.click();
await settle();
menuAction(i18n.global.t(labelKey)).click();
await settle();
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("DataGrid transpose sorting", () => {
it("uses the shared local/database sort actions and keeps the active record and row selection", async () => {
const { host, onSort } = mountGrid();
await settle();
await openTranspose(host);
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
const nextRecordButton = host.querySelector(".lucide-chevron-right")?.closest<HTMLButtonElement>("button");
if (!nextRecordButton) throw new Error("Next transpose record button not found");
nextRecordButton.click();
await settle();
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.sortCurrentPageAscending");
expect(onSort).toHaveBeenLastCalledWith("score", 1, "asc", undefined, "local");
expect(transposeValues(host, 1)).toEqual(["2", "10", "NULL"]);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.sortDatabaseDescending");
expect(onSort).toHaveBeenLastCalledWith("score", 1, "desc", undefined, "database");
expect(transposeValues(host, 1)).toEqual(["10", "2", "NULL"]);
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.clearSort");
expect(onSort).toHaveBeenLastCalledWith("score", 1, null, undefined, "database");
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
expect(host.querySelector(".transpose-grid-scroller")).not.toBeNull();
});
});