feat(grid): enhance field search and filter interaction
This commit is contained in:
parent
13878a1a30
commit
f9e93b356f
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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] }));
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1219,7 +1219,9 @@ export default withEnglishFallback({
|
|||
filterBuilderContains: "含む",
|
||||
filterBuilderNotContains: "含まない",
|
||||
filterBuilderGreaterThan: "より大きい",
|
||||
filterBuilderGreaterThanOrEqual: "以上",
|
||||
filterBuilderLessThan: "より小さい",
|
||||
filterBuilderLessThanOrEqual: "以下",
|
||||
filterBuilderIn: "一覧内",
|
||||
filterBuilderNotIn: "一覧外",
|
||||
filterBuilderBetween: "範囲内",
|
||||
|
|
|
|||
|
|
@ -1215,7 +1215,9 @@ export default withEnglishFallback({
|
|||
filterBuilderContains: "포함",
|
||||
filterBuilderNotContains: "포함하지 않음",
|
||||
filterBuilderGreaterThan: "보다 큼",
|
||||
filterBuilderGreaterThanOrEqual: "크거나 같음",
|
||||
filterBuilderLessThan: "보다 작음",
|
||||
filterBuilderLessThanOrEqual: "작거나 같음",
|
||||
filterBuilderIn: "목록 내",
|
||||
filterBuilderNotIn: "목록 외",
|
||||
filterBuilderBetween: "범위 내",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1311,7 +1311,9 @@ export default withEnglishFallback({
|
|||
filterBuilderContains: "包含",
|
||||
filterBuilderNotContains: "不包含",
|
||||
filterBuilderGreaterThan: "大于",
|
||||
filterBuilderGreaterThanOrEqual: "大于等于",
|
||||
filterBuilderLessThan: "小于",
|
||||
filterBuilderLessThanOrEqual: "小于等于",
|
||||
filterBuilderIn: "列表内",
|
||||
filterBuilderNotIn: "列表外",
|
||||
filterBuilderBetween: "介于",
|
||||
|
|
|
|||
|
|
@ -1202,7 +1202,9 @@ export default withEnglishFallback({
|
|||
filterBuilderContains: "包含",
|
||||
filterBuilderNotContains: "不包含",
|
||||
filterBuilderGreaterThan: "大於",
|
||||
filterBuilderGreaterThanOrEqual: "大於等於",
|
||||
filterBuilderLessThan: "小於",
|
||||
filterBuilderLessThanOrEqual: "小於等於",
|
||||
filterBuilderIn: "清單內",
|
||||
filterBuilderNotIn: "清單外",
|
||||
filterBuilderBetween: "介於",
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue