feat(grid): enhance field search and filter interaction

This commit is contained in:
zipg 2026-08-07 13:43:55 +08:00 committed by GitHub
parent 13878a1a30
commit f9e93b356f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 491 additions and 35 deletions

View File

@ -720,7 +720,9 @@ const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [
{ value: "like", labelKey: "grid.filterBuilderContains" },
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
{ value: "greater-than-or-equal", labelKey: "grid.filterBuilderGreaterThanOrEqual" },
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
{ value: "less-than-or-equal", labelKey: "grid.filterBuilderLessThanOrEqual" },
{ value: "in", labelKey: "grid.filterBuilderIn" },
{ value: "not-in", labelKey: "grid.filterBuilderNotIn" },
{ value: "between", labelKey: "grid.filterBuilderBetween" },
@ -1565,8 +1567,8 @@ watch(
[structuredFilterRules, appliedStructuredWhereInput, serverColumnFilters],
() => {
const columns = filterBuilderColumnOptions.value;
if (columns.length > 0 && structuredFilterRules.value.some((rule) => !columns.includes(rule.columnName))) {
structuredFilterRules.value = structuredFilterRules.value.map((rule) => (columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] ?? "" }));
if (columns.length > 0 && structuredFilterRules.value.some((rule) => rule.columnName && !columns.includes(rule.columnName))) {
structuredFilterRules.value = structuredFilterRules.value.map((rule) => (!rule.columnName || columns.includes(rule.columnName) ? rule : { ...rule, columnName: columns[0] ?? "" }));
return;
}
persistStructuredFilterState();

View File

@ -134,6 +134,13 @@ async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open:
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
}
async function openFirstEmptyRuleColumnSearch() {
const rule = props.rules.find((item) => !item.columnName && !item.disabled);
if (rule) await handleColumnSelectOpen(rule, true);
}
defineExpose({ openFirstEmptyRuleColumnSearch });
function handleColumnCloseAutoFocus(id: string, event: Event) {
if (pendingKeyboardAddFocus.delete(id)) {
event.preventDefault();
@ -263,7 +270,7 @@ function blurValueRule(id: string) {
<div v-if="index > 0" class="flex justify-center">
<Button variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
</div>
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center justify-start gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,260px)_88px_auto]' : 'grid-cols-[minmax(0,210px)_88px_minmax(0,210px)_auto]'">
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center justify-start gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,260px)_92px_auto]' : 'grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]'">
<Select :model-value="rule.columnName" :open="openColumnSelectIds.has(rule.id)" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule, value)" @update:open="(open: boolean) => handleColumnSelectOpen(rule, open)">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from "vue";
import { Filter, Trash2 } from "@lucide/vue";
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { Filter, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -61,6 +61,9 @@ const emit = defineEmits<{
const { t } = useI18n();
const containerRef = ref<HTMLDivElement>();
const filterBuilderRef = ref<InstanceType<typeof DataGridFilterBuilder>>();
const pendingFirstEmptyRuleColumnSearch = ref(false);
let openingFirstEmptyRuleColumnSearch = false;
const whereWidth = ref<number | null>(null);
const resizing = ref(false);
let resizeStartX = 0;
@ -112,6 +115,32 @@ function clearWhere() {
emit("clearFilters");
}
async function openPendingFirstEmptyRuleColumnSearch() {
if (openingFirstEmptyRuleColumnSearch || !pendingFirstEmptyRuleColumnSearch.value || !props.filterBuilderOpen || !filterBuilderRef.value) return;
if (!props.rules.some((rule) => !rule.columnName && !rule.disabled)) return;
openingFirstEmptyRuleColumnSearch = true;
await nextTick();
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
try {
if (!pendingFirstEmptyRuleColumnSearch.value || !props.filterBuilderOpen || !filterBuilderRef.value) return;
pendingFirstEmptyRuleColumnSearch.value = false;
await filterBuilderRef.value.openFirstEmptyRuleColumnSearch();
} finally {
openingFirstEmptyRuleColumnSearch = false;
}
}
async function handleFilterButtonClick() {
const shouldFocusColumnSearch = !props.filterBuilderOpen && props.rules.every((rule) => !rule.columnName);
pendingFirstEmptyRuleColumnSearch.value = shouldFocusColumnSearch;
emit("ensureRule");
if (!shouldFocusColumnSearch) return;
await nextTick();
await openPendingFirstEmptyRuleColumnSearch();
}
watch([() => props.filterBuilderOpen, () => props.rules.map((rule) => `${rule.id}:${rule.columnName}:${rule.disabled ? "1" : "0"}`).join("\u0000"), filterBuilderRef], () => void openPendingFirstEmptyRuleColumnSearch(), { flush: "post" });
onUnmounted(onResizeEnd);
</script>
@ -125,7 +154,7 @@ onUnmounted(onResizeEnd);
class="relative flex h-5 w-5 -translate-x-1 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
:class="filterButtonActive ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:disabled="!canUseWhereSearch"
@click="emit('ensureRule')"
@click="handleFilterButtonClick"
>
<Filter class="h-3 w-3" />
<span v-if="filterButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ filterButtonCount }}</span>
@ -159,6 +188,7 @@ onUnmounted(onResizeEnd);
</div>
<DataGridFilterBuilder
ref="filterBuilderRef"
:rules="rules"
:columns="[...columns]"
:filtered-columns="filteredColumns"

View File

@ -174,6 +174,23 @@ describe("DataGridConditionEditor quote completion", () => {
expect(document.querySelector('[role="option"][aria-selected="true"]')?.textContent).toContain("name");
});
it("keeps suggestions closed after Enter applies a complete condition", async () => {
const { input } = mountEditor("where", "", { columns: ["id", "order0", "status"] });
input.focus();
input.value = "id > 0";
input.setSelectionRange(6, 6);
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await nextTick();
input.setSelectionRange(0, 0);
input.dispatchEvent(new Event("select", { bubbles: true }));
await nextTick();
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it("keeps expanded input first-line indent and wraps long tokens", () => {
const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8");
const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?<body>[\s\S]*?)\n\}/)?.groups?.body;

View File

@ -387,6 +387,21 @@ describe("DataGridColumnHeader", () => {
});
describe("DataGridFilterBuilder", () => {
it("opens the first empty rule column search on request", async () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
await mounted.exposed.value.openFirstEmptyRuleColumnSearch();
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
expect(columnSelect.props.open).toBe(true);
});
it("keeps selected columns and values readable without stretching the controls", () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "appointmentStatusWithAnExceptionallyLongName", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
@ -400,7 +415,7 @@ describe("DataGridFilterBuilder", () => {
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_88px_minmax(0,210px)_auto]"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
@ -414,7 +429,7 @@ describe("DataGridFilterBuilder", () => {
expect(items.every((item) => String(item.props.class).includes("rounded-none"))).toBe(true);
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_88px_minmax(0,210px)_auto]");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]");
expect(String(ruleGrid.props.class)).toContain("justify-start");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
@ -616,6 +631,88 @@ describe("DataGridFilterBuilder", () => {
});
describe("DataGridQueryControls", () => {
it("opens column search when the filter button creates the first rule", async () => {
let mounted: ReturnType<typeof mountComponent>;
const firstRule = { id: "r1", columnName: "", mode: "equals" as const, rawValue: "", rawEndValue: "", conjunction: "AND" as const };
const ensureRule = vi.fn(() => {
void mounted.setProps({ rules: [firstRule], filterBuilderOpen: true });
});
mounted = mountComponent(DataGridQueryControls, {
whereInput: "",
orderByInput: "",
columns: ["id"],
conditionColumns: ["id"],
historyScope: {},
canUseWhereSearch: true,
compact: false,
leadingBorder: false,
filterBuilderOpen: false,
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
rules: [],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
onEnsureRule: ensureRule,
});
const filterButton = findOne(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
dispatch(filterButton, "click");
await nextTick();
await nextTick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await nextTick();
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
expect(ensureRule).toHaveBeenCalledOnce();
expect(columnSelect.props.open).toBe(true);
});
it("does not open column search when filter rules already exist", async () => {
let mounted: ReturnType<typeof mountComponent>;
const ensureRule = vi.fn(() => {
void mounted.setProps({ filterBuilderOpen: true });
});
mounted = mountComponent(DataGridQueryControls, {
whereInput: "id = 1",
orderByInput: "",
columns: ["id"],
conditionColumns: ["id"],
historyScope: {},
canUseWhereSearch: true,
compact: false,
leadingBorder: false,
filterBuilderOpen: false,
filterButtonActive: true,
filterButtonCount: 1,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
onEnsureRule: ensureRule,
});
const filterButton = findOne(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
dispatch(filterButton, "click");
await nextTick();
const columnSelect = findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[0];
expect(ensureRule).toHaveBeenCalledOnce();
expect(columnSelect.props.open).toBe(false);
});
it("gives filter rules enough horizontal space for longer column names", () => {
const mounted = mountComponent(DataGridQueryControls, {
whereInput: "",

View File

@ -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");

View File

@ -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')");

View File

@ -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);
},

View File

@ -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] }));
},
);

View File

@ -1311,7 +1311,9 @@ export default {
filterBuilderContains: "Contains",
filterBuilderNotContains: "Does not contain",
filterBuilderGreaterThan: "Greater than",
filterBuilderGreaterThanOrEqual: "Greater than or equal to",
filterBuilderLessThan: "Less than",
filterBuilderLessThanOrEqual: "Less than or equal to",
filterBuilderIn: "In list",
filterBuilderNotIn: "Not in list",
filterBuilderBetween: "Within range",

View File

@ -1203,7 +1203,9 @@ export default withEnglishFallback({
filterBuilderContains: "Contiene",
filterBuilderNotContains: "No contiene",
filterBuilderGreaterThan: "Mayor que",
filterBuilderGreaterThanOrEqual: "Mayor o igual que",
filterBuilderLessThan: "Menor que",
filterBuilderLessThanOrEqual: "Menor o igual que",
filterBuilderIn: "En lista",
filterBuilderNotIn: "Fuera de lista",
filterBuilderBetween: "En rango",

View File

@ -1201,7 +1201,9 @@ export default withEnglishFallback({
filterBuilderContains: "Contiene",
filterBuilderNotContains: "Non contiene",
filterBuilderGreaterThan: "Maggiore di",
filterBuilderGreaterThanOrEqual: "Maggiore o uguale a",
filterBuilderLessThan: "Minore di",
filterBuilderLessThanOrEqual: "Minore o uguale a",
filterBuilderIn: "Nell'elenco",
filterBuilderNotIn: "Fuori elenco",
filterBuilderBetween: "Nell'intervallo",

View File

@ -1219,7 +1219,9 @@ export default withEnglishFallback({
filterBuilderContains: "含む",
filterBuilderNotContains: "含まない",
filterBuilderGreaterThan: "より大きい",
filterBuilderGreaterThanOrEqual: "以上",
filterBuilderLessThan: "より小さい",
filterBuilderLessThanOrEqual: "以下",
filterBuilderIn: "一覧内",
filterBuilderNotIn: "一覧外",
filterBuilderBetween: "範囲内",

View File

@ -1215,7 +1215,9 @@ export default withEnglishFallback({
filterBuilderContains: "포함",
filterBuilderNotContains: "포함하지 않음",
filterBuilderGreaterThan: "보다 큼",
filterBuilderGreaterThanOrEqual: "크거나 같음",
filterBuilderLessThan: "보다 작음",
filterBuilderLessThanOrEqual: "작거나 같음",
filterBuilderIn: "목록 내",
filterBuilderNotIn: "목록 외",
filterBuilderBetween: "범위 내",

View File

@ -1203,7 +1203,9 @@ export default withEnglishFallback({
filterBuilderContains: "Contém",
filterBuilderNotContains: "Não contém",
filterBuilderGreaterThan: "Maior que",
filterBuilderGreaterThanOrEqual: "Maior ou igual a",
filterBuilderLessThan: "Menor que",
filterBuilderLessThanOrEqual: "Menor ou igual a",
filterBuilderIn: "Na lista",
filterBuilderNotIn: "Fora da lista",
filterBuilderBetween: "No intervalo",

View File

@ -1311,7 +1311,9 @@ export default withEnglishFallback({
filterBuilderContains: "包含",
filterBuilderNotContains: "不包含",
filterBuilderGreaterThan: "大于",
filterBuilderGreaterThanOrEqual: "大于等于",
filterBuilderLessThan: "小于",
filterBuilderLessThanOrEqual: "小于等于",
filterBuilderIn: "列表内",
filterBuilderNotIn: "列表外",
filterBuilderBetween: "介于",

View File

@ -1202,7 +1202,9 @@ export default withEnglishFallback({
filterBuilderContains: "包含",
filterBuilderNotContains: "不包含",
filterBuilderGreaterThan: "大於",
filterBuilderGreaterThanOrEqual: "大於等於",
filterBuilderLessThan: "小於",
filterBuilderLessThanOrEqual: "小於等於",
filterBuilderIn: "清單內",
filterBuilderNotIn: "清單外",
filterBuilderBetween: "介於",

View File

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildDocumentFilterCondition, documentFilterModeOptions } from "@/lib/app/documentStoreProvider";
describe("document store structured filters", () => {
it("offers and builds inclusive comparison filters", () => {
expect(documentFilterModeOptions.map((option) => option.value)).toEqual(expect.arrayContaining(["greater-than-or-equal", "less-than-or-equal"]));
expect(buildDocumentFilterCondition({ id: "gte", fieldName: "score", mode: "greater-than-or-equal", rawValue: "80", conjunction: "AND" })).toEqual({ score: { $gte: 80 } });
expect(buildDocumentFilterCondition({ id: "lte", fieldName: "score", mode: "less-than-or-equal", rawValue: "80", conjunction: "AND" })).toEqual({ score: { $lte: 80 } });
});
});

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup";
describe("data grid column lookup search", () => {
const items = buildDataGridColumnLookupItems({
columns: ["userProfile", "order_id", "created_at"],
});
it("matches camel-case initials", () => {
expect(filterDataGridColumnLookupItems(items, "up").map((item) => item.name)).toEqual(["userProfile"]);
});
it("matches text from any position", () => {
expect(filterDataGridColumnLookupItems(items, "id").map((item) => item.name)).toEqual(["order_id"]);
});
it("returns all columns for an empty query", () => {
expect(filterDataGridColumnLookupItems(items, " ")).toEqual(items);
});
});

View File

@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
import { containsHan, matchesPinyinInitials, orderedSubsequenceSpan, pinyinAwareMatchScore, pinyinFirstLetters } from "@/lib/common/pinyin";
import { completionMatchRanges } from "@/lib/common/completionMatch";
import { buildSqlCompletionItems } from "@/lib/sql/sqlCompletion";
import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
describe("identifier search", () => {
it("matches camel-case initials and ranks them above loose fuzzy matches", () => {
expect(matchesIdentifierSearch("userProfile", "up")).toBe(true);
expect(identifierMatchScore("userProfile", "up")).toBeGreaterThan(identifierMatchScore("userPreference", "up"));
});
it("matches an identifier from any position", () => {
expect(matchesIdentifierSearch("order_id", "id")).toBe(true);
expect(matchesIdentifierSearch("created_at", "id")).toBe(false);
expect(matchesIdentifierSearch("customer_order_total", "cot")).toBe(true);
});
it("keeps unrelated identifiers out of the result", () => {
expect(matchesIdentifierSearch("userProfile", "xyz")).toBe(false);
});
});
describe("pinyinAwareMatchScore", () => {
it("ranks prefix above pinyin prefix above substring above pinyin subsequence", () => {
@ -127,3 +145,35 @@ describe("sqlCompletion Chinese column matching", () => {
expect(items.some((item) => item.label === "总租金")).toBe(false);
});
});
describe("sqlCompletion identifier column matching", () => {
function completionItems(typedPrefix: string) {
const sql = `SELECT * FROM orders WHERE ${typedPrefix}`;
return buildSqlCompletionItems(sql, sql.length, {
databaseType: "mysql",
tables: [{ name: "orders", type: "table" }],
columnsByTable: new Map([
[
"orders",
[
{ name: "userProfile", table: "orders" },
{ name: "order_id", table: "orders" },
{ name: "created_at", table: "orders" },
],
],
]),
});
}
it("matches camel-case initials in WHERE column completion", () => {
const items = completionItems("up");
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "userProfile", type: "column" })]));
expect(items.some((item) => item.label === "order_id")).toBe(false);
});
it("matches WHERE columns from any position", () => {
const items = completionItems("id");
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "order_id", type: "column" })]));
expect(items.some((item) => item.label === "created_at")).toBe(false);
});
});

View File

@ -5,7 +5,7 @@ import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "elasticsearch";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "less-than" | "is-null" | "is-not-null";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
export type ElasticsearchQueryType = "term" | "terms" | "match" | "match_phrase" | "wildcard" | "range_gt" | "range_gte" | "range_lt" | "range_lte" | "exists";
@ -55,7 +55,9 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
{ value: "like", labelKey: "grid.filterBuilderContains" },
{ value: "not-like", labelKey: "grid.filterBuilderNotContains" },
{ value: "greater-than", labelKey: "grid.filterBuilderGreaterThan" },
{ value: "greater-than-or-equal", labelKey: "grid.filterBuilderGreaterThanOrEqual" },
{ value: "less-than", labelKey: "grid.filterBuilderLessThan" },
{ value: "less-than-or-equal", labelKey: "grid.filterBuilderLessThanOrEqual" },
{ value: "is-null", labelKey: "grid.filterBuilderIsNull" },
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
];
@ -435,8 +437,12 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } };
case "greater-than":
return { [rule.fieldName]: { $gt: value } };
case "greater-than-or-equal":
return { [rule.fieldName]: { $gte: value } };
case "less-than":
return { [rule.fieldName]: { $lt: value } };
case "less-than-or-equal":
return { [rule.fieldName]: { $lte: value } };
case "is-null":
return { [rule.fieldName]: null };
case "is-not-null":

View File

@ -1,3 +1,5 @@
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
export interface DataGridColumnLookupItem {
index: number;
name: string;
@ -13,7 +15,7 @@ export interface DataGridColumnLookupOptions {
}
function normalizedSearchText(value: string): string {
return value.trim().toLocaleLowerCase();
return value.trim();
}
function nonEmptyComment(value: string | undefined): string | undefined {
@ -49,5 +51,5 @@ export function buildDataGridColumnLookupItems(options: DataGridColumnLookupOpti
export function filterDataGridColumnLookupItems<ColumnLookupItem extends DataGridColumnLookupItem>(items: readonly ColumnLookupItem[], query: string): ColumnLookupItem[] {
const normalizedQuery = normalizedSearchText(query);
if (!normalizedQuery) return [...items];
return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => value.toLocaleLowerCase().includes(normalizedQuery)));
return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => matchesIdentifierSearch(value, normalizedQuery)));
}

View File

@ -56,7 +56,7 @@ export interface DataGridCopyInsertStatementOptions {
insertMode?: DataGridCopyInsertMode;
}
export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "greater-than" | "in" | "not-in" | "between" | "not-between";
export type DataGridContextFilterMode = "equals" | "not-equals" | "is-null" | "is-not-null" | "like" | "not-like" | "less-than" | "less-than-or-equal" | "greater-than" | "greater-than-or-equal" | "in" | "not-in" | "between" | "not-between";
export interface DataGridContextFilterConditionOptions {
databaseType?: DatabaseType;

View File

@ -0,0 +1,82 @@
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
/**
* Scores identifier matches for completion and field pickers.
* Higher scores are better; -1 means there is no match.
*/
export function identifierMatchScore(candidate: string, query: string): number {
if (!query) return 1;
const candidateLower = candidate.toLowerCase();
const queryLower = query.toLowerCase();
if (candidateLower === queryLower) return 3000 - candidateLower.length;
if (candidateLower.startsWith(queryLower)) return 2000 - candidateLower.length;
const initials = identifierInitials(candidate);
if (initials && initials.startsWith(queryLower)) {
const exactInitialsBonus = initials === queryLower ? 400 : 0;
return 2400 + exactInitialsBonus - candidateLower.length;
}
if (/^[a-z0-9]+$/.test(queryLower) && containsHan(candidateLower)) {
const pinyinInitials = pinyinFirstLetters(candidateLower);
if (pinyinInitials.startsWith(queryLower)) {
const exactInitialsBonus = pinyinInitials === queryLower ? 300 : 0;
return 2300 + exactInitialsBonus - candidateLower.length;
}
const subsequence = orderedSubsequenceSpan(pinyinInitials, queryLower);
if (subsequence) {
return 1600 - subsequence.first * 30 - (subsequence.span - queryLower.length) * 10 - candidateLower.length;
}
}
const substringIndex = candidateLower.indexOf(queryLower);
if (substringIndex >= 0) {
const boundaryBonus = isIdentifierBoundary(candidate, substringIndex) ? 400 : Math.max(0, 180 - substringIndex * 12);
return 900 + boundaryBonus - candidateLower.length;
}
let candidateIndex = 0;
let totalGap = 0;
let firstMatchPosition = -1;
let boundaryBonus = 0;
for (const character of queryLower) {
const nextPosition = candidateLower.indexOf(character, candidateIndex);
if (nextPosition === -1) return -1;
if (firstMatchPosition === -1) firstMatchPosition = nextPosition;
if (isIdentifierBoundary(candidate, nextPosition)) boundaryBonus += 40;
totalGap += nextPosition - candidateIndex;
candidateIndex = nextPosition + 1;
}
const earlyMatchBonus = Math.max(0, 700 - firstMatchPosition * 35) + boundaryBonus;
if (totalGap >= queryLower.length) {
return 400 + earlyMatchBonus * 0.3 - totalGap * 20 - candidateLower.length;
}
return 1200 + earlyMatchBonus - totalGap * 10 - candidateLower.length;
}
export function matchesIdentifierSearch(candidate: string, query: string): boolean {
return identifierMatchScore(candidate, query.trim()) >= 0;
}
function identifierWords(candidate: string): string[] {
return candidate
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
function identifierInitials(candidate: string): string {
return identifierWords(candidate)
.map((part) => part[0])
.join("");
}
function isIdentifierBoundary(candidate: string, index: number): boolean {
if (index <= 0) return true;
const previous = candidate[index - 1] ?? "";
const current = candidate[index] ?? "";
return /[^A-Za-z0-9]/.test(previous) || (/[a-z0-9]/.test(previous) && /[A-Z]/.test(current));
}

View File

@ -10,6 +10,7 @@ import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/seman
import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types";
import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
@ -3635,16 +3636,17 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map<str
const relevanceBoost = context.referencedTables.length > 0 || !!context.qualifier || !!context.insertTable ? 2000 : 0;
return uniqueColumns
.filter((column) => matchesPrefix(column.displayLabel, context.prefix))
.filter((column) => matchesIdentifierSearch(column.name, context.prefix) || matchesIdentifierSearch(column.displayLabel, context.prefix))
.map((column) => {
const keyBoost = isKeyColumn(column.name) ? 500 : 0;
const matchScore = Math.max(identifierMatchScore(column.name, context.prefix), identifierMatchScore(column.displayLabel, context.prefix));
return {
label: column.displayLabel,
type: "column" as const,
detail: buildColumnDetail(column),
info: buildColumnInfo(column),
apply: buildColumnApply(column, context, dialect),
boost: computeBoost(column.displayLabel, context.prefix) + keyBoost + relevanceBoost,
boost: matchScore + keyBoost + relevanceBoost,
};
})
.sort(compareCompletionItems);
@ -3667,7 +3669,7 @@ function applyReferencedColumnAliases<T extends SqlCompletionColumn>(table: SqlC
function hasMatchingReferencedColumnPrefix(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>): boolean {
if (!context.suggestColumns || !context.prefix || context.referencedTables.length === 0) return false;
return context.referencedTables.some((table) => columnsForReferencedTable(table, columnsByTable).some((column) => matchesPrefix(column.name, context.prefix)));
return context.referencedTables.some((table) => columnsForReferencedTable(table, columnsByTable).some((column) => matchesIdentifierSearch(column.name, context.prefix)));
}
function qualifiedTableTargetFromContext(context: SqlCompletionContext): { database?: string; schema: string; table: string } | null {
@ -4230,7 +4232,7 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT
for (const col of cols) {
const key = col.name.toLowerCase();
if (!nonAggSet.has(key) || seen.has(key)) continue;
if (context.prefix && !matchesPrefix(col.name, context.prefix)) continue;
if (context.prefix && !matchesIdentifierSearch(col.name, context.prefix)) continue;
seen.add(key);
items.push({
label: col.name,

View File

@ -139,7 +139,9 @@ pub enum DataGridContextFilterMode {
Like,
NotLike,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
In,
NotIn,
Between,
@ -520,10 +522,18 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo
"{column} < {}",
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
)),
DataGridContextFilterMode::LessThanOrEqual => Some(format!(
"{column} <= {}",
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
)),
DataGridContextFilterMode::GreaterThan => Some(format!(
"{column} > {}",
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
)),
DataGridContextFilterMode::GreaterThanOrEqual => Some(format!(
"{column} >= {}",
format_grid_sql_literal(value, options.database_type, options.column_info.as_ref())
)),
DataGridContextFilterMode::In => build_data_grid_context_membership_filter_condition(
&column,
&options.values,
@ -3508,6 +3518,25 @@ mod tests {
.as_deref(),
Some("`file_name` = '34-B-0048'")
);
for (mode, expected) in [
(DataGridContextFilterMode::GreaterThanOrEqual, "`score` >= 80"),
(DataGridContextFilterMode::LessThanOrEqual, "`score` <= 80"),
] {
assert_eq!(
build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
column_name: "score".to_string(),
mode,
value: json!(80),
values: Vec::new(),
end_value: None,
column_info: Some(column("score", "int", false, None)),
})
.as_deref(),
Some(expected)
);
}
assert_eq!(
build_data_grid_column_value_filter_condition(DataGridColumnValueFilterConditionOptions {
database_type: Some(DatabaseType::Kingbase),