feat(grid): show column comments in condition suggestions
This commit is contained in:
parent
ecec25f0f4
commit
dfd47bc54c
|
|
@ -7404,6 +7404,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
v-model:order-by-input="orderByInput"
|
||||
v-model:filter-builder-open="filterBuilderOpen"
|
||||
:columns="props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns"
|
||||
:condition-columns="props.tableMeta?.columns ?? props.result.columns"
|
||||
:history-scope="conditionHistoryScope"
|
||||
:can-use-where-search="canUseWhereSearch"
|
||||
:compact="compactDataGridToolbar"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch, type CSSProperties } from "vue";
|
||||
import { ChevronDown, X } from "@lucide/vue";
|
||||
import { useDataGridConditionEditor, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
|
||||
import { getDataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
|
||||
import { useDataGridConditionEditor, type DataGridConditionColumnOption, type DataGridConditionSuggestionProvider } from "@/composables/useDataGridConditionEditor";
|
||||
import { getDataGridConditionSuggestionPosition, getDataGridConditionSuggestionPreferredWidth } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
|
||||
import type { DataGridConditionHistoryKind, DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: DataGridConditionHistoryKind;
|
||||
columns?: readonly string[];
|
||||
columns?: readonly DataGridConditionColumnOption[];
|
||||
historyScope: DataGridConditionHistoryScope;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
|
|
@ -64,6 +64,7 @@ const activeEditor = computed(() => overlayRef.value ?? inputRef.value);
|
|||
const hasValue = computed(() => modelValue.value.trim().length > 0);
|
||||
const emptyHistoryText = computed(() => (modelValue.value.trim() ? props.historyNoMatchesText : props.historyEmptyText));
|
||||
const activeSuggestionId = computed(() => (editor.highlightedIndex.value >= 0 ? `${suggestionListId}-${editor.highlightedIndex.value}` : undefined));
|
||||
const suggestionPreferredWidth = computed(() => getDataGridConditionSuggestionPreferredWidth(editor.suggestions.value));
|
||||
const suggestionStyle = computed<CSSProperties>(() => ({
|
||||
left: `${suggestionPosition.value.left}px`,
|
||||
top: `${suggestionPosition.value.top}px`,
|
||||
|
|
@ -112,7 +113,11 @@ function updateSuggestionPosition() {
|
|||
void nextTick(() => {
|
||||
const target = activeEditor.value;
|
||||
if (!target) return;
|
||||
suggestionPosition.value = getDataGridConditionSuggestionPosition(target.getBoundingClientRect(), { viewportWidth: window.innerWidth });
|
||||
suggestionPosition.value = getDataGridConditionSuggestionPosition(target.getBoundingClientRect(), {
|
||||
viewportWidth: window.innerWidth,
|
||||
preferredWidth: suggestionPreferredWidth.value,
|
||||
maxWidth: suggestionPreferredWidth.value === undefined ? undefined : 520,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +239,9 @@ function hideHistoryPreview() {
|
|||
}
|
||||
|
||||
watch(modelValue, () => resizeEditor());
|
||||
watch(suggestionPreferredWidth, () => {
|
||||
if (editor.dropdownOpen.value) updateSuggestionPosition();
|
||||
});
|
||||
watch(
|
||||
() => editor.dropdownOpen.value,
|
||||
(open) => {
|
||||
|
|
@ -357,7 +365,10 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
"
|
||||
@mouseleave="hideHistoryPreview"
|
||||
>
|
||||
<span data-condition-history-text class="min-w-0 flex-1 truncate font-mono">{{ suggestion.value }}</span>
|
||||
<span data-condition-history-text class="data-grid-condition-suggestion-field min-w-0 truncate" :class="suggestion.comment ? 'max-w-[75%] shrink-0' : 'flex-1'" :title="suggestion.value">
|
||||
{{ suggestion.value }}
|
||||
</span>
|
||||
<span v-if="suggestion.comment" class="ml-3 min-w-0 flex-1 truncate text-right text-muted-foreground" :title="suggestion.comment">{{ suggestion.comment }}</span>
|
||||
<button v-if="suggestion.kind === 'history'" type="button" class="ml-2 shrink-0 text-muted-foreground hover:text-foreground" @mousedown.stop.prevent="editor.deleteHistory(suggestion.value)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -418,6 +429,16 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input,
|
||||
.data-grid-condition-suggestion-field {
|
||||
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-size: 0.875rem;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings:
|
||||
"liga" 0,
|
||||
"calt" 0;
|
||||
}
|
||||
|
||||
.data-grid-topbar-condition-input {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
|
@ -430,13 +451,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH
|
|||
border-radius: 0;
|
||||
appearance: none;
|
||||
scrollbar-width: none;
|
||||
font-family: var(--data-grid-condition-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings:
|
||||
"liga" 0,
|
||||
"calt" 0;
|
||||
}
|
||||
|
||||
:global(.dark) .data-grid-topbar-condition-input {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import type { DataGridConditionColumnOption } from "@/composables/useDataGridConditionEditor";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import type { DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
|
|
@ -22,6 +23,7 @@ const props = defineProps<{
|
|||
whereInput: string;
|
||||
orderByInput: string;
|
||||
columns: readonly string[];
|
||||
conditionColumns: readonly DataGridConditionColumnOption[];
|
||||
historyScope: DataGridConditionHistoryScope;
|
||||
canUseWhereSearch: boolean;
|
||||
compact: boolean;
|
||||
|
|
@ -174,7 +176,7 @@ onUnmounted(onResizeEnd);
|
|||
<DataGridConditionEditor
|
||||
:model-value="whereInput"
|
||||
kind="where"
|
||||
:columns="columns"
|
||||
:columns="conditionColumns"
|
||||
:history-scope="historyScope"
|
||||
placeholder="WHERE"
|
||||
:history-empty-text="t('grid.conditionHistoryEmpty')"
|
||||
|
|
@ -199,7 +201,7 @@ onUnmounted(onResizeEnd);
|
|||
<DataGridConditionEditor
|
||||
:model-value="orderByInput"
|
||||
kind="orderBy"
|
||||
:columns="columns"
|
||||
:columns="conditionColumns"
|
||||
:history-scope="historyScope"
|
||||
placeholder="ORDER BY"
|
||||
:history-empty-text="t('grid.conditionHistoryEmpty')"
|
||||
|
|
|
|||
|
|
@ -41,6 +41,64 @@ describe("useDataGridConditionEditor", () => {
|
|||
expect(value.value).toBe("status = customer_name");
|
||||
});
|
||||
|
||||
it("reuses column comments for field suggestions without adding them to history", async () => {
|
||||
const scope = { connectionId: "connection", database: "db", tableName: "users" };
|
||||
rememberDataGridConditionHistory("where", scope, "customer_id = 1");
|
||||
const value = ref("");
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind: "where",
|
||||
value,
|
||||
columns: [
|
||||
{ name: "customer_id", comment: "客户编号" },
|
||||
{ name: "customer_name", comment: null },
|
||||
],
|
||||
historyScope: scope,
|
||||
});
|
||||
|
||||
value.value = "cust";
|
||||
await nextTick();
|
||||
await vi.waitFor(() =>
|
||||
expect(editor.suggestions.value).toEqual([
|
||||
{ value: "customer_id", kind: "column", comment: "客户编号" },
|
||||
{ value: "customer_name", kind: "column" },
|
||||
]),
|
||||
);
|
||||
|
||||
value.value = "customer";
|
||||
editor.dismiss();
|
||||
editor.openHistory();
|
||||
expect(editor.suggestions.value).toEqual([{ value: "customer_id = 1", kind: "history" }]);
|
||||
});
|
||||
|
||||
it.each(["where", "orderBy"] as const)("normalizes %s comments from different metadata providers", async (kind) => {
|
||||
const value = ref("");
|
||||
const editor = useDataGridConditionEditor({
|
||||
kind,
|
||||
value,
|
||||
columns: [
|
||||
"customer_plain",
|
||||
{ name: "customer_native", comment: " 原生注释 " },
|
||||
{ name: "customer_jdbc", comment: "JDBC remarks" },
|
||||
{ name: "customer_null", comment: null },
|
||||
{ name: "customer_blank", comment: " \n\t " },
|
||||
{ name: "customer_invalid", comment: 42 } as unknown as { name: string; comment: string },
|
||||
],
|
||||
historyScope: {},
|
||||
});
|
||||
|
||||
value.value = "cust";
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(6));
|
||||
expect(editor.suggestions.value).toEqual([
|
||||
{ value: "customer_plain", kind: "column" },
|
||||
{ value: "customer_native", kind: "column", comment: "原生注释" },
|
||||
{ value: "customer_jdbc", kind: "column", comment: "JDBC remarks" },
|
||||
{ value: "customer_null", kind: "column" },
|
||||
{ value: "customer_blank", kind: "column" },
|
||||
{ value: "customer_invalid", kind: "column" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores stale asynchronous suggestion responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const value = ref("");
|
||||
|
|
|
|||
|
|
@ -3,9 +3,17 @@ import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberD
|
|||
|
||||
export type DataGridConditionSuggestionKind = "column" | "history";
|
||||
|
||||
export interface DataGridConditionColumnSuggestion {
|
||||
name: string;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export type DataGridConditionColumnOption = string | DataGridConditionColumnSuggestion;
|
||||
|
||||
export interface DataGridConditionSuggestion {
|
||||
value: string;
|
||||
kind: DataGridConditionSuggestionKind;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface DataGridConditionSuggestionContext {
|
||||
|
|
@ -20,7 +28,7 @@ export type DataGridConditionSuggestionProvider = (context: DataGridConditionSug
|
|||
export interface UseDataGridConditionEditorOptions {
|
||||
kind: DataGridConditionHistoryKind;
|
||||
value: Ref<string>;
|
||||
columns?: MaybeRefOrGetter<readonly string[] | undefined>;
|
||||
columns?: MaybeRefOrGetter<readonly DataGridConditionColumnOption[] | undefined>;
|
||||
historyScope: MaybeRefOrGetter<DataGridConditionHistoryScope>;
|
||||
suggestionProvider?: DataGridConditionSuggestionProvider;
|
||||
suggestionDebounceMs?: number;
|
||||
|
|
@ -30,6 +38,11 @@ export interface UseDataGridConditionEditorOptions {
|
|||
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
|
||||
const ORDER_BY_TOKEN_PATTERN = /([^\s,()]+)$/;
|
||||
|
||||
function normalizedColumnComment(column: DataGridConditionColumnOption): string | undefined {
|
||||
if (typeof column === "string" || typeof column.comment !== "string") return undefined;
|
||||
return column.comment.trim() || undefined;
|
||||
}
|
||||
|
||||
function activeToken(kind: DataGridConditionHistoryKind, value: string): string {
|
||||
return (
|
||||
value
|
||||
|
|
@ -73,10 +86,20 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
historyOpen.value = false;
|
||||
}
|
||||
|
||||
function defaultSuggestions(token: string): readonly string[] {
|
||||
function defaultSuggestions(token: string): DataGridConditionSuggestion[] {
|
||||
const normalizedToken = token.toLowerCase();
|
||||
if (!normalizedToken) return [];
|
||||
return (toValue(options.columns) ?? []).filter((column) => column.toLowerCase().startsWith(normalizedToken) && column.toLowerCase() !== normalizedToken);
|
||||
const seen = new Set<string>();
|
||||
const suggestions: DataGridConditionSuggestion[] = [];
|
||||
for (const column of toValue(options.columns) ?? []) {
|
||||
const value = typeof column === "string" ? column : column.name;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
const comment = normalizedColumnComment(column);
|
||||
suggestions.push({ value, kind: "column", ...(comment ? { comment } : {}) });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
async function loadSuggestions(value: string, requestId: number, controller: AbortController) {
|
||||
|
|
@ -84,11 +107,11 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
|||
if (!token) return;
|
||||
suggestionsLoading.value = true;
|
||||
try {
|
||||
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : defaultSuggestions(token);
|
||||
const values = options.suggestionProvider ? await options.suggestionProvider({ kind: options.kind, value, token, signal: controller.signal }) : undefined;
|
||||
// A slower request must never replace suggestions for a newer editor value.
|
||||
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== value || historyOpen.value) return;
|
||||
const limit = options.suggestionLimit ?? 8;
|
||||
suggestions.value = [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" }));
|
||||
suggestions.value = values ? [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })) : defaultSuggestions(token).slice(0, limit);
|
||||
highlightedIndex.value = suggestions.value.length > 0 ? 0 : -1;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && requestId === suggestionRequestId) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
|
||||
import { getDataGridConditionSuggestionPosition, getDataGridConditionSuggestionPreferredWidth } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
|
||||
|
||||
describe("getDataGridConditionSuggestionPosition", () => {
|
||||
test("anchors the dropdown to the input instead of the caret", () => {
|
||||
|
|
@ -21,4 +21,35 @@ describe("getDataGridConditionSuggestionPosition", () => {
|
|||
|
||||
expect(position.width).toBe(180);
|
||||
});
|
||||
|
||||
test("widens commented field suggestions within the configured maximum", () => {
|
||||
const preferredWidth = getDataGridConditionSuggestionPreferredWidth([
|
||||
{ value: "customer_external_identifier", kind: "column", comment: "客户外部系统唯一标识" },
|
||||
{ value: "customer_name", kind: "column", comment: "客户名称" },
|
||||
]);
|
||||
const position = getDataGridConditionSuggestionPosition({ left: 120, bottom: 40, width: 220 }, { viewportWidth: 1000, preferredWidth, maxWidth: 520 });
|
||||
|
||||
expect(preferredWidth).toBeGreaterThan(220);
|
||||
expect(position.width).toBe(preferredWidth);
|
||||
expect(position.width).toBeLessThanOrEqual(520);
|
||||
});
|
||||
|
||||
test("keeps normal width when suggestions have no comments or are history entries", () => {
|
||||
expect(
|
||||
getDataGridConditionSuggestionPreferredWidth([
|
||||
{ value: "customer_id", kind: "column", comment: "" },
|
||||
{ value: "status = 'active'", kind: "history" },
|
||||
]),
|
||||
).toBeUndefined();
|
||||
|
||||
const position = getDataGridConditionSuggestionPosition({ left: 120, bottom: 40, width: 220 }, { viewportWidth: 1000, preferredWidth: undefined, maxWidth: 520 });
|
||||
expect(position.width).toBe(220);
|
||||
});
|
||||
|
||||
test("caps commented suggestions to both the maximum and the viewport", () => {
|
||||
const position = getDataGridConditionSuggestionPosition({ left: 760, bottom: 40, width: 180 }, { viewportWidth: 420, preferredWidth: 800, maxWidth: 520 });
|
||||
|
||||
expect(position.width).toBe(404);
|
||||
expect(position.left + position.width).toBeLessThanOrEqual(412);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export interface DataGridConditionSuggestionRect {
|
|||
export interface DataGridConditionSuggestionPositionOptions {
|
||||
viewportWidth: number;
|
||||
minWidth?: number;
|
||||
preferredWidth?: number;
|
||||
maxWidth?: number;
|
||||
viewportMargin?: number;
|
||||
topOffset?: number;
|
||||
}
|
||||
|
|
@ -17,12 +19,33 @@ export interface DataGridConditionSuggestionPosition {
|
|||
width: number;
|
||||
}
|
||||
|
||||
export interface DataGridConditionSuggestionContent {
|
||||
value: string;
|
||||
kind: "column" | "history";
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
const COMMENTED_SUGGESTION_MIN_WIDTH = 360;
|
||||
const COMMENTED_SUGGESTION_MAX_WIDTH = 520;
|
||||
|
||||
function textWidthUnits(value: string): number {
|
||||
return [...value].reduce((total, character) => total + (character.charCodeAt(0) > 0xff ? 2 : 1), 0);
|
||||
}
|
||||
|
||||
export function getDataGridConditionSuggestionPreferredWidth(suggestions: readonly DataGridConditionSuggestionContent[]): number | undefined {
|
||||
const hasComment = suggestions.some((suggestion) => suggestion.kind === "column" && !!suggestion.comment?.trim());
|
||||
if (!hasComment) return undefined;
|
||||
const longestFieldUnits = suggestions.reduce((longest, suggestion) => (suggestion.kind === "column" ? Math.max(longest, textWidthUnits(suggestion.value)) : longest), 0);
|
||||
return Math.min(COMMENTED_SUGGESTION_MAX_WIDTH, Math.max(COMMENTED_SUGGESTION_MIN_WIDTH, Math.ceil(longestFieldUnits * 7.5 + 208)));
|
||||
}
|
||||
|
||||
export function getDataGridConditionSuggestionPosition(inputRect: DataGridConditionSuggestionRect, options: DataGridConditionSuggestionPositionOptions): DataGridConditionSuggestionPosition {
|
||||
const minWidth = options.minWidth ?? 180;
|
||||
const viewportMargin = options.viewportMargin ?? 8;
|
||||
const topOffset = options.topOffset ?? 2;
|
||||
const availableWidth = Math.max(minWidth, options.viewportWidth - viewportMargin * 2);
|
||||
const width = Math.min(Math.max(inputRect.width, minWidth), availableWidth);
|
||||
const availableWidth = Math.max(0, options.viewportWidth - viewportMargin * 2);
|
||||
const widthLimit = Math.min(availableWidth, options.maxWidth ?? availableWidth);
|
||||
const width = Math.min(Math.max(inputRect.width, minWidth, options.preferredWidth ?? 0), widthLimit);
|
||||
const maxLeft = Math.max(viewportMargin, options.viewportWidth - viewportMargin - width);
|
||||
const left = Math.min(Math.max(inputRect.left, viewportMargin), maxLeft);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue