mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user