feat(grid): show nullable indicator in query result headers
This commit is contained in:
parent
5d5b4f6154
commit
7c2528d32f
|
|
@ -138,7 +138,7 @@ import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDet
|
|||
import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter";
|
||||
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||
import { resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
|
||||
import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
|
||||
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
|
||||
|
|
@ -538,6 +538,10 @@ function headerColumnType(column: string, actualColIdx: number): string {
|
|||
return resolved ? shortTypeName(compactHeaderColumnType(resolved)) : "";
|
||||
}
|
||||
|
||||
function headerColumnNullability(actualColIdx: number): "nullable" | "required" | undefined {
|
||||
return resolveDataGridColumnNullability(props.context, tableColumnForGridColumn(actualColIdx));
|
||||
}
|
||||
|
||||
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)));
|
||||
|
|
@ -9355,6 +9359,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:show-comment-line="reserveColumnCommentLine"
|
||||
:tooltip-column-type="columnTypeMap.get(col.name)"
|
||||
:tooltip-column-comment="columnCommentMap.get(col.name)"
|
||||
:column-nullability="headerColumnNullability(col.actualColIdx)"
|
||||
:type-class="typeColorClass(headerColumnType(col.name, col.actualColIdx))"
|
||||
:drag-class="columnHeaderDragClass(col.visibleColIdx)"
|
||||
:column-style="columnHeaderStyle(col.visibleColIdx)"
|
||||
|
|
@ -9362,6 +9367,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:column-name-label="t('grid.columnName')"
|
||||
:column-type-label="t('grid.columnType')"
|
||||
:column-comment-label="t('grid.columnComment')"
|
||||
:nullable-label="t('structureEditor.nullable')"
|
||||
:yes-label="t('structureEditor.yes')"
|
||||
:no-label="t('structureEditor.no')"
|
||||
:column-index-label="t('grid.tableInfoIndexes')"
|
||||
:column-primary-index-label="t('grid.columnPrimaryIndex')"
|
||||
:column-unique-index-label="t('grid.columnUniqueIndex')"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const props = defineProps<{
|
|||
columnComment?: string;
|
||||
tooltipColumnType?: string;
|
||||
tooltipColumnComment?: string;
|
||||
columnNullability?: "nullable" | "required";
|
||||
showTypeLine?: boolean;
|
||||
showCommentLine?: boolean;
|
||||
typeClass?: HTMLAttributes["class"];
|
||||
|
|
@ -27,6 +28,9 @@ const props = defineProps<{
|
|||
columnNameLabel: string;
|
||||
columnTypeLabel: string;
|
||||
columnCommentLabel: string;
|
||||
nullableLabel?: string;
|
||||
yesLabel?: string;
|
||||
noLabel?: string;
|
||||
columnIndexLabel: string;
|
||||
columnPrimaryIndexLabel: string;
|
||||
columnUniqueIndexLabel: string;
|
||||
|
|
@ -71,7 +75,10 @@ const emit = defineEmits<{
|
|||
<KeyRound v-if="columnIndexKind === 'primary'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
|
||||
<Hash v-else-if="columnIndexKind && columnIndexKind !== 'none'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
|
||||
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<span class="min-w-0 truncate leading-4">{{ name }}</span>
|
||||
<span class="flex min-w-0 items-center gap-1 leading-4">
|
||||
<span class="min-w-0 truncate">{{ name }}</span>
|
||||
<span v-if="columnNullability === 'nullable'" data-grid-header-nullable class="shrink-0 rounded-sm border border-muted-foreground/40 px-0.5 text-[8px] font-semibold leading-3 text-muted-foreground" :title="nullableLabel" :aria-label="nullableLabel"> NULL </span>
|
||||
</span>
|
||||
<span v-if="showTypeLine" data-grid-header-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3" :class="[typeClass, { invisible: !columnType }]" :title="columnType || undefined" :aria-hidden="columnType ? undefined : true">{{ columnType }}</span>
|
||||
<span v-if="showCommentLine" data-grid-header-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground" :class="{ invisible: !columnComment }" :title="columnComment || undefined" :aria-hidden="columnComment ? undefined : true">{{
|
||||
columnComment
|
||||
|
|
@ -98,6 +105,10 @@ const emit = defineEmits<{
|
|||
<span class="text-background/70">{{ columnCommentLabel }}</span>
|
||||
<span>{{ tooltipColumnComment ?? columnComment }}</span>
|
||||
</template>
|
||||
<template v-if="columnNullability">
|
||||
<span class="text-background/70">{{ nullableLabel }}</span>
|
||||
<span>{{ columnNullability === "nullable" ? yesLabel : noLabel }}</span>
|
||||
</template>
|
||||
<template v-if="columnIndexKind && columnIndexKind !== 'none'">
|
||||
<span class="text-background/70">{{ columnIndexLabel }}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -384,6 +384,34 @@ describe("DataGridColumnHeader", () => {
|
|||
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);
|
||||
});
|
||||
|
||||
it("marks nullable columns without marking required columns", () => {
|
||||
const baseProps = {
|
||||
name: "nickname",
|
||||
actualColumnIndex: 1,
|
||||
visibleColumnIndex: 1,
|
||||
copyColumnNameLabel: "copy",
|
||||
columnNameLabel: "name",
|
||||
columnTypeLabel: "type",
|
||||
columnCommentLabel: "comment",
|
||||
nullableLabel: "nullable",
|
||||
yesLabel: "yes",
|
||||
noLabel: "no",
|
||||
columnIndexLabel: "index",
|
||||
columnPrimaryIndexLabel: "primary",
|
||||
columnUniqueIndexLabel: "unique",
|
||||
columnRegularIndexLabel: "regular",
|
||||
};
|
||||
const nullable = mountComponent(DataGridColumnHeader, { ...baseProps, columnNullability: "nullable" });
|
||||
const required = mountComponent(DataGridColumnHeader, { ...baseProps, columnNullability: "required" });
|
||||
const badge = findOne(nullable.root, (node) => node.props["data-grid-header-nullable"] === "");
|
||||
|
||||
expect(hostText(badge).trim()).toBe("NULL");
|
||||
expect(badge.props.title).toBe("nullable");
|
||||
expect(hostText(nullable.root)).toContain("nullableyes");
|
||||
expect(findAll(required.root, (node) => node.props["data-grid-header-nullable"] === "")).toHaveLength(0);
|
||||
expect(hostText(required.root)).toContain("nullableno");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridFilterBuilder", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { ColumnInfo } from "@/types/database";
|
||||
|
||||
export type DataGridColumnNullability = "nullable" | "required";
|
||||
|
||||
export function resolveDataGridColumnNullability(context: "results" | "table-data" | undefined, column: Pick<ColumnInfo, "is_nullable"> | undefined): DataGridColumnNullability | undefined {
|
||||
if (context !== "results" || !column) return undefined;
|
||||
return column.is_nullable ? "nullable" : "required";
|
||||
}
|
||||
|
||||
export function resolveDataGridColumnsByResultIndex(options: { resultColumns: readonly string[]; sourceColumns?: readonly (string | undefined)[]; tableColumns: readonly ColumnInfo[] }): Array<ColumnInfo | undefined> {
|
||||
const columnsByName = new Map<string, ColumnInfo>();
|
||||
for (const column of options.tableColumns) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { strict as assert } from "node:assert";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "../../apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts";
|
||||
import { resolveDataGridColumnsByResultIndex } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts";
|
||||
import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts";
|
||||
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function column(name: string, dataType: string): ColumnInfo {
|
||||
|
|
@ -93,6 +93,17 @@ test("indexes table metadata once and resolves source-column aliases", () => {
|
|||
assert.equal(resolved[2], undefined);
|
||||
});
|
||||
|
||||
test("shows nullability only for query results with resolved column metadata", () => {
|
||||
const nullable = column("nickname", "varchar");
|
||||
const required = { ...column("code", "varchar"), is_nullable: false };
|
||||
|
||||
assert.equal(resolveDataGridColumnNullability("results", nullable), "nullable");
|
||||
assert.equal(resolveDataGridColumnNullability("results", required), "required");
|
||||
assert.equal(resolveDataGridColumnNullability("results", undefined), undefined);
|
||||
assert.equal(resolveDataGridColumnNullability("table-data", nullable), undefined);
|
||||
assert.equal(resolveDataGridColumnNullability(undefined, nullable), undefined);
|
||||
});
|
||||
|
||||
test("keeps the enum editor as the default boolean edit path and gates checkbox interaction behind the checkbox display mode", () => {
|
||||
const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const rendererSource = readFileSync("apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts", "utf8");
|
||||
|
|
|
|||
Loading…
Reference in New Issue