feat(elasticsearch): add searchable field picker
This commit is contained in:
parent
e1d240e502
commit
9173cb754d
|
|
@ -34,10 +34,12 @@ import {
|
|||
documentFilterModeOptions,
|
||||
documentStoreProviderFor,
|
||||
elasticsearchBoolClauseOptions,
|
||||
elasticsearchFieldPathTreeFromFieldNames,
|
||||
elasticsearchQueryTypeNeedsValue,
|
||||
elasticsearchQueryTypeOptions,
|
||||
elasticsearchStructuredFilter,
|
||||
formatDocumentQueryInput,
|
||||
searchElasticsearchFieldPathTree,
|
||||
type DocumentFieldPathNode,
|
||||
type DocumentFilterMode,
|
||||
type DocumentFilterRule,
|
||||
|
|
@ -254,7 +256,15 @@ const gridResult = computed<QueryResult>(() => {
|
|||
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
||||
});
|
||||
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
|
||||
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
|
||||
const elasticsearchFilterFieldNames = computed(() => {
|
||||
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
});
|
||||
const documentFilterFieldTree = computed<DocumentFieldPathNode[]>(() => {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
return elasticsearchFieldPathTreeFromFieldNames(elasticsearchFilterFieldNames.value, elasticsearchFieldTypes.value);
|
||||
}
|
||||
const tree = documentFieldPathTreeFromDocuments(documents.value);
|
||||
if (tree.length > 0) return tree;
|
||||
return gridResult.value.columns.map((column) => ({
|
||||
|
|
@ -269,15 +279,15 @@ const documentFilterFieldTree = computed<DocumentFieldPathNode[]>(() => {
|
|||
});
|
||||
const documentFilterFieldOptions = computed(() => {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
return flattenDocumentFieldPathTree(documentFilterFieldTree.value)
|
||||
.filter((field) => field.selectable)
|
||||
.map((field) => field.path);
|
||||
}
|
||||
const nestedFields = documentFieldPathOptionsFromDocuments(documents.value);
|
||||
return nestedFields.length > 0 ? nestedFields : gridResult.value.columns;
|
||||
});
|
||||
const documentFilterFieldRows = computed<DocumentFilterFieldTreeRow[]>(() => visibleDocumentFilterFieldRows(documentFilterFieldTree.value));
|
||||
const documentFilterFieldByPath = computed(() => new Map(flattenDocumentFieldPathTree(documentFilterFieldTree.value).map((node) => [node.path, node])));
|
||||
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
|
||||
const documentStructuredFilterCount = computed(() => {
|
||||
if (!appliedDocumentFilter.value) return 0;
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") return 1;
|
||||
|
|
@ -336,6 +346,9 @@ function setDocumentFilterFieldPopoverOpen(ruleId: string, open: boolean) {
|
|||
if (open) next[ruleId] = true;
|
||||
else delete next[ruleId];
|
||||
documentFilterFieldPopoverOpen.value = next;
|
||||
if (open) {
|
||||
void nextTick(() => document.getElementById(documentFilterFieldSearchInputId(ruleId))?.focus());
|
||||
}
|
||||
if (!open) {
|
||||
const search = { ...documentFilterFieldSearch.value };
|
||||
delete search[ruleId];
|
||||
|
|
@ -343,6 +356,10 @@ function setDocumentFilterFieldPopoverOpen(ruleId: string, open: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
function documentFilterFieldSearchInputId(ruleId: string): string {
|
||||
return `document-filter-field-search-${ruleId}`;
|
||||
}
|
||||
|
||||
function documentFilterFieldSearchActive(ruleId: string): boolean {
|
||||
return !!documentFilterFieldSearch.value[ruleId]?.trim();
|
||||
}
|
||||
|
|
@ -350,7 +367,8 @@ function documentFilterFieldSearchActive(ruleId: string): boolean {
|
|||
function documentFilterFieldRowsForRule(ruleId: string): DocumentFilterFieldTreeRow[] {
|
||||
const query = documentFilterFieldSearch.value[ruleId] ?? "";
|
||||
if (!query.trim()) return documentFilterFieldRows.value;
|
||||
return searchDocumentFieldPathTree(documentFilterFieldTree.value, query).map((node) => ({ ...node, depth: 0 }));
|
||||
const matchingFields = documentStoreProvider.value.kind === "elasticsearch" ? searchElasticsearchFieldPathTree(documentFilterFieldTree.value, query) : searchDocumentFieldPathTree(documentFilterFieldTree.value, query);
|
||||
return matchingFields.map((node) => ({ ...node, depth: 0 }));
|
||||
}
|
||||
|
||||
function selectDocumentFilterField(ruleId: string, fieldName: string) {
|
||||
|
|
@ -359,7 +377,11 @@ function selectDocumentFilterField(ruleId: string, fieldName: string) {
|
|||
}
|
||||
|
||||
function documentFilterFieldLabel(path: string): string {
|
||||
return documentFilterFieldByPath.value.get(path)?.displayPath ?? path;
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") {
|
||||
return documentFilterFieldByPath.value.get(path)?.displayPath ?? path;
|
||||
}
|
||||
const fieldType = elasticsearchFieldTypes.value.get(path);
|
||||
return fieldType ? `${path} (${fieldType})` : path;
|
||||
}
|
||||
|
||||
function documentFilterFieldKindLabel(kind: DocumentFieldPathNode["kind"]): string {
|
||||
|
|
@ -1684,7 +1706,7 @@ defineExpose({ focusSearch });
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Popover v-if="documentStoreProvider.kind !== 'elasticsearch'" :open="!!documentFilterFieldPopoverOpen[rule.id]" @update:open="(open) => setDocumentFilterFieldPopoverOpen(rule.id, open)">
|
||||
<Popover :open="!!documentFilterFieldPopoverOpen[rule.id]" @update:open="(open) => setDocumentFilterFieldPopoverOpen(rule.id, open)">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="flex h-8 w-full min-w-0 items-center justify-between gap-1 rounded-md border bg-background px-2 text-left text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||
<span class="min-w-0 truncate font-mono" :title="documentFilterFieldLabel(rule.fieldName)">{{ documentFilterFieldLabel(rule.fieldName) || t("grid.filterBuilderColumn") }}</span>
|
||||
|
|
@ -1695,7 +1717,7 @@ defineExpose({ focusSearch });
|
|||
<div class="border-b bg-muted/40 px-2 py-1.5 text-xs font-medium text-foreground">{{ t("grid.filterBuilderColumn") }}</div>
|
||||
<div class="relative border-b p-2">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="documentFilterFieldSearch[rule.id]" autofocus class="h-7 pl-7 text-xs" :placeholder="t('grid.filterBuilderSearchColumns')" />
|
||||
<Input :id="documentFilterFieldSearchInputId(rule.id)" v-model="documentFilterFieldSearch[rule.id]" autofocus class="h-7 pl-7 text-xs" :placeholder="t('grid.filterBuilderSearchColumns')" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-1">
|
||||
<div v-for="field in documentFilterFieldRowsForRule(rule.id)" :key="field.path" class="flex items-center gap-1 px-1.5">
|
||||
|
|
@ -1709,9 +1731,20 @@ defineExpose({ focusSearch });
|
|||
<ChevronRight v-if="!expandedDocumentFilterFieldPaths.has(field.path)" class="h-3.5 w-3.5" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 text-left text-xs hover:bg-accent" :class="rule.fieldName === field.path ? 'bg-accent text-foreground' : ''" @click="selectDocumentFilterField(rule.id, field.path)">
|
||||
<span class="min-w-0 flex-1 truncate font-mono" :title="field.displayPath">{{ documentFilterFieldSearchActive(rule.id) ? field.displayPath : field.label }}</span>
|
||||
<span v-if="field.kind !== 'scalar'" class="shrink-0 rounded border px-1 py-0 text-[10px] leading-4 text-muted-foreground">{{ documentFilterFieldKindLabel(field.kind) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 text-left text-xs hover:bg-accent disabled:cursor-default disabled:text-muted-foreground disabled:hover:bg-transparent"
|
||||
:class="rule.fieldName === field.path ? 'bg-accent text-foreground' : ''"
|
||||
:disabled="!field.selectable"
|
||||
@click="selectDocumentFilterField(rule.id, field.path)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-mono" :title="documentStoreProvider.kind === 'elasticsearch' ? field.path : field.displayPath">
|
||||
{{ documentFilterFieldSearchActive(rule.id) ? (documentStoreProvider.kind === "elasticsearch" ? field.path : field.displayPath) : field.label }}
|
||||
</span>
|
||||
<span v-if="documentStoreProvider.kind === 'elasticsearch' && field.selectable && elasticsearchFieldTypes.get(field.path)" class="shrink-0 text-[10px] text-muted-foreground"> ({{ elasticsearchFieldTypes.get(field.path) }}) </span>
|
||||
<span v-else-if="documentStoreProvider.kind !== 'elasticsearch' && field.kind !== 'scalar'" class="shrink-0 rounded border px-1 py-0 text-[10px] leading-4 text-muted-foreground">
|
||||
{{ documentFilterFieldKindLabel(field.kind) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="documentFilterFieldRowsForRule(rule.id).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
|
|
@ -1721,20 +1754,6 @@ defineExpose({ focusSearch });
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<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 :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate">{{ fieldName }}</span>
|
||||
<span v-if="elasticsearchFieldTypes.get(fieldName)" class="shrink-0 text-[10px] text-muted-foreground">({{ elasticsearchFieldTypes.get(fieldName) }})</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchQueryType || elasticsearchRuleQueryTypes(rule)[0]" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchQueryType: value as ElasticsearchQueryType })">
|
||||
<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 />
|
||||
|
|
|
|||
289
apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts
vendored
Normal file
289
apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App, type ComputedRef, type InjectionKey } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
getColumns: vi.fn(),
|
||||
documentFindDocuments: vi.fn(),
|
||||
cancelQuery: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
getColumns: backend.getColumns,
|
||||
documentFindDocuments: backend.documentFindDocuments,
|
||||
cancelQuery: backend.cancelQuery,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: backend.ensureConnected,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
pageSize: 100,
|
||||
mongoViewMode: "table",
|
||||
},
|
||||
updateEditorSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: "DataGridStub",
|
||||
inheritAttrs: false,
|
||||
setup(_, { expose, slots }) {
|
||||
expose({
|
||||
nullColumnsHidden: false,
|
||||
canToggleAllNullColumns: false,
|
||||
allNullColumnCount: 0,
|
||||
toggleAllNullColumns: vi.fn(),
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{ "data-testid": "data-grid" },
|
||||
slots["search-bar"]?.({
|
||||
localFilterCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterSummaries: [],
|
||||
clearLocalFilter: vi.fn(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { computed, defineComponent, h, inject, provide } = await import("vue");
|
||||
type PopoverContext = {
|
||||
open: ComputedRef<boolean>;
|
||||
setOpen(open: boolean): void;
|
||||
};
|
||||
const popoverContextKey: InjectionKey<PopoverContext> = Symbol("popover");
|
||||
|
||||
const Popover = defineComponent({
|
||||
name: "PopoverStub",
|
||||
props: {
|
||||
open: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["update:open"],
|
||||
setup(props, { emit, slots }) {
|
||||
const open = computed(() => props.open);
|
||||
provide(popoverContextKey, {
|
||||
open,
|
||||
setOpen: (nextOpen) => emit("update:open", nextOpen),
|
||||
});
|
||||
return () => h("div", { "data-testid": "popover" }, slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
const PopoverTrigger = defineComponent({
|
||||
name: "PopoverTriggerStub",
|
||||
setup(_, { slots }) {
|
||||
const context = inject(popoverContextKey);
|
||||
return () =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
"data-testid": "popover-trigger",
|
||||
onClick: () => context?.setOpen(!context.open.value),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const PopoverContent = defineComponent({
|
||||
name: "PopoverContentStub",
|
||||
setup(_, { slots }) {
|
||||
const context = inject(popoverContextKey);
|
||||
return () => (context?.open.value ? h("div", { "data-testid": "popover-content" }, slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
|
||||
return { Popover, PopoverTrigger, PopoverContent };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Select = defineComponent({
|
||||
name: "SelectStub",
|
||||
props: {
|
||||
modelValue: { type: String, default: "" },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue }, slots.default?.());
|
||||
},
|
||||
});
|
||||
const passthrough = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
return {
|
||||
Select,
|
||||
SelectContent: passthrough("SelectContentStub"),
|
||||
SelectItem: passthrough("SelectItemStub"),
|
||||
SelectTrigger: passthrough("SelectTriggerStub"),
|
||||
SelectValue: passthrough("SelectValueStub"),
|
||||
};
|
||||
});
|
||||
|
||||
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function buttonWithTitle(title: string): HTMLButtonElement {
|
||||
const button = document.body.querySelector<HTMLElement>(`[title="${title}"]`)?.closest<HTMLButtonElement>("button") ?? null;
|
||||
expect(button).not.toBeNull();
|
||||
return button!;
|
||||
}
|
||||
|
||||
function buttonWithText(text: string): HTMLButtonElement {
|
||||
const button = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((candidate) => candidate.textContent?.replace(/\s+/g, " ").trim() === text);
|
||||
expect(button).toBeDefined();
|
||||
return button!;
|
||||
}
|
||||
|
||||
function fieldTriggerButtons(title: string): HTMLButtonElement[] {
|
||||
return [...document.body.querySelectorAll<HTMLElement>(`[title="${title}"]`)].map((label) => label.closest<HTMLButtonElement>("button")!);
|
||||
}
|
||||
|
||||
async function setSearchInput(value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]');
|
||||
expect(input).not.toBeNull();
|
||||
input!.value = value;
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
return input!;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
backend.getColumns.mockReset();
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.cancelQuery.mockReset();
|
||||
backend.ensureConnected.mockReset();
|
||||
backend.ensureConnected.mockResolvedValue(undefined);
|
||||
backend.getColumns.mockResolvedValue([
|
||||
{ name: "buyers", data_type: "nested" },
|
||||
{ name: "buyers.email", data_type: "text" },
|
||||
{ name: "buyers.email.keyword", data_type: "keyword" },
|
||||
{ name: "title", data_type: "text" },
|
||||
{ name: "title.keyword", data_type: "keyword" },
|
||||
]);
|
||||
backend.documentFindDocuments.mockResolvedValue({
|
||||
documents: [{ _id: "document-1", title: "Example" }],
|
||||
raw_documents: [],
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
});
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "connection-1",
|
||||
database: "",
|
||||
collection: "orders",
|
||||
databaseType: "elasticsearch",
|
||||
});
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("DocumentBrowser Elasticsearch field search", () => {
|
||||
it("searches, selects, updates the query type, and clears the search when closed", async () => {
|
||||
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
|
||||
await flushUi();
|
||||
|
||||
const initialFieldTrigger = buttonWithTitle("buyers.email (text)");
|
||||
expect(document.body.querySelector('[data-testid="select"][data-model-value="match"]')).not.toBeNull();
|
||||
initialFieldTrigger.click();
|
||||
await flushUi();
|
||||
|
||||
const focusedSearch = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]');
|
||||
expect(document.activeElement).toBe(focusedSearch);
|
||||
expect(buttonWithText("buyers").disabled).toBe(true);
|
||||
|
||||
await setSearchInput("missing.field");
|
||||
expect(document.body.textContent).toContain("grid.noSearchResults");
|
||||
|
||||
await setSearchInput(" BUYERS.EMAIL.KEYWORD ");
|
||||
const resultButton = buttonWithText("buyers.email.keyword (keyword)");
|
||||
expect(document.body.textContent).not.toContain("title.keyword (keyword)");
|
||||
|
||||
resultButton.click();
|
||||
await flushUi();
|
||||
|
||||
const selectedFieldTrigger = buttonWithTitle("buyers.email.keyword (keyword)");
|
||||
expect(document.body.querySelector('[data-testid="select"][data-model-value="term"]')).not.toBeNull();
|
||||
|
||||
selectedFieldTrigger.click();
|
||||
await flushUi();
|
||||
const reopenedSearch = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]');
|
||||
expect(reopenedSearch?.value).toBe("");
|
||||
});
|
||||
|
||||
it("clears each rule search independently when its field popover closes", async () => {
|
||||
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
|
||||
await flushUi();
|
||||
buttonWithText("grid.filterBuilderAddRule").click();
|
||||
await flushUi();
|
||||
|
||||
const fieldTriggers = fieldTriggerButtons("buyers.email (text)");
|
||||
expect(fieldTriggers).toHaveLength(2);
|
||||
|
||||
fieldTriggers[0].click();
|
||||
await flushUi();
|
||||
await setSearchInput("title");
|
||||
fieldTriggers[0].click();
|
||||
await flushUi();
|
||||
|
||||
fieldTriggers[1].click();
|
||||
await flushUi();
|
||||
const secondSearch = await setSearchInput("keyword");
|
||||
expect(secondSearch.value).toBe("keyword");
|
||||
fieldTriggers[1].click();
|
||||
await flushUi();
|
||||
|
||||
fieldTriggers[0].click();
|
||||
await flushUi();
|
||||
expect(document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]')?.value).toBe("");
|
||||
fieldTriggers[0].click();
|
||||
await flushUi();
|
||||
|
||||
fieldTriggers[1].click();
|
||||
await flushUi();
|
||||
expect(document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]')?.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -142,10 +142,79 @@ type DocumentFieldPathAccumulatorNode = {
|
|||
childByKey: Map<string, DocumentFieldPathAccumulatorNode>;
|
||||
};
|
||||
|
||||
type ElasticsearchFieldPathAccumulatorNode = {
|
||||
key: string;
|
||||
path: string;
|
||||
selectable: boolean;
|
||||
children: ElasticsearchFieldPathAccumulatorNode[];
|
||||
childByKey: Map<string, ElasticsearchFieldPathAccumulatorNode>;
|
||||
};
|
||||
|
||||
export function documentFieldPathOptionsFromDocuments(documents: readonly Record<string, unknown>[]): string[] {
|
||||
return flattenDocumentFieldPathTree(documentFieldPathTreeFromDocuments(documents)).map((node) => node.path);
|
||||
}
|
||||
|
||||
export function elasticsearchFieldPathTreeFromFieldNames(fieldNames: readonly string[], fieldTypes: ReadonlyMap<string, string> = new Map()): DocumentFieldPathNode[] {
|
||||
const rootNodes: ElasticsearchFieldPathAccumulatorNode[] = [];
|
||||
const rootByKey = new Map<string, ElasticsearchFieldPathAccumulatorNode>();
|
||||
|
||||
for (const fieldName of fieldNames) {
|
||||
appendElasticsearchFieldPath(rootNodes, rootByKey, fieldName, fieldTypes.get(fieldName));
|
||||
}
|
||||
return finalizeElasticsearchFieldPathNodes(rootNodes);
|
||||
}
|
||||
|
||||
function appendElasticsearchFieldPath(nodes: ElasticsearchFieldPathAccumulatorNode[], byKey: Map<string, ElasticsearchFieldPathAccumulatorNode>, fieldName: string, fieldType?: string): void {
|
||||
const segments = fieldName.split(".");
|
||||
if (segments.some((segment) => !segment)) return;
|
||||
let siblingNodes = nodes;
|
||||
let siblingByKey = byKey;
|
||||
let currentPath = "";
|
||||
|
||||
segments.forEach((segment, segmentIndex) => {
|
||||
currentPath = currentPath ? `${currentPath}.${segment}` : segment;
|
||||
const node = ensureElasticsearchFieldPathNode(siblingNodes, siblingByKey, segment, currentPath);
|
||||
if (segmentIndex === segments.length - 1) node.selectable = !isElasticsearchContainerFieldType(fieldType);
|
||||
siblingNodes = node.children;
|
||||
siblingByKey = node.childByKey;
|
||||
});
|
||||
}
|
||||
|
||||
function isElasticsearchContainerFieldType(fieldType?: string): boolean {
|
||||
const normalizedType = fieldType?.trim().toLowerCase();
|
||||
return normalizedType === "object" || normalizedType === "nested";
|
||||
}
|
||||
|
||||
function ensureElasticsearchFieldPathNode(nodes: ElasticsearchFieldPathAccumulatorNode[], byKey: Map<string, ElasticsearchFieldPathAccumulatorNode>, key: string, path: string): ElasticsearchFieldPathAccumulatorNode {
|
||||
const existing = byKey.get(key);
|
||||
if (existing) return existing;
|
||||
const node: ElasticsearchFieldPathAccumulatorNode = {
|
||||
key,
|
||||
path,
|
||||
selectable: false,
|
||||
children: [],
|
||||
childByKey: new Map(),
|
||||
};
|
||||
byKey.set(key, node);
|
||||
nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function finalizeElasticsearchFieldPathNodes(nodes: readonly ElasticsearchFieldPathAccumulatorNode[], parentDisplaySegments: readonly string[] = []): DocumentFieldPathNode[] {
|
||||
return nodes.map((node) => {
|
||||
const displaySegments = [...parentDisplaySegments, node.key];
|
||||
return {
|
||||
key: node.key,
|
||||
path: node.path,
|
||||
label: node.key,
|
||||
displayPath: displaySegments.join(" > "),
|
||||
kind: "scalar",
|
||||
selectable: node.selectable,
|
||||
children: finalizeElasticsearchFieldPathNodes(node.children, displaySegments),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function documentFieldPathTreeFromDocuments(documents: readonly Record<string, unknown>[]): DocumentFieldPathNode[] {
|
||||
if (documents.length === 0) return [];
|
||||
const rootNodes: DocumentFieldPathAccumulatorNode[] = [];
|
||||
|
|
@ -178,6 +247,10 @@ export function searchDocumentFieldPathTree(nodes: readonly DocumentFieldPathNod
|
|||
});
|
||||
}
|
||||
|
||||
export function searchElasticsearchFieldPathTree(nodes: readonly DocumentFieldPathNode[], query: string): DocumentFieldPathNode[] {
|
||||
return searchDocumentFieldPathTree(nodes, query).filter((node) => node.selectable);
|
||||
}
|
||||
|
||||
export function arrayObjectAncestorPathForDocumentField(nodes: readonly DocumentFieldPathNode[], path: string): string | null {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return null;
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import {
|
|||
documentFieldPathOptionsFromDocuments,
|
||||
documentFieldPathTreeFromDocuments,
|
||||
documentStoreProviderFor,
|
||||
elasticsearchFieldPathTreeFromFieldNames,
|
||||
elasticsearchQueryTypeOptions,
|
||||
elasticsearchSearchBodyFromDocumentQuery,
|
||||
elasticsearchStructuredFilter,
|
||||
flattenDocumentFieldPathTree,
|
||||
formatDocumentQueryInput,
|
||||
searchDocumentFieldPathTree,
|
||||
searchElasticsearchFieldPathTree,
|
||||
type DocumentFilterRule,
|
||||
} from "../../apps/desktop/src/lib/app/documentStoreProvider.ts";
|
||||
|
||||
|
|
@ -192,6 +194,70 @@ test("searches nested document field paths", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("builds searchable Elasticsearch mapping field paths", () => {
|
||||
const deepValuePath = "deep_nested_example.level_1.level_2.level_3.level_4.level_5.level_6.value";
|
||||
const deepKeywordPath = `${deepValuePath}.keyword`;
|
||||
const fieldNames = ["amount", "buyer.contact.email", "buyer.contact.email.keyword", "deep_nested_example.level_1.level_2.level_3.level_4.level_5.level_6.sequence", deepValuePath, deepKeywordPath, "is_priority", "_id", "_routing"];
|
||||
const originalFieldNames = [...fieldNames];
|
||||
const tree = elasticsearchFieldPathTreeFromFieldNames(fieldNames);
|
||||
const buyer = tree.find((node) => node.path === "buyer");
|
||||
const contact = buyer?.children.find((node) => node.path === "buyer.contact");
|
||||
const email = contact?.children.find((node) => node.path === "buyer.contact.email");
|
||||
const keyword = email?.children.find((node) => node.path === "buyer.contact.email.keyword");
|
||||
const deepKeyword = flattenDocumentFieldPathTree(tree).find((node) => node.path === deepKeywordPath);
|
||||
|
||||
assert.ok(buyer);
|
||||
assert.ok(contact);
|
||||
assert.ok(email);
|
||||
assert.ok(keyword);
|
||||
assert.ok(deepKeyword);
|
||||
assert.deepEqual(
|
||||
tree.map((node) => node.path),
|
||||
["amount", "buyer", "deep_nested_example", "is_priority", "_id", "_routing"],
|
||||
);
|
||||
assert.equal(buyer.selectable, false);
|
||||
assert.equal(contact.selectable, false);
|
||||
assert.equal(email.selectable, true);
|
||||
assert.equal(keyword.selectable, true);
|
||||
assert.deepEqual(
|
||||
buyer.children.map((node) => node.path),
|
||||
["buyer.contact"],
|
||||
);
|
||||
assert.equal(keyword.displayPath, "buyer > contact > email > keyword");
|
||||
assert.equal(deepKeyword.displayPath, "deep_nested_example > level_1 > level_2 > level_3 > level_4 > level_5 > level_6 > value > keyword");
|
||||
assert.deepEqual(
|
||||
searchElasticsearchFieldPathTree(tree, " EMAIL ").map((node) => node.path),
|
||||
["buyer.contact.email", "buyer.contact.email.keyword"],
|
||||
);
|
||||
assert.deepEqual(searchElasticsearchFieldPathTree(tree, "text"), []);
|
||||
assert.deepEqual(fieldNames, originalFieldNames);
|
||||
});
|
||||
|
||||
test("keeps Elasticsearch object and nested mapping fields expandable but not selectable", () => {
|
||||
const mappingFields = [
|
||||
{ name: "buyers", type: "nested" },
|
||||
{ name: "buyers.email", type: "keyword" },
|
||||
{ name: "profile", type: "object" },
|
||||
{ name: "profile.city", type: "text" },
|
||||
{ name: "title", type: "text" },
|
||||
{ name: "title.keyword", type: "keyword" },
|
||||
];
|
||||
const fieldTypes = new Map(mappingFields.map((field) => [field.name, field.type]));
|
||||
const tree = elasticsearchFieldPathTreeFromFieldNames(
|
||||
mappingFields.map((field) => field.name),
|
||||
fieldTypes,
|
||||
);
|
||||
const fieldsByPath = new Map(flattenDocumentFieldPathTree(tree).map((field) => [field.path, field]));
|
||||
|
||||
assert.equal(fieldsByPath.get("buyers")?.selectable, false);
|
||||
assert.equal(fieldsByPath.get("buyers")?.children.length, 1);
|
||||
assert.equal(fieldsByPath.get("buyers.email")?.selectable, true);
|
||||
assert.equal(fieldsByPath.get("profile")?.selectable, false);
|
||||
assert.equal(fieldsByPath.get("profile.city")?.selectable, true);
|
||||
assert.equal(fieldsByPath.get("title")?.selectable, true);
|
||||
assert.equal(fieldsByPath.get("title.keyword")?.selectable, true);
|
||||
});
|
||||
|
||||
test("uses elemMatch only for AND conditions on the same array object", () => {
|
||||
const conditions = [{ "orders.sku": "A" }, { "orders.qty": 2 }];
|
||||
const rules = [rule({ fieldName: "orders.sku" }), rule({ fieldName: "orders.qty", rawValue: "2", conjunction: "AND" })];
|
||||
|
|
|
|||
Loading…
Reference in New Issue