diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index c9ee74c090..a0987b2395 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -81,6 +81,14 @@ import { usesSyntheticRowIdKey, } from "@/lib/tableEditing"; import { formatGridSqlLiteral } from "@/lib/dataGridSql"; +import { + buildTransposeRows, + nextTransposeState, + transposeAnchorRowIndex, + transposeFieldWidth, + transposeScrollLeftForRecord, + visibleTransposeRecordWindow, +} from "@/lib/dataGridTranspose"; import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus"; import { displayCellValue, type CellValue } from "@/lib/cellValue"; import { @@ -241,7 +249,9 @@ const detailCell = ref<{ rowIndex: number; col: number } | null>(null); const showCellDetail = ref(false); const transposeRowIndex = ref(null); const showTranspose = ref(false); -const transposePanelWidth = ref(320); +const transposeScrollRef = ref(); +const transposeScrollLeft = ref(0); +const transposeViewportWidth = ref(0); const sortCol = ref(null); const sortColIndex = ref(null); const sortDir = ref<"asc" | "desc">("asc"); @@ -1880,26 +1890,97 @@ function copyDetailSqlCondition() { copyText(condition); } -const transposeData = computed(() => { - if (transposeRowIndex.value === null) return null; - const item = displayItems.value[transposeRowIndex.value]; - if (!item) return null; - return visibleColumnIndexes.value.map((columnIndex) => { - const col = props.result.columns[columnIndex]; - return { - column: col, - type: columnTypeMap.value.get(col) || "", - value: item.data[columnIndex], - display: formatCell(item.data[columnIndex], columnIndex), - isNull: item.data[columnIndex] === null, - }; +const TRANSPOSE_RECORD_WIDTH = 168; +const transposePinnedWidth = computed(() => transposeFieldWidth(visibleColumns.value)); + +const transposeRows = computed(() => { + return buildTransposeRows({ + columns: visibleColumns.value, + records: displayItems.value.map((item) => visibleColumnIndexes.value.map((columnIndex) => item.data[columnIndex])), + typeByColumn: columnTypeMap.value, + displayValue: (value, _column, index) => formatCell(value, visibleColumnIndexes.value[index]), }); }); +const isTransposeMode = computed(() => showTranspose.value && transposeRows.value.length > 0); +const transposeRecordWindow = computed(() => + visibleTransposeRecordWindow({ + totalRecords: displayItems.value.length, + scrollLeft: transposeScrollLeft.value, + viewportWidth: transposeViewportWidth.value, + pinnedWidth: transposePinnedWidth.value, + recordWidth: TRANSPOSE_RECORD_WIDTH, + overscan: 2, + }), +); +const visibleTransposeRecordIndexes = computed(() => { + const window = transposeRecordWindow.value; + return Array.from({ length: window.end - window.start }, (_, offset) => window.start + offset); +}); +const transposeTotalWidth = computed( + () => transposePinnedWidth.value + displayItems.value.length * TRANSPOSE_RECORD_WIDTH, +); + +function transposeScrollElement(): HTMLElement | undefined { + const raw = transposeScrollRef.value; + if (!raw) return undefined; + return raw instanceof HTMLElement ? raw : raw.$el; +} + +function updateTransposeViewport() { + const el = transposeScrollElement(); + if (!el) return; + transposeScrollLeft.value = el.scrollLeft; + transposeViewportWidth.value = el.clientWidth; +} + +function onTransposeScroll() { + updateTransposeViewport(); +} + +function scrollTransposeRecordIntoView(rowIndex: number) { + nextTick(() => { + const el = transposeScrollElement(); + if (!el) return; + el.scrollLeft = transposeScrollLeftForRecord({ + recordIndex: rowIndex, + totalRecords: displayItems.value.length, + viewportWidth: el.clientWidth, + pinnedWidth: transposePinnedWidth.value, + recordWidth: TRANSPOSE_RECORD_WIDTH, + }); + updateTransposeViewport(); + }); +} function openTranspose(rowIndex: number) { transposeRowIndex.value = rowIndex; showTranspose.value = true; showCellDetail.value = false; + nextTick(updateTransposeViewport); + scrollTransposeRecordIntoView(rowIndex); +} + +function openContextTranspose() { + if (!contextCell.value) return; + openTranspose( + transposeAnchorRowIndex({ + requestedRowIndex: contextCell.value.rowIndex, + rowIds: displayItems.value.map((item) => item.id), + selectedRowIds: selectedRowIds.value, + selectedRange: selectedRange.value, + }), + ); +} + +function toggleTranspose(rowIndex: number) { + const next = nextTransposeState(showTranspose.value, transposeRowIndex.value, rowIndex); + transposeRowIndex.value = next.transposeRowIndex; + showTranspose.value = next.showTranspose; + if (next.showTranspose) { + showCellDetail.value = false; + nextTick(updateTransposeViewport); + if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + } } function transposeNav(delta: number) { @@ -1907,23 +1988,13 @@ function transposeNav(delta: number) { const next = transposeRowIndex.value + delta; if (next >= 0 && next < displayItems.value.length) { transposeRowIndex.value = next; + scrollTransposeRecordIntoView(next); } } -function onTransposeResizeStart(e: MouseEvent) { - e.preventDefault(); - const startX = e.clientX; - const startWidth = transposePanelWidth.value; - const onMove = (ev: MouseEvent) => { - transposePanelWidth.value = Math.max(200, startWidth - (ev.clientX - startX)); - }; - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); -} +watch(isTransposeMode, (active) => { + if (active) nextTick(updateTransposeViewport); +}); watch( () => props.result, @@ -2531,520 +2602,626 @@ defineExpose({ - -
-
-
+
+ + {{ t("grid.transpose") }} + + {{ t("grid.rowNumber") }} {{ (transposeRowIndex ?? 0) + 1 }} + + +
- - + + + + +
+ + + + +
+
- -
-
-
- - {{ t("grid.transpose") }} - {{ t("grid.rowNumber") }} {{ (transposeRowIndex ?? 0) + 1 }} - - - - -
-
- - - - - - - -
-
{{ field.column }}
-
- {{ field.type }} -
-
- {{ field.display }} -
-
-
@@ -3428,7 +3544,7 @@ defineExpose({ {{ t("grid.copyAll") }} - + {{ t("grid.transpose") }} @@ -3623,6 +3739,20 @@ defineExpose({ pointer-events: none; } +.transpose-grid-scroller { + overflow-anchor: none; + will-change: scroll-position; +} + +.transpose-grid-scroller :deep(.vue-recycle-scroller__item-wrapper) { + min-width: var(--transpose-total-w); + overflow: visible; +} + +.transpose-grid-scroller :deep(.vue-recycle-scroller__item-view) { + contain: layout style paint; +} + .ddl-drawer-resizing { transition: none; } diff --git a/src/lib/dataGridTranspose.ts b/src/lib/dataGridTranspose.ts new file mode 100644 index 0000000000..5c495220c4 --- /dev/null +++ b/src/lib/dataGridTranspose.ts @@ -0,0 +1,157 @@ +export interface DataGridTransposeState { + showTranspose: boolean; + transposeRowIndex: number | null; +} + +export interface BuildTransposeRowsOptions { + columns: string[]; + records: T[][]; + typeByColumn?: Map; + displayValue: (value: T, column: string, columnIndex: number, recordIndex: number) => string; +} + +export interface DataGridTransposeCell { + value: T; + display: string; + isNull: boolean; +} + +export interface DataGridTransposeRow { + id: string; + column: string; + type: string; + values: Array>; +} + +export interface TransposeRecordWindowOptions { + totalRecords: number; + scrollLeft: number; + viewportWidth: number; + pinnedWidth: number; + recordWidth: number; + overscan?: number; +} + +export interface TransposeRecordWindow { + start: number; + end: number; + beforeWidth: number; + afterWidth: number; +} + +export interface TransposeSelectionRange { + startRow: number; + endRow: number; + startCol: number; + endCol: number; +} + +export interface TransposeAnchorOptions { + requestedRowIndex: number; + rowIds: number[]; + selectedRowIds: Set; + selectedRange: TransposeSelectionRange | null; +} + +export interface TransposeFieldWidthOptions { + minWidth?: number; + maxWidth?: number; + charWidth?: number; + padding?: number; +} + +export interface TransposeScrollLeftOptions { + recordIndex: number; + totalRecords: number; + viewportWidth: number; + pinnedWidth: number; + recordWidth: number; +} + +export function nextTransposeState( + showTranspose: boolean, + transposeRowIndex: number | null, + requestedRowIndex: number, +): DataGridTransposeState { + if (showTranspose && transposeRowIndex === requestedRowIndex) { + return { showTranspose: false, transposeRowIndex: null }; + } + return { showTranspose: true, transposeRowIndex: requestedRowIndex }; +} + +export function buildTransposeRows(options: BuildTransposeRowsOptions): Array> { + return options.columns.map((column, columnIndex) => { + return { + id: `${columnIndex}:${column}`, + column, + type: options.typeByColumn?.get(column) || "", + values: options.records.map((record, recordIndex) => { + const value = record[columnIndex] as T; + return { + value, + display: options.displayValue(value, column, columnIndex, recordIndex), + isNull: value === null, + }; + }), + }; + }); +} + +export function visibleTransposeRecordWindow(options: TransposeRecordWindowOptions): TransposeRecordWindow { + if (options.totalRecords <= 0 || options.recordWidth <= 0) { + return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 }; + } + + const overscan = options.overscan ?? 2; + const recordScrollLeft = Math.max(0, options.scrollLeft - options.pinnedWidth); + const recordViewportWidth = Math.max(0, options.viewportWidth - options.pinnedWidth); + const start = Math.max(0, Math.floor(recordScrollLeft / options.recordWidth) - overscan); + const end = Math.min( + options.totalRecords, + Math.ceil((recordScrollLeft + recordViewportWidth) / options.recordWidth) + overscan + 1, + ); + + return { + start, + end, + beforeWidth: start * options.recordWidth, + afterWidth: Math.max(0, (options.totalRecords - end) * options.recordWidth), + }; +} + +export function transposeAnchorRowIndex(options: TransposeAnchorOptions): number { + const requestedRowId = options.rowIds[options.requestedRowIndex]; + if (requestedRowId !== undefined && options.selectedRowIds.size > 1 && options.selectedRowIds.has(requestedRowId)) { + const firstSelectedIndex = options.rowIds.findIndex((rowId) => options.selectedRowIds.has(rowId)); + if (firstSelectedIndex >= 0) return firstSelectedIndex; + } + + const range = options.selectedRange; + if ( + range && + range.startRow !== range.endRow && + options.requestedRowIndex >= range.startRow && + options.requestedRowIndex <= range.endRow + ) { + return range.startRow; + } + + return options.requestedRowIndex; +} + +export function transposeFieldWidth(columns: string[], options: TransposeFieldWidthOptions = {}): number { + const minWidth = options.minWidth ?? 104; + const maxWidth = options.maxWidth ?? 220; + const charWidth = options.charWidth ?? 8; + const padding = options.padding ?? 32; + const longest = columns.reduce((max, column) => Math.max(max, column.length), 0); + return Math.min(maxWidth, Math.max(minWidth, Math.ceil(longest * charWidth + padding))); +} + +export function transposeScrollLeftForRecord(options: TransposeScrollLeftOptions): number { + if (options.recordWidth <= 0 || options.totalRecords <= 0) return 0; + const desired = Math.max(0, options.recordIndex) * options.recordWidth; + const totalWidth = options.pinnedWidth + options.totalRecords * options.recordWidth; + const maxScrollLeft = Math.max(0, totalWidth - options.viewportWidth); + return Math.min(desired, maxScrollLeft); +} diff --git a/tests/dataGridTranspose.test.ts b/tests/dataGridTranspose.test.ts new file mode 100644 index 0000000000..b517a8723c --- /dev/null +++ b/tests/dataGridTranspose.test.ts @@ -0,0 +1,141 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + buildTransposeRows, + nextTransposeState, + transposeAnchorRowIndex, + transposeFieldWidth, + transposeScrollLeftForRecord, + visibleTransposeRecordWindow, +} from "../src/lib/dataGridTranspose.ts"; + +test("row number double click opens transpose for a row", () => { + assert.deepEqual(nextTransposeState(false, null, 2), { + showTranspose: true, + transposeRowIndex: 2, + }); +}); + +test("row number double click switches transpose to another row", () => { + assert.deepEqual(nextTransposeState(true, 2, 4), { + showTranspose: true, + transposeRowIndex: 4, + }); +}); + +test("row number double click closes transpose for the same row", () => { + assert.deepEqual(nextTransposeState(true, 2, 2), { + showTranspose: false, + transposeRowIndex: null, + }); +}); + +test("builds one virtualizable transpose row per field with all record values", () => { + const rows = buildTransposeRows({ + columns: ["id", "name", "notes"], + records: [ + [1, "Ada", null], + [2, "Grace", "compiler"], + ], + typeByColumn: new Map([ + ["id", "int"], + ["name", "varchar"], + ]), + displayValue: (value) => (value === null ? "NULL" : String(value)), + }); + + assert.deepEqual(rows, [ + { + id: "0:id", + column: "id", + type: "int", + values: [ + { value: 1, display: "1", isNull: false }, + { value: 2, display: "2", isNull: false }, + ], + }, + { + id: "1:name", + column: "name", + type: "varchar", + values: [ + { value: "Ada", display: "Ada", isNull: false }, + { value: "Grace", display: "Grace", isNull: false }, + ], + }, + { + id: "2:notes", + column: "notes", + type: "", + values: [ + { value: null, display: "NULL", isNull: true }, + { value: "compiler", display: "compiler", isNull: false }, + ], + }, + ]); +}); + +test("calculates a horizontal record window with spacer widths", () => { + assert.deepEqual( + visibleTransposeRecordWindow({ + totalRecords: 100, + scrollLeft: 680, + viewportWidth: 720, + pinnedWidth: 320, + recordWidth: 160, + overscan: 1, + }), + { + start: 1, + end: 7, + beforeWidth: 160, + afterWidth: 14880, + }, + ); +}); + +test("uses the first selected row as the transpose anchor when context row is inside row selection", () => { + assert.equal( + transposeAnchorRowIndex({ + requestedRowIndex: 3, + rowIds: [10, 11, 12, 13, 14, 15], + selectedRowIds: new Set([12, 13, 14]), + selectedRange: null, + }), + 2, + ); +}); + +test("uses the first selected cell range row as the transpose anchor when context row is inside range", () => { + assert.equal( + transposeAnchorRowIndex({ + requestedRowIndex: 5, + rowIds: [10, 11, 12, 13, 14, 15], + selectedRowIds: new Set(), + selectedRange: { startRow: 2, endRow: 5, startCol: 0, endCol: 2 }, + }), + 2, + ); +}); + +test("sizes the transpose field column from visible field names", () => { + assert.equal(transposeFieldWidth(["id", "iso3", "year"]), 104); + assert.equal(transposeFieldWidth(["country_name"]), 128); +}); + +test("caps the transpose field column width for long field names", () => { + assert.equal(transposeFieldWidth(["a_very_long_metric_column_name"]), 220); +}); + +test("aligns the selected transpose record at the start of the scrollable records", () => { + assert.equal( + transposeScrollLeftForRecord({ + recordIndex: 55, + totalRecords: 100, + viewportWidth: 1200, + pinnedWidth: 104, + recordWidth: 168, + }), + 9240, + ); +});