diff --git a/ui/src/components/config-form.search.node.test.ts b/ui/src/components/config-form.search.node.test.ts index 59744756ebc8..35523573d6e2 100644 --- a/ui/src/components/config-form.search.node.test.ts +++ b/ui/src/components/config-form.search.node.test.ts @@ -90,4 +90,30 @@ describe("config form search", () => { expect(matched).toBe(true); }); + + it("searches additional-property schemas before entries exist", () => { + const matched = matchesNodeSearch({ + schema: { + type: "object", + additionalProperties: { + type: "object", + properties: { + url: { + type: "string", + }, + }, + }, + }, + value: {}, + path: ["servers"], + hints: { + "servers.*.url": { + help: "Endpoint used by the remote service", + }, + }, + criteria: parseConfigSearchQuery("remote service"), + }); + + expect(matched).toBe(true); + }); }); diff --git a/ui/src/components/config-form.search.ts b/ui/src/components/config-form.search.ts index 6c3d1cd8ad8d..68cd1645d1da 100644 --- a/ui/src/components/config-form.search.ts +++ b/ui/src/components/config-form.search.ts @@ -1,8 +1,5 @@ import type { ConfigUiHints } from "../api/types.ts"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "../lib/string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; export type ConfigSearchCriteria = { @@ -16,6 +13,8 @@ export type ConfigFieldMeta = { tags: string[]; }; +export type ConfigSearchTextMatcher = (value: string, query: string) => boolean; + export function hasConfigSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean { return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0)); } @@ -79,13 +78,19 @@ export function resolveConfigFieldMeta( }; } -function matchesText(text: string, candidates: Array): boolean { +function defaultTextMatcher(value: string, query: string): boolean { + return normalizeLowercaseStringOrEmpty(value).includes(normalizeLowercaseStringOrEmpty(query)); +} + +function matchesText( + text: string, + candidates: Array, + textMatcher: ConfigSearchTextMatcher, +): boolean { if (!text) { return true; } - return candidates.some((candidate) => - normalizeOptionalLowercaseString(candidate)?.includes(text), - ); + return candidates.some((candidate) => candidate !== undefined && textMatcher(candidate, text)); } function matchesTags(filterTags: string[], fieldTags: string[]): boolean { @@ -101,8 +106,9 @@ export function matchesNodeSelf(params: { path: Array; hints: ConfigUiHints; criteria: ConfigSearchCriteria; + textMatcher?: ConfigSearchTextMatcher; }): boolean { - const { schema, path, hints, criteria } = params; + const { schema, path, hints, criteria, textMatcher = defaultTextMatcher } = params; if (!hasConfigSearchCriteria(criteria)) { return true; } @@ -118,14 +124,11 @@ export function matchesNodeSelf(params: { .filter((segment): segment is string => typeof segment === "string") .join("."); const enumText = schema.enum?.map((value) => String(value)).join(" ") ?? ""; - return matchesText(criteria.text, [ - label, - help, - schema.title, - schema.description, - pathLabel, - enumText, - ]); + return matchesText( + criteria.text, + [label, help, schema.title, schema.description, pathLabel, enumText], + textMatcher, + ); } export function matchesNodeSearch(params: { @@ -134,12 +137,13 @@ export function matchesNodeSearch(params: { path: Array; hints: ConfigUiHints; criteria: ConfigSearchCriteria; + textMatcher?: ConfigSearchTextMatcher; }): boolean { - const { schema, value, path, hints, criteria } = params; + const { schema, value, path, hints, criteria, textMatcher = defaultTextMatcher } = params; if (!hasConfigSearchCriteria(criteria)) { return true; } - if (matchesNodeSelf({ schema, path, hints, criteria })) { + if (matchesNodeSelf({ schema, path, hints, criteria, textMatcher })) { return true; } @@ -159,6 +163,7 @@ export function matchesNodeSearch(params: { path: [...path, propertyKey], hints, criteria, + textMatcher, }) ) { return true; @@ -167,15 +172,26 @@ export function matchesNodeSearch(params: { const additional = schema.additionalProperties; if (additional && typeof additional === "object") { const reserved = new Set(Object.keys(properties)); - for (const [entryKey, entryValue] of Object.entries(obj)) { + const dynamicEntries = Object.entries(obj).filter(([entryKey]) => !reserved.has(entryKey)); + if (dynamicEntries.length === 0) { + return matchesNodeSearch({ + schema: additional, + value: undefined, + path: [...path, "*"], + hints, + criteria, + textMatcher, + }); + } + for (const [entryKey, entryValue] of dynamicEntries) { if ( - !reserved.has(entryKey) && matchesNodeSearch({ schema: additional, value: entryValue, path: [...path, entryKey], hints, criteria, + textMatcher, }) ) { return true; @@ -200,6 +216,7 @@ export function matchesNodeSearch(params: { path: [...path, 0], hints, criteria, + textMatcher, }); } return values.some((entry, index) => @@ -209,6 +226,7 @@ export function matchesNodeSearch(params: { path: [...path, index], hints, criteria, + textMatcher, }), ); } @@ -221,6 +239,7 @@ export function matchesConfigSectionSearch(params: { query: string; label?: string; description?: string; + textMatcher?: ConfigSearchTextMatcher; }): boolean { if (!params.query) { return true; @@ -230,7 +249,9 @@ export function matchesConfigSectionSearch(params: { criteria.tags.length === 0 && criteria.text.length > 0 && [params.key, params.label, params.description].some((candidate) => - normalizeOptionalLowercaseString(candidate)?.includes(criteria.text), + candidate !== undefined + ? (params.textMatcher ?? defaultTextMatcher)(candidate, criteria.text) + : false, ); return ( metadataMatches || @@ -240,6 +261,7 @@ export function matchesConfigSectionSearch(params: { path: [params.key], hints: params.hints, criteria, + textMatcher: params.textMatcher, }) ); } diff --git a/ui/src/pages/config/settings-search.test.ts b/ui/src/pages/config/settings-search.test.ts index 02b7ff8a1516..6e5387b0344b 100644 --- a/ui/src/pages/config/settings-search.test.ts +++ b/ui/src/pages/config/settings-search.test.ts @@ -95,6 +95,37 @@ describe("findSettingsSearchBlocks", () => { ]); }); + it("preserves nested schema matches for short prefix queries", () => { + const matches = findSettingsSearchBlocks({ + query: "sa", + schema: { + type: "object", + properties: { + tools: { + type: "object", + properties: { + profile: { + type: "string", + description: "Controls sandbox access", + }, + }, + }, + }, + }, + value: {}, + uiHints: {}, + }); + + expect(matches).toEqual([ + { + routeId: "ai-agents", + label: "Tools", + search: "?section=tools", + hash: "#config-section-tools", + }, + ]); + }); + it("searches and displays static settings blocks in the active locale", async () => { await i18n.setLocale("es"); diff --git a/ui/src/pages/config/settings-search.ts b/ui/src/pages/config/settings-search.ts index 2074ff535153..051d02601ddd 100644 --- a/ui/src/pages/config/settings-search.ts +++ b/ui/src/pages/config/settings-search.ts @@ -263,24 +263,18 @@ export function findSettingsSearchBlocks(params: { return matches; } const value = params.value ?? {}; - const usePrefixMatching = - criteria.tags.length === 0 && criteria.text.length > 0 && criteria.text.length <= 2; for (const [key, sectionSchema] of Object.entries(schema.properties)) { const meta = SECTION_META[key]; - const matchesSection = usePrefixMatching - ? [key, meta?.label, meta?.description, sectionSchema.title, sectionSchema.description].some( - (candidate) => - typeof candidate === "string" && settingsSearchTextMatches(candidate, criteria.text), - ) - : matchesConfigSectionSearch({ - key, - schema: sectionSchema, - value: value[key], - hints: params.uiHints, - query: params.query, - label: meta?.label, - description: meta?.description, - }); + const matchesSection = matchesConfigSectionSearch({ + key, + schema: sectionSchema, + value: value[key], + hints: params.uiHints, + query: params.query, + label: meta?.label, + description: meta?.description, + textMatcher: settingsSearchTextMatches, + }); if (!matchesSection) { continue; }