feat(grid): enhance field search and filter interaction

This commit is contained in:
zipg authored and GitHub committed 2026-08-07 13:43:55 +08:00
1 parent 13878a1a30
commit f9e93b356f
26 files changed
+491 -35

No files matched your search

@@ -43,6 +43,59 @@ describe("useDataGridConditionEditor", () => {
expect(value.value).toBe("status = customer_name");
});
it.each(["where", "orderBy"] as const)("searches %s fields by camel-case initials and any-position text", async (kind) => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind, value, columns: ["userProfile", "order_id", "created_at"], historyScope: {} });
value.value = "up";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["userProfile"]));
value.value = "id";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["order_id"]));
});
it.each(["where", "orderBy"] as const)("hides weaker %s matches when the input exactly matches a field", async (kind) => {
const value = ref("");
const editor = useDataGridConditionEditor({ kind, value, columns: ["id", "Is_Di", "Did"], historyScope: {} });
value.value = "id";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toEqual([]));
});
it("keeps suggestions dismissed after accepting until the text changes again", async () => {
const value = ref("");
const selectionStart = ref(0);
const selectionEnd = ref(0);
const editor = useDataGridConditionEditor({ kind: "orderBy", value, selectionStart, selectionEnd, columns: ["name", "namespace"], historyScope: {} });
value.value = "na";
selectionStart.value = 2;
selectionEnd.value = 2;
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(2));
editor.navigate(1);
expect(editor.handleKeydown(keyboardEvent("Enter"))).toBe("accept");
expect(value.value).toBe("name");
expect(editor.suggestions.value).toEqual([]);
selectionStart.value = 2;
selectionEnd.value = 2;
await nextTick();
selectionStart.value = 4;
selectionEnd.value = 4;
await nextTick();
expect(editor.suggestions.value).toEqual([]);
value.value = "nam";
selectionStart.value = 3;
selectionEnd.value = 3;
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value.map((item) => item.value)).toEqual(["name", "namespace"]));
});
it("builds and applies suggestions at the current caret instead of the value end", async () => {
const value = ref("");
const selectionStart = ref(0);
@@ -393,6 +446,11 @@ describe("useDataGridConditionEditor", () => {
expect(editor.handleKeydown(initialEnter)).toBe("apply");
expect(initialEnter.preventDefault).toHaveBeenCalledOnce();
expect(value.value).toBe("na");
expect(editor.suggestions.value).toEqual([]);
value.value = "nam";
await nextTick();
await vi.waitFor(() => expect(editor.suggestions.value).toHaveLength(2));
const down = keyboardEvent("ArrowDown");
expect(editor.handleKeydown(down)).toBe("navigate");
@@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest";
import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
describe("useDataGridFilterBuilder", () => {
it("searches columns by camel-case initials and any-position text", () => {
const builder = useDataGridFilterBuilder({ columns: ["userProfile", "order_id", "created_at"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "" });
builder.columnSearch.value = "up";
expect(builder.filteredColumns.value).toEqual(["userProfile"]);
builder.columnSearch.value = "id";
expect(builder.filteredColumns.value).toEqual(["order_id"]);
});
it("normalizes values when modes change", () => {
const builder = useDataGridFilterBuilder({ columns: ["id"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "id = 1" });
builder.ensureRule();
@@ -9,6 +19,14 @@ describe("useDataGridFilterBuilder", () => {
expect(builder.rules.value[0]).toMatchObject({ rawValue: "", rawEndValue: "" });
});
it("starts new filter rules without preselecting a column", () => {
const builder = useDataGridFilterBuilder({ columns: ["id", "name"], createId: () => "rule-1", isComplete: () => true, buildCondition: async () => "" });
builder.ensureRule();
expect(builder.rules.value[0]?.columnName).toBe("");
});
it("skips disabled rules and applies conjunctions", async () => {
let nextId = 0;
const builder = useDataGridFilterBuilder({
@@ -18,7 +36,7 @@ describe("useDataGridFilterBuilder", () => {
buildCondition: async (rule) => `${rule.columnName} = '${rule.rawValue}'`,
});
builder.ensureRule();
builder.updateRule("rule-1", { rawValue: "1" });
builder.updateRule("rule-1", { columnName: "id", rawValue: "1" });
builder.addRule();
builder.updateRule("rule-2", { columnName: "name", rawValue: "Alice", conjunction: "OR" });
expect(await builder.apply()).toBe("(id = '1') OR (name = 'Alice')");
@@ -1,6 +1,7 @@
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
import { pinyinAwareMatchScore } from "@/lib/common/pinyin";
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
export type DataGridConditionSuggestionKind = "column" | "keyword" | "history";
@@ -205,7 +206,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
let suggestionTimer: ReturnType<typeof setTimeout> | undefined;
let suggestionRequestId = 0;
let suggestionAbortController: AbortController | undefined;
let suppressNextValueSuggestion = false;
let suppressedSuggestionValue: string | undefined;
const dropdownOpen = computed(() => suggestions.value.length > 0 || historyOpen.value);
@@ -226,6 +227,11 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
replacementRange.value = undefined;
}
function dismissUntilValueChanges() {
suppressedSuggestionValue = options.value.value;
dismiss();
}
function defaultSuggestions(target: DataGridConditionCompletionTarget): DataGridConditionSuggestion[] {
const role = options.kind === "where" ? whereSuggestionRole(target) : "field";
if (role === "none") return [];
@@ -233,17 +239,19 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
const seen = new Set<string>();
const suggestions: DataGridConditionSuggestion[] = [];
if (role === "field") {
const columns = toValue(options.columns) ?? [];
if (normalizedToken && columns.some((column) => (typeof column === "string" ? column : column.name).toLowerCase() === normalizedToken)) return [];
const scored: Array<{ suggestion: DataGridConditionSuggestion; score: number; index: number }> = [];
let index = 0;
for (const column of toValue(options.columns) ?? []) {
for (const column of columns) {
const columnValue = typeof column === "string" ? column : column.name;
const normalizedValue = columnValue.toLowerCase();
if ((normalizedToken && normalizedValue === normalizedToken) || seen.has(columnValue)) continue;
if (seen.has(columnValue)) continue;
seen.add(columnValue);
// Substring + pinyin-initials matching (金 / zzj / zj → 总租金), scored so
// prefix matches rank above looser ones and ties keep the column order.
const score = normalizedToken ? pinyinAwareMatchScore(columnValue, normalizedToken) : 0;
if (score < 0) continue;
// Keep the existing prefix/pinyin/substring ranking stable, then append
// new camel-initial and ordered-fuzzy matches behind those tiers.
const existingScore = normalizedToken ? pinyinAwareMatchScore(columnValue, normalizedToken) : 0;
if (existingScore < 0 && !matchesIdentifierSearch(columnValue, normalizedToken)) continue;
const score = existingScore < 0 ? 50 : existingScore;
const comment = normalizedColumnComment(column);
const insertText = target.quotedIdentifier ? columnValue : typeof column === "string" ? columnValue : column.insertText;
scored.push({ suggestion: { value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) }, score, index: index++ });
@@ -271,7 +279,8 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
// A slower request must never replace suggestions for a newer editor value.
if (controller.signal.aborted || requestId !== suggestionRequestId || options.value.value !== target.value || historyOpen.value) return;
const limit = options.suggestionLimit ?? 8;
suggestions.value = values ? [...new Set(values)].slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" })) : defaultSuggestions(target).slice(0, limit);
const providerValues = values ? [...new Set(values)] : undefined;
suggestions.value = providerValues ? (providerValues.some((value) => value.toLowerCase() === target.token.toLowerCase()) ? [] : providerValues.slice(0, limit).map((suggestion) => ({ value: suggestion, kind: "column" }))) : defaultSuggestions(target).slice(0, limit);
replacementRange.value = { from: target.from, to: target.to };
highlightedIndex.value = -1;
} catch (error) {
@@ -342,7 +351,6 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
if (!suggestion) return false;
let caret: number;
if (suggestion.kind === "history") {
suppressNextValueSuggestion = true;
options.value.value = suggestion.value;
caret = suggestion.value.length;
} else {
@@ -350,10 +358,10 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
const currentTarget = conditionCompletionTarget(options.kind, options.value.value, options.selectionStart?.value, options.selectionEnd?.value, toValue(options.identifierQuote));
if (!range || currentTarget.from !== range.from || currentTarget.to !== range.to) return false;
const replacement = suggestion.insertText ?? suggestion.value;
suppressNextValueSuggestion = true;
options.value.value = `${options.value.value.slice(0, range.from)}${replacement}${options.value.value.slice(range.to)}`;
caret = range.from + replacement.length;
}
suppressedSuggestionValue = options.value.value;
if (options.selectionStart) options.selectionStart.value = caret;
if (options.selectionEnd) options.selectionEnd.value = caret;
dismiss();
@@ -382,6 +390,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
if (suggestions.value.length > 0 && highlightedIndex.value >= 0) {
if (accept()) return "accept";
}
dismissUntilValueChanges();
return "apply";
}
return undefined;
@@ -390,9 +399,9 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
watch(
() => [options.value.value, options.selectionStart?.value, options.selectionEnd?.value] as const,
([value, selectionStart, selectionEnd]) => {
if (suppressNextValueSuggestion) {
suppressNextValueSuggestion = false;
return;
if (suppressedSuggestionValue !== undefined) {
if (value === suppressedSuggestionValue) return;
suppressedSuggestionValue = undefined;
}
scheduleSuggestions(value, selectionStart, selectionEnd);
},
@@ -1,6 +1,7 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
export type DataGridStructuredFilterRule = {
id: string;
@@ -34,13 +35,13 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
const columnSearch = ref("");
const appliedWhereInput = ref("");
const filteredColumns = computed(() => {
const query = columnSearch.value.trim().toLowerCase();
return query ? toValue(options.columns).filter((column) => column.toLowerCase().includes(query)) : [...toValue(options.columns)];
const query = columnSearch.value.trim();
return query ? toValue(options.columns).filter((column) => matchesIdentifierSearch(column, query)) : [...toValue(options.columns)];
});
const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length);
function defaultRule(): DataGridStructuredFilterRule {
return { id: options.createId?.() ?? crypto.randomUUID(), columnName: toValue(options.columns)[0] ?? "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
return { id: options.createId?.() ?? crypto.randomUUID(), columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
}
function ensureRule() {
if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
@@ -85,7 +86,7 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
() => [...toValue(options.columns)],
(columns) => {
if (!columns.length) rules.value = [];
else rules.value = rules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
else rules.value = rules.value.map((rule) => (!rule.columnName || columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] }));
},
);