diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index c68b1d9848..2d65052a37 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -196,6 +196,7 @@ import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dat import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent, type DataGridToolbarActionCapability, type DataGridToolbarAutoRefreshCapability, type DataGridToolbarSaveCapability } from "@/lib/dataGrid/dataGridToolbar"; import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; +import { reserveDataGridHeaderLine } from "@/lib/dataGrid/dataGridHeaderLayout"; import { supportsTableStructureEditing } from "@/lib/database/databaseCapabilities"; import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -441,6 +442,10 @@ function headerColumnType(column: string, actualColIdx: number): string { return resolved ? shortTypeName(compactHeaderColumnType(resolved)) : ""; } +const reserveColumnTypeLine = computed(() => reserveDataGridHeaderLine(showColumnTypesInHeader.value, props.result.columns, (column, index) => headerColumnType(column, index))); +// Match the rendered header columns so comments from unprojected metadata cannot add an empty row. +const reserveColumnCommentLine = computed(() => reserveDataGridHeaderLine(showColumnCommentsInHeader.value, props.result.columns, (column) => headerColumnComment(column))); + function shortTypeName(t: string): string { const s = t.toLowerCase(); if (s === "character varying") return "varchar"; @@ -7769,6 +7774,8 @@ const gridContextMenuItems = computed(() => { :tooltip-disabled="columnHeaderTooltipsDisabled" :column-type="headerColumnType(col.name, col.actualColIdx)" :column-comment="headerColumnComment(col.name)" + :show-type-line="reserveColumnTypeLine" + :show-comment-line="reserveColumnCommentLine" :tooltip-column-type="columnTypeMap.get(col.name)" :tooltip-column-comment="columnCommentMap.get(col.name)" :type-class="typeColorClass(headerColumnType(col.name, col.actualColIdx))" diff --git a/apps/desktop/src/components/grid/DataGridColumnHeader.vue b/apps/desktop/src/components/grid/DataGridColumnHeader.vue index 5dbf51c9c7..d1b98a348b 100644 --- a/apps/desktop/src/components/grid/DataGridColumnHeader.vue +++ b/apps/desktop/src/components/grid/DataGridColumnHeader.vue @@ -15,6 +15,8 @@ defineProps<{ columnComment?: string; tooltipColumnType?: string; tooltipColumnComment?: string; + showTypeLine?: boolean; + showCommentLine?: boolean; typeClass?: HTMLAttributes["class"]; dragClass?: HTMLAttributes["class"]; columnStyle?: CSSProperties; @@ -51,8 +53,10 @@ const emit = defineEmits<{ {{ name }} - {{ columnType }} - {{ columnComment }} + {{ columnType }} + {{ + columnComment + }} diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index b8f83fe147..963fad13d9 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -212,6 +212,67 @@ describe("DataGridColumnHeader", () => { dispatch(handle, "dblclick"); expect(autoFit).toHaveBeenCalledOnce(); }); + + it("keeps configured type and comment lines mounted for columns without values", () => { + const empty = mountComponent(DataGridColumnHeader, { + name: "id", + actualColumnIndex: 0, + visibleColumnIndex: 0, + showTypeLine: true, + showCommentLine: true, + copyColumnNameLabel: "copy", + columnNameLabel: "name", + columnTypeLabel: "type", + columnCommentLabel: "comment", + }); + const emptyType = findOne(empty.root, (node) => node.props["data-grid-header-type-line"] === ""); + const emptyComment = findOne(empty.root, (node) => node.props["data-grid-header-comment-line"] === ""); + + expect(String(emptyType.props.class)).toContain("h-3"); + expect(String(emptyType.props.class)).toContain("invisible"); + expect(emptyType.props.title).toBeUndefined(); + expect(String(emptyComment.props.class)).toContain("h-3"); + expect(String(emptyComment.props.class)).toContain("invisible"); + expect(emptyComment.props.title).toBeUndefined(); + + const populated = mountComponent(DataGridColumnHeader, { + name: "status", + actualColumnIndex: 1, + visibleColumnIndex: 1, + columnType: "varchar", + columnComment: "Current status", + showTypeLine: true, + showCommentLine: true, + copyColumnNameLabel: "copy", + columnNameLabel: "name", + columnTypeLabel: "type", + columnCommentLabel: "comment", + }); + const populatedType = findOne(populated.root, (node) => node.props["data-grid-header-type-line"] === ""); + const populatedComment = findOne(populated.root, (node) => node.props["data-grid-header-comment-line"] === ""); + + expect(String(populatedType.props.class)).not.toContain("invisible"); + expect(populatedType.props.title).toBe("varchar"); + expect(String(populatedComment.props.class)).not.toContain("invisible"); + expect(populatedComment.props.title).toBe("Current status"); + }); + + it("omits optional header lines when both display settings are off", () => { + const mounted = mountComponent(DataGridColumnHeader, { + name: "id", + actualColumnIndex: 0, + visibleColumnIndex: 0, + columnType: "number", + columnComment: "Identifier", + copyColumnNameLabel: "copy", + columnNameLabel: "name", + columnTypeLabel: "type", + columnCommentLabel: "comment", + }); + + expect(findAll(mounted.root, (node) => node.props["data-grid-header-type-line"] === "")).toHaveLength(0); + expect(findAll(mounted.root, (node) => node.props["data-grid-header-comment-line"] === "")).toHaveLength(0); + }); }); describe("DataGridFilterBuilder", () => { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridHeaderLayout.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridHeaderLayout.spec.ts new file mode 100644 index 0000000000..392b9613cb --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridHeaderLayout.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { reserveDataGridHeaderLine } from "@/lib/dataGrid/dataGridHeaderLayout"; + +describe("reserveDataGridHeaderLine", () => { + it("reserves a line only when the setting and full-column content require it", () => { + const values = [{ text: "" }, { text: "later column" }]; + + expect(reserveDataGridHeaderLine(false, values, (value) => value.text)).toBe(false); + expect(reserveDataGridHeaderLine(true, values, (value) => value.text)).toBe(true); + expect(reserveDataGridHeaderLine(true, [{ text: "" }, { text: " " }], (value) => value.text)).toBe(false); + expect(reserveDataGridHeaderLine(true, [], (value: { text: string }) => value.text)).toBe(false); + }); + + it("resolves all columns rather than relying on the currently rendered window", () => { + const visited: number[] = []; + const reserved = reserveDataGridHeaderLine(true, ["", "", "comment"], (value, index) => { + visited.push(index); + return value; + }); + + expect(reserved).toBe(true); + expect(visited).toEqual([0, 1, 2]); + }); + + it("does not reserve a comment line for a comment on an unprojected column", () => { + const comments = new Map([["internal_id", "Not selected"]]); + + expect(reserveDataGridHeaderLine(true, ["id", "name"], (column) => comments.get(column))).toBe(false); + }); + + it("reserves a comment line when a projected column has a comment", () => { + const comments = new Map([["name", "Display name"]]); + + expect(reserveDataGridHeaderLine(true, ["id", "name"], (column) => comments.get(column))).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridHeaderLayout.ts b/apps/desktop/src/lib/dataGrid/dataGridHeaderLayout.ts new file mode 100644 index 0000000000..69417827f7 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridHeaderLayout.ts @@ -0,0 +1,4 @@ +/** Resolve header height from the full column set so virtual-window changes cannot resize it. */ +export function reserveDataGridHeaderLine(enabled: boolean, values: readonly T[], resolveText: (value: T, index: number) => string | null | undefined): boolean { + return enabled && values.some((value, index) => Boolean(resolveText(value, index)?.trim())); +}