From d20746aae2812ea9b7719bf0756ac828c584e12b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:34:17 -0500 Subject: [PATCH 01/37] feat(field-mapper): add search for field names --- .../lib/components/QueryBuilder/Wrapped.tsx | 5 + .../lib/components/QueryBuilder/reducer.ts | 5 + .../WbPlanView/CustomSelectElement.tsx | 5 +- .../lib/components/WbPlanView/Mapper.tsx | 5 + .../WbPlanView/MapperComponents.tsx | 72 ++++++- .../WbPlanView/__tests__/navigator.test.ts | 40 +++- .../components/WbPlanView/mappingReducer.ts | 9 + .../lib/components/WbPlanView/navigator.ts | 182 ++++++++++++++++++ 8 files changed, 319 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 5755fb1e432..84a8c16f358 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -418,6 +418,7 @@ function Wrapped({
{showMappingView && ( + dispatch({ type: 'SetMappingViewAction', mappingPath }) + } > {isReadOnly ? undefined : ( | Action< 'ChangeFieldAction', { readonly line: number; readonly field: QueryField } @@ -91,6 +92,10 @@ type Actions = | Action<'SavedQueryAction'>; export const reducer = generateReducer({ + SetMappingViewAction: ({ state, action }) => ({ + ...state, + mappingView: action.mappingPath, + }), ResetStateAction: ({ action: { state } }) => ({ ...state, openedElement: { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx index 014a39eeb27..f8251e51fd0 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx @@ -220,6 +220,7 @@ type CustomSelectElementPropsBase = { readonly onClose?: () => void; readonly customSelectOptionGroups?: IR; readonly autoMapperSuggestions?: JSX.Element; + readonly fieldSearch?: JSX.Element; }; export type CustomSelectElementPropsClosed = CustomSelectElementPropsBase & { @@ -467,6 +468,7 @@ export function CustomSelectElement({ onClose: handleClose, previewOption, autoMapperSuggestions, + fieldSearch, validation, }: CustomSelectElementPropsClosed | CustomSelectElementPropsOpen): JSX.Element { const has = React.useCallback( @@ -748,7 +750,7 @@ export function CustomSelectElement({
{autoMapperSuggestions} {header} + {fieldSearch} {preview} {optionsShadow} {customSelectOptions} diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx index af719e42aa6..d7295d54139 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx @@ -525,6 +525,7 @@ export function Mapper(props: { )} {state.showMappingView && ( + dispatch({ type: 'SetMappingViewAction', mappingPath }) + } > ; + readonly baseTableName?: keyof Tables; + readonly showHiddenFields?: boolean; + readonly onSelectSearchResult?: (mappingPath: MappingPath) => void; readonly children: JSX.Element | undefined; }): JSX.Element | null { // `resize` event listener for the mapping view @@ -165,6 +171,19 @@ export function MappingView({ const [mappingView, setMappingView] = React.useState( null ); + const [search, setSearch] = React.useState(''); + const searchResults = React.useMemo( + () => + baseTableName === undefined + ? [] + : searchFields({ + baseTableName, + search, + showHiddenFields, + spec: navigatorSpecs.wbPlanView, + }).slice(0, 50), + [baseTableName, search, showHiddenFields] + ); React.useEffect(() => { if (globalThis.ResizeObserver === undefined || mappingView === null) return undefined; @@ -180,6 +199,53 @@ export function MappingView({ return (): void => resizeObserver.disconnect(); }, [mappingView, setMappingViewHeight]); + const fieldSearch = + baseTableName !== undefined && + typeof handleSelectSearchResult === 'function' ? ( +
+ + {search.length > 0 && ( +
+ {searchResults.length === 0 ? ( +
{commonText.noResults()}
+ ) : ( + searchResults.map((result) => ( + + )) + )} +
+ )} +
+ ) : undefined; + + const mappingElementPropsWithSearch = mappingElementProps.map( + (mappingElement, index) => + index === 0 && fieldSearch !== undefined + ? { ...mappingElement, fieldSearch } + : mappingElement + ); + return (
- +
{children}
diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index b36458dfd91..6dd60b22e53 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -1,11 +1,49 @@ import { requireContext } from '../../../tests/helpers'; import { theories } from '../../../tests/utils'; import { localized } from '../../../utils/types'; -import { getMappingLineData } from '../navigator'; +import { getMappingLineData, searchFields } from '../navigator'; import { navigatorSpecs } from '../navigatorSpecs'; requireContext(); +test('searchFields returns matching downstream fields in join order', () => { + const results = searchFields({ + baseTableName: 'CollectionObject', + search: 'taxon', + spec: navigatorSpecs.wbPlanView, + }); + expect(results.length).toBeGreaterThan(0); + expect(results.every(({ mappingPath }) => mappingPath.at(-1))).toBe(true); + const joinCounts = results.map(({ joinCount }) => joinCount); + expect(joinCounts).toEqual([...joinCounts].sort((a, b) => a - b)); +}); + +test('searchFields matches relationship names and returns complete paths', () => { + const results = searchFields({ + baseTableName: 'CollectionObject', + search: 'determination', + spec: navigatorSpecs.wbPlanView, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: expect.arrayContaining(['determinations']), + }), + ]) + ); +}); + +test('searchFields returns no results for empty input', () => { + expect( + searchFields({ + baseTableName: 'CollectionObject', + search: ' ', + spec: navigatorSpecs.wbPlanView, + }) + ).toEqual([]); +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingReducer.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingReducer.ts index 3a798f31e24..69e7615547d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingReducer.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/mappingReducer.ts @@ -78,6 +78,10 @@ type FocusLineAction = Action< >; type MappingViewMapAction = Action<'MappingViewMapAction'>; +type SetMappingViewAction = Action< + 'SetMappingViewAction', + { readonly mappingPath: MappingPath } +>; type AddNewHeaderAction = Action< 'AddNewHeaderAction', @@ -198,6 +202,7 @@ export type MappingActions = | CloseSelectElementAction | FocusLineAction | MappingViewMapAction + | SetMappingViewAction | OpenSelectElementAction | ReRunAutoMapperAction | ResetMappingsAction @@ -285,6 +290,10 @@ export const reducer = generateReducer({ mappingsAreValidated: false, }; }, + SetMappingViewAction: ({ state, action }) => ({ + ...state, + mappingView: action.mappingPath, + }), AddNewHeaderAction: ({ action: { newHeaderName }, state }) => ({ ...state, lines: [ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index b31e5e53752..06f6ca15e2d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -228,6 +228,188 @@ export type MappingLineData = Pick< readonly defaultValue: string; }; +export type FieldSearchResult = { + readonly mappingPath: MappingPath; + readonly label: string; + readonly isHidden: boolean; + readonly joinCount: number; +}; + +const fieldSearchDepthLimit = 6; +const fieldSearchResultLimit = 500; + +const fieldSearchLabel = (value: unknown): string => `${value}`; + +/** Find selectable fields in downstream relationships in join order. */ +export function searchFields({ + baseTableName, + search, + showHiddenFields = false, + spec, +}: { + readonly baseTableName: keyof Tables; + readonly search: string; + readonly showHiddenFields?: boolean; + readonly spec: NavigatorSpec; +}): RA { + const normalizedSearch = search.trim().toLocaleLowerCase(); + if (normalizedSearch.length === 0) return []; + + type QueueItem = { + readonly table: SpecifyTable; + readonly mappingPath: MappingPath; + readonly tablePath: readonly string[]; + readonly relationshipLabels: readonly string[]; + readonly relationshipNames: readonly string[]; + readonly isHidden: boolean; + readonly joinCount: number; + }; + + const queue: QueueItem[] = [ + { + table: strictGetTable(baseTableName), + mappingPath: [], + tablePath: [baseTableName], + relationshipLabels: [], + relationshipNames: [], + isHidden: false, + joinCount: 0, + }, + ]; + const results: FieldSearchResult[] = []; + + const matches = (values: readonly string[]): boolean => + values.some((value) => + value.toLocaleLowerCase().includes(normalizedSearch) + ); + + while (queue.length > 0 && results.length < fieldSearchResultLimit) { + const current = queue.shift()!; + const fields = [current.table.idField, ...current.table.fields]; + + fields.forEach((field) => { + if (field.isRelationship) return; + const isHidden = + current.isHidden || + (spec.useSchemaOverrides ? field.overrides.isHidden : field.isHidden); + if (isHidden && !showHiddenFields) return; + if ( + !spec.isNoRestrictions() && + !spec.includeReadOnly && + field.overrides.isReadOnly + ) + return; + if ( + !matches([ + field.name, + fieldSearchLabel(field.label), + ...current.relationshipLabels, + ...current.relationshipNames, + ]) + ) + return; + + results.push({ + mappingPath: [...current.mappingPath, field.name], + label: [ + fieldSearchLabel(current.table.label), + ...current.relationshipLabels, + fieldSearchLabel(field.label), + ].join(' → '), + isHidden, + joinCount: current.joinCount, + }); + }); + + if (current.joinCount >= fieldSearchDepthLimit) continue; + + current.table.fields + .filter((field): field is Relationship => field.isRelationship) + .filter((relationship) => { + const isHidden = current.isHidden || relationship.overrides.isHidden; + if (isHidden && !showHiddenFields) return false; + if ( + !spec.isNoRestrictions() && + !spec.includeReadOnly && + relationship.overrides.isReadOnly + ) + return false; + if ( + spec.hasActionPermission() && + spec.ensurePermission() !== undefined && + (isTreeTable(current.table.name) + ? !hasTreeAccess( + current.table.name as AnyTree['tableName'], + spec.ensurePermission()! + ) + : !hasTablePermission( + relationship.relatedTable.name, + spec.ensurePermission()! + )) + ) + return false; + if ( + !spec.allowNestedToMany && + (relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship)) && + current.mappingPath.some((part) => part.startsWith('#')) + ) + return false; + if ( + !spec.includeToManyToTree && + (relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship)) && + isTreeTable(relationship.relatedTable.name) + ) + return false; + return ( + !isTreeTable(current.table.name) || spec.includeRelationshipsFromTree + ); + }) + .forEach((relationship) => { + if (current.tablePath.includes(relationship.relatedTable.name)) return; + const relationshipPath = [ + ...current.mappingPath, + relationship.name, + ...(relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship) + ? [formatToManyIndex(1)] + : []), + ]; + queue.push({ + table: relationship.relatedTable, + mappingPath: relationshipPath, + tablePath: [...current.tablePath, relationship.relatedTable.name], + relationshipLabels: [ + ...current.relationshipLabels, + fieldSearchLabel(relationship.label), + ], + relationshipNames: [...current.relationshipNames, relationship.name], + isHidden: current.isHidden || relationship.overrides.isHidden, + joinCount: current.joinCount + 1, + }); + }); + } + + const specialPath = ['determinations', 'taxon']; + return results.sort((a, b) => { + const hiddenOrder = Number(a.isHidden) - Number(b.isHidden); + if (hiddenOrder !== 0) return hiddenOrder; + const depthOrder = a.joinCount - b.joinCount; + if (depthOrder !== 0) return depthOrder; + const specialOrder = (path: MappingPath): number => + baseTableName === 'CollectionObject' && + path.includes(specialPath[0]) && + path.includes(specialPath[1]) + ? 0 + : 1; + return ( + specialOrder(a.mappingPath) - specialOrder(b.mappingPath) || + a.label.localeCompare(b.label) + ); + }); +} + /** * Get data required to build a mapping line from a source mapping path * Handles circular dependencies and must match tables From 9fbc91db5bc43b22d38304bdfe42cbe51b4719ca Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:38:58 -0500 Subject: [PATCH 02/37] feat(field-mapper): support formatted and aggregated --- .../WbPlanView/__tests__/navigator.test.ts | 28 +++++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 22 ++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index 6dd60b22e53..cd7d3c5f253 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -34,6 +34,34 @@ test('searchFields matches relationship names and returns complete paths', () => ); }); +test('searchFields includes aggregate and formatted relationship options', () => { + const aggregateResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'determinations', + spec: navigatorSpecs.wbPlanView, + }); + const formattedResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'cataloger', + spec: navigatorSpecs.wbPlanView, + }); + + expect(aggregateResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['determinations', '#1', '-formatted'], + }), + ]) + ); + expect(formattedResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['cataloger', '-formatted'], + }), + ]) + ); +}); + test('searchFields returns no results for empty input', () => { expect( searchFields({ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 06f6ca15e2d..5d87cc70bf0 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -368,14 +368,28 @@ export function searchFields({ }) .forEach((relationship) => { if (current.tablePath.includes(relationship.relatedTable.name)) return; + const relationshipIsToManyField = + relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship); const relationshipPath = [ ...current.mappingPath, relationship.name, - ...(relationshipIsToMany(relationship) || - relationshipIsRemoteToOne(relationship) - ? [formatToManyIndex(1)] - : []), + ...(relationshipIsToManyField ? [formatToManyIndex(1)] : []), ]; + if (matches([relationship.name, fieldSearchLabel(relationship.label)])) + results.push({ + mappingPath: [...relationshipPath, formattedEntry], + label: [ + fieldSearchLabel(current.table.label), + ...current.relationshipLabels, + fieldSearchLabel(relationship.label), + relationshipIsToManyField + ? queryText.aggregatedInline() + : queryText.formattedInline(), + ].join(' → '), + isHidden: current.isHidden || relationship.overrides.isHidden, + joinCount: current.joinCount + 1, + }); queue.push({ table: relationship.relatedTable, mappingPath: relationshipPath, From b840652eff6cd06b4bc91896716164f34455d31b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:45:34 -0500 Subject: [PATCH 03/37] feat(field-mapper): add table icons --- .../WbPlanView/MapperComponents.tsx | 21 ++++++++++++++++--- .../lib/components/WbPlanView/navigator.ts | 3 +++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index a33fe3a1a21..994ebfa363e 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -172,6 +172,7 @@ export function MappingView({ null ); const [search, setSearch] = React.useState(''); + const [visibleResultCount, setVisibleResultCount] = React.useState(10); const searchResults = React.useMemo( () => baseTableName === undefined @@ -181,9 +182,12 @@ export function MappingView({ search, showHiddenFields, spec: navigatorSpecs.wbPlanView, - }).slice(0, 50), + }), [baseTableName, search, showHiddenFields] ); + React.useEffect(() => { + setVisibleResultCount(10); + }, [baseTableName, search, showHiddenFields]); React.useEffect(() => { if (globalThis.ResizeObserver === undefined || mappingView === null) return undefined; @@ -216,13 +220,23 @@ export function MappingView({ aria-label={commonText.search()} className="absolute left-0 top-full z-20 max-h-48 w-full overflow-y-auto border border-gray-500 bg-white dark:bg-neutral-600" role="listbox" + onScroll={(event): void => { + const element = event.currentTarget; + if ( + element.scrollTop + element.clientHeight >= + element.scrollHeight - 8 + ) + setVisibleResultCount((count) => + Math.min(count + 10, searchResults.length) + ); + }} > {searchResults.length === 0 ? (
{commonText.noResults()}
) : ( - searchResults.map((result) => ( + searchResults.slice(0, visibleResultCount).map((result) => ( )) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 5d87cc70bf0..3b5ff71dc63 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -233,6 +233,7 @@ export type FieldSearchResult = { readonly label: string; readonly isHidden: boolean; readonly joinCount: number; + readonly tableName: keyof Tables; }; const fieldSearchDepthLimit = 6; @@ -318,6 +319,7 @@ export function searchFields({ ].join(' → '), isHidden, joinCount: current.joinCount, + tableName: current.table.name, }); }); @@ -389,6 +391,7 @@ export function searchFields({ ].join(' → '), isHidden: current.isHidden || relationship.overrides.isHidden, joinCount: current.joinCount + 1, + tableName: relationship.relatedTable.name, }); queue.push({ table: relationship.relatedTable, From 3a79d3fe84e7d92bfffcb850523f76cf18f6ab09 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:07:10 -0500 Subject: [PATCH 04/37] feat(field-mapper): add field priorities --- .../WbPlanView/__tests__/navigator.test.ts | 62 +++++++++ .../lib/components/WbPlanView/navigator.ts | 121 +++++++++++++++++- 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index cd7d3c5f253..a77d0f55390 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -62,6 +62,68 @@ test('searchFields includes aggregate and formatted relationship options', () => ); }); +test('searchFields resolves tree ranks and rank fields', () => { + const speciesResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'Species', + spec: navigatorSpecs.wbPlanView, + }); + const fullNameResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'Full Name', + spec: navigatorSpecs.wbPlanView, + }); + + expect(speciesResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['determinations', '#1', 'taxon', '$Species', 'fullName'], + label: expect.stringMatching(/Species$/u), + }), + ]) + ); + expect(speciesResults).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['determinations', '#1', 'taxon', '$Species', 'name'], + }), + ]) + ); + expect(fullNameResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: expect.arrayContaining(['$Species', 'fullName']), + }), + ]) + ); +}); + +test('searchFields prefers the collecting event route to collectors', () => { + const results = searchFields({ + baseTableName: 'CollectionObject', + search: 'collectors', + spec: navigatorSpecs.wbPlanView, + }); + const normalizedPath = (mappingPath: readonly string[]): string[] => + mappingPath.filter( + (part) => !part.startsWith('#') && part !== '-formatted' + ); + const preferredIndex = results.findIndex( + ({ mappingPath }) => + normalizedPath(mappingPath).slice(0, 2).join('.') === + 'collectingEvent.collectors' + ); + const discouragedIndex = results.findIndex( + ({ mappingPath }) => + normalizedPath(mappingPath).slice(0, 2).join('.') === + 'cataloger.collectors' + ); + + expect(preferredIndex).toBeGreaterThanOrEqual(0); + expect(discouragedIndex).toBeGreaterThanOrEqual(0); + expect(preferredIndex).toBeLessThan(discouragedIndex); +}); + test('searchFields returns no results for empty input', () => { expect( searchFields({ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 3b5ff71dc63..99eb816c224 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -234,13 +234,55 @@ export type FieldSearchResult = { readonly isHidden: boolean; readonly joinCount: number; readonly tableName: keyof Tables; + readonly searchRank: number; }; const fieldSearchDepthLimit = 6; const fieldSearchResultLimit = 500; +type FieldSearchPathPreference = { + readonly baseTableName: keyof Tables; + readonly path: readonly string[]; + readonly priority: number; +}; + +// Lower priorities are shown first. Keep all matching paths; these only affect +// ordering. Paths omit to-many indexes and special mapping fields. +const fieldSearchPathPreferences: readonly FieldSearchPathPreference[] = [ + { + baseTableName: 'CollectionObject', + path: ['collectingEvent', 'collectors'], + priority: -100, + }, + { + baseTableName: 'CollectionObject', + path: ['cataloger', 'collectors'], + priority: 100, + }, +]; + const fieldSearchLabel = (value: unknown): string => `${value}`; +const normalizeFieldSearchPath = (path: MappingPath): readonly string[] => + path.filter( + (part) => + !part.startsWith('#') && !part.startsWith('$') && part !== formattedEntry + ); + +const getFieldSearchPathPriority = ( + baseTableName: keyof Tables, + mappingPath: MappingPath +): number => { + const normalizedPath = normalizeFieldSearchPath(mappingPath); + return ( + fieldSearchPathPreferences.find( + ({ baseTableName: preferenceBaseTableName, path }) => + preferenceBaseTableName === baseTableName && + path.every((part, index) => normalizedPath[index] === part) + )?.priority ?? 0 + ); +}; + /** Find selectable fields in downstream relationships in join order. */ export function searchFields({ baseTableName, @@ -289,7 +331,7 @@ export function searchFields({ const fields = [current.table.idField, ...current.table.fields]; fields.forEach((field) => { - if (field.isRelationship) return; + if (field.isRelationship || isTreeTable(current.table.name)) return; const isHidden = current.isHidden || (spec.useSchemaOverrides ? field.overrides.isHidden : field.isHidden); @@ -320,9 +362,79 @@ export function searchFields({ isHidden, joinCount: current.joinCount, tableName: current.table.name, + searchRank: 1, }); }); + if ( + isTreeTable(current.table.name) && + hasTreeAccess(current.table.name, 'read') + ) { + const definitions = getTreeDefinitions(current.table.name, 'all'); + definitions.forEach(({ definition, ranks }) => { + const definitionPath = + spec.useSpecificTreeInterface && definitions.length > 1 + ? [formatTreeDefinition(definition.name)] + : []; + ranks.slice(1).forEach((rank) => { + const rankPath = [ + ...current.mappingPath, + ...definitionPath, + formatTreeRank(rank.name), + ]; + const rankLabel = fieldSearchLabel(rank.title ?? rank.name); + const rankMatches = matches([rank.name, rankLabel]); + + current.table.fields + .filter((field) => !field.isRelationship) + .filter((field) => { + const isHidden = + current.isHidden || + (spec.useSchemaOverrides + ? field.overrides.isHidden + : field.isHidden); + if (isHidden && !showHiddenFields) return false; + if ( + !spec.isNoRestrictions() && + !spec.includeReadOnly && + !spec.includeAllTreeFields && + field.overrides.isReadOnly + ) + return false; + return rankMatches + ? field.name === 'fullName' + : matches([ + field.name, + fieldSearchLabel(field.label), + ...current.relationshipLabels, + ...current.relationshipNames, + ]); + }) + .forEach((field) => { + const isHidden = + current.isHidden || + (spec.useSchemaOverrides + ? field.overrides.isHidden + : field.isHidden); + results.push({ + mappingPath: [...rankPath, field.name], + label: [ + fieldSearchLabel(current.table.label), + ...current.relationshipLabels, + rankMatches + ? rankLabel + : `${rankLabel} - ${fieldSearchLabel(field.label)}`, + ].join(' → '), + isHidden, + joinCount: current.joinCount, + tableName: current.table.name, + searchRank: rankMatches ? 0 : 1, + }); + }); + }); + }); + } + if (current.joinCount >= fieldSearchDepthLimit) continue; current.table.fields @@ -392,6 +504,7 @@ export function searchFields({ isHidden: current.isHidden || relationship.overrides.isHidden, joinCount: current.joinCount + 1, tableName: relationship.relatedTable.name, + searchRank: 1, }); queue.push({ table: relationship.relatedTable, @@ -414,6 +527,12 @@ export function searchFields({ if (hiddenOrder !== 0) return hiddenOrder; const depthOrder = a.joinCount - b.joinCount; if (depthOrder !== 0) return depthOrder; + const pathPriorityOrder = + getFieldSearchPathPriority(baseTableName, a.mappingPath) - + getFieldSearchPathPriority(baseTableName, b.mappingPath); + if (pathPriorityOrder !== 0) return pathPriorityOrder; + const searchRankOrder = a.searchRank - b.searchRank; + if (searchRankOrder !== 0) return searchRankOrder; const specialOrder = (path: MappingPath): number => baseTableName === 'CollectionObject' && path.includes(specialPath[0]) && From 9243255eac6f410e149e803f50e35ab1d8958e9b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:10:42 -0500 Subject: [PATCH 05/37] fix(field-mapper): handle missing trees --- .../components/WbPlanView/__tests__/navigator.test.ts | 11 +++++++++++ .../js_src/lib/components/WbPlanView/navigator.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index a77d0f55390..e96b3695af4 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -134,6 +134,17 @@ test('searchFields returns no results for empty input', () => { ).toEqual([]); }); +test('searchFields tolerates hidden-field searches across unconfigured trees', () => { + expect(() => + searchFields({ + baseTableName: 'CollectionObject', + search: 'modified', + showHiddenFields: true, + spec: navigatorSpecs.wbPlanView, + }) + ).not.toThrow(); +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 99eb816c224..6c64778cf6d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -370,7 +370,7 @@ export function searchFields({ isTreeTable(current.table.name) && hasTreeAccess(current.table.name, 'read') ) { - const definitions = getTreeDefinitions(current.table.name, 'all'); + const definitions = getTreeDefinitions(current.table.name, 'all') ?? []; definitions.forEach(({ definition, ranks }) => { const definitionPath = spec.useSpecificTreeInterface && definitions.length > 1 From 681e82c2ae90bcda948700eb54b2825c68ac022c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:15:32 -0500 Subject: [PATCH 06/37] fix(field-mapper): search now shows fields based on context --- .../lib/components/QueryBuilder/Wrapped.tsx | 1 + .../WbPlanView/MapperComponents.tsx | 9 ++++--- .../WbPlanView/__tests__/navigator.test.ts | 26 ++++++++++++++++++- .../lib/components/WbPlanView/navigator.ts | 4 +-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 84a8c16f358..edd7c01bb9f 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -472,6 +472,7 @@ function Wrapped({ }, })} showHiddenFields={showHiddenFields} + searchSpec={navigatorSpecs.queryBuilder} onSelectSearchResult={(mappingPath): void => dispatch({ type: 'SetMappingViewAction', mappingPath }) } diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index 994ebfa363e..2129ffbd45e 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -34,6 +34,7 @@ import { DEFAULT_BATCH_EDIT_PREFS, } from './Mapper'; import { getMappingLineData, searchFields } from './navigator'; +import type { NavigatorSpec } from './navigatorSpecs'; import { navigatorSpecs } from './navigatorSpecs'; import type { ColumnOptions, @@ -155,12 +156,14 @@ export function MappingView({ mappingElementProps, baseTableName, showHiddenFields, + searchSpec = navigatorSpecs.wbPlanView, onSelectSearchResult: handleSelectSearchResult, children, }: { readonly mappingElementProps: RA; readonly baseTableName?: keyof Tables; readonly showHiddenFields?: boolean; + readonly searchSpec?: NavigatorSpec; readonly onSelectSearchResult?: (mappingPath: MappingPath) => void; readonly children: JSX.Element | undefined; }): JSX.Element | null { @@ -181,13 +184,13 @@ export function MappingView({ baseTableName, search, showHiddenFields, - spec: navigatorSpecs.wbPlanView, + spec: searchSpec, }), - [baseTableName, search, showHiddenFields] + [baseTableName, search, searchSpec, showHiddenFields] ); React.useEffect(() => { setVisibleResultCount(10); - }, [baseTableName, search, showHiddenFields]); + }, [baseTableName, search, searchSpec, showHiddenFields]); React.useEffect(() => { if (globalThis.ResizeObserver === undefined || mappingView === null) return undefined; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index e96b3695af4..f9de0efeacf 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -71,7 +71,7 @@ test('searchFields resolves tree ranks and rank fields', () => { const fullNameResults = searchFields({ baseTableName: 'CollectionObject', search: 'Full Name', - spec: navigatorSpecs.wbPlanView, + spec: navigatorSpecs.queryBuilder, }); expect(speciesResults).toEqual( @@ -145,6 +145,30 @@ test('searchFields tolerates hidden-field searches across unconfigured trees', ( ).not.toThrow(); }); +test('searchFields includes read-only fields only for Query Builder', () => { + const workBenchResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'timestampModified', + spec: navigatorSpecs.wbPlanView, + }); + const queryBuilderResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'timestampModified', + spec: navigatorSpecs.queryBuilder, + }); + + expect( + workBenchResults.some(({ mappingPath }) => + mappingPath.includes('timestampModified') + ) + ).toBe(false); + expect( + queryBuilderResults.some(({ mappingPath }) => + mappingPath.includes('timestampModified') + ) + ).toBe(true); +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 6c64778cf6d..5fa34a4d07c 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -397,8 +397,8 @@ export function searchFields({ if ( !spec.isNoRestrictions() && !spec.includeReadOnly && - !spec.includeAllTreeFields && - field.overrides.isReadOnly + field.overrides.isReadOnly && + !(rankMatches && field.name === 'fullName') ) return false; return rankMatches From 52fcd61965d6aee3c1850ae38d3dfb51f37773c4 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:22:57 -0500 Subject: [PATCH 07/37] feat(field-mapper) limit search results on backend --- .../WbPlanView/__tests__/navigator.test.ts | 24 ++++++ .../lib/components/WbPlanView/navigator.ts | 73 ++++++++++++------- 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index f9de0efeacf..8ca4b95c26f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -134,6 +134,30 @@ test('searchFields returns no results for empty input', () => { ).toEqual([]); }); +test('searchFields supports bounded result windows', () => { + const allResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'taxon', + spec: navigatorSpecs.wbPlanView, + }); + const firstResults = searchFields({ + baseTableName: 'CollectionObject', + limit: 3, + search: 'taxon', + spec: navigatorSpecs.wbPlanView, + }); + const nextResults = searchFields({ + baseTableName: 'CollectionObject', + limit: 3, + offset: 3, + search: 'taxon', + spec: navigatorSpecs.wbPlanView, + }); + + expect(firstResults).toEqual(allResults.slice(0, 3)); + expect(nextResults).toEqual(allResults.slice(3, 6)); +}); + test('searchFields tolerates hidden-field searches across unconfigured trees', () => { expect(() => searchFields({ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 5fa34a4d07c..c85883a110d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -239,6 +239,7 @@ export type FieldSearchResult = { const fieldSearchDepthLimit = 6; const fieldSearchResultLimit = 500; +const fieldSearchVisitedStateLimit = 10_000; type FieldSearchPathPreference = { readonly baseTableName: keyof Tables; @@ -286,17 +287,21 @@ const getFieldSearchPathPriority = ( /** Find selectable fields in downstream relationships in join order. */ export function searchFields({ baseTableName, + limit = fieldSearchResultLimit, + offset = 0, search, showHiddenFields = false, spec, }: { readonly baseTableName: keyof Tables; + readonly limit?: number; + readonly offset?: number; readonly search: string; readonly showHiddenFields?: boolean; readonly spec: NavigatorSpec; }): RA { const normalizedSearch = search.trim().toLocaleLowerCase(); - if (normalizedSearch.length === 0) return []; + if (normalizedSearch.length === 0 || limit <= 0) return []; type QueueItem = { readonly table: SpecifyTable; @@ -320,14 +325,24 @@ export function searchFields({ }, ]; const results: FieldSearchResult[] = []; + let queueIndex = 0; + let visitedStateCount = 0; + + const addResult = (result: FieldSearchResult): void => { + if (results.length < fieldSearchResultLimit) results.push(result); + }; const matches = (values: readonly string[]): boolean => values.some((value) => value.toLocaleLowerCase().includes(normalizedSearch) ); - while (queue.length > 0 && results.length < fieldSearchResultLimit) { - const current = queue.shift()!; + while ( + queueIndex < queue.length && + visitedStateCount < fieldSearchVisitedStateLimit + ) { + const current = queue[queueIndex++]!; + visitedStateCount += 1; const fields = [current.table.idField, ...current.table.fields]; fields.forEach((field) => { @@ -352,7 +367,7 @@ export function searchFields({ ) return; - results.push({ + addResult({ mappingPath: [...current.mappingPath, field.name], label: [ fieldSearchLabel(current.table.label), @@ -416,7 +431,7 @@ export function searchFields({ (spec.useSchemaOverrides ? field.overrides.isHidden : field.isHidden); - results.push({ + addResult({ mappingPath: [...rankPath, field.name], label: [ fieldSearchLabel(current.table.label), @@ -491,7 +506,7 @@ export function searchFields({ ...(relationshipIsToManyField ? [formatToManyIndex(1)] : []), ]; if (matches([relationship.name, fieldSearchLabel(relationship.label)])) - results.push({ + addResult({ mappingPath: [...relationshipPath, formattedEntry], label: [ fieldSearchLabel(current.table.label), @@ -522,28 +537,30 @@ export function searchFields({ } const specialPath = ['determinations', 'taxon']; - return results.sort((a, b) => { - const hiddenOrder = Number(a.isHidden) - Number(b.isHidden); - if (hiddenOrder !== 0) return hiddenOrder; - const depthOrder = a.joinCount - b.joinCount; - if (depthOrder !== 0) return depthOrder; - const pathPriorityOrder = - getFieldSearchPathPriority(baseTableName, a.mappingPath) - - getFieldSearchPathPriority(baseTableName, b.mappingPath); - if (pathPriorityOrder !== 0) return pathPriorityOrder; - const searchRankOrder = a.searchRank - b.searchRank; - if (searchRankOrder !== 0) return searchRankOrder; - const specialOrder = (path: MappingPath): number => - baseTableName === 'CollectionObject' && - path.includes(specialPath[0]) && - path.includes(specialPath[1]) - ? 0 - : 1; - return ( - specialOrder(a.mappingPath) - specialOrder(b.mappingPath) || - a.label.localeCompare(b.label) - ); - }); + return results + .sort((a, b) => { + const hiddenOrder = Number(a.isHidden) - Number(b.isHidden); + if (hiddenOrder !== 0) return hiddenOrder; + const depthOrder = a.joinCount - b.joinCount; + if (depthOrder !== 0) return depthOrder; + const pathPriorityOrder = + getFieldSearchPathPriority(baseTableName, a.mappingPath) - + getFieldSearchPathPriority(baseTableName, b.mappingPath); + if (pathPriorityOrder !== 0) return pathPriorityOrder; + const searchRankOrder = a.searchRank - b.searchRank; + if (searchRankOrder !== 0) return searchRankOrder; + const specialOrder = (path: MappingPath): number => + baseTableName === 'CollectionObject' && + path.includes(specialPath[0]) && + path.includes(specialPath[1]) + ? 0 + : 1; + return ( + specialOrder(a.mappingPath) - specialOrder(b.mappingPath) || + a.label.localeCompare(b.label) + ); + }) + .slice(offset, offset + limit); } /** From 67c71ddd1a55d3a32b206426a99387bdacaf079a Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:29:50 -0500 Subject: [PATCH 08/37] fix(field-mapper): stop searching virtual fields also fixes it so that full dates are added properly (it was bugged before) --- .../WbPlanView/__tests__/navigator.test.ts | 28 ++++++++++++++++++- .../lib/components/WbPlanView/navigator.ts | 19 +++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index 8ca4b95c26f..f7450e8599c 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -188,9 +188,35 @@ test('searchFields includes read-only fields only for Query Builder', () => { ).toBe(false); expect( queryBuilderResults.some(({ mappingPath }) => - mappingPath.includes('timestampModified') + mappingPath.includes('timestampModified-fullDate') ) ).toBe(true); + expect(queryBuilderResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: expect.arrayContaining(['timestampModified-fullDate']), + }), + ]) + ); + expect( + queryBuilderResults.some( + ({ mappingPath }) => mappingPath.at(-1) === 'timestampModified' + ) + ).toBe(false); +}); + +test('searchFields excludes virtual fields and relationships', () => { + const results = searchFields({ + baseTableName: 'CollectionObject', + search: 'currentDetermination', + spec: navigatorSpecs.queryBuilder, + }); + + expect( + results.some(({ mappingPath }) => + mappingPath.includes('currentDetermination') + ) + ).toBe(false); }); // TEST: break this test into smaller tests diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index c85883a110d..b25c2d52b4d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -346,7 +346,12 @@ export function searchFields({ const fields = [current.table.idField, ...current.table.fields]; fields.forEach((field) => { - if (field.isRelationship || isTreeTable(current.table.name)) return; + if ( + field.isVirtual || + field.isRelationship || + isTreeTable(current.table.name) + ) + return; const isHidden = current.isHidden || (spec.useSchemaOverrides ? field.overrides.isHidden : field.isHidden); @@ -368,7 +373,12 @@ export function searchFields({ return; addResult({ - mappingPath: [...current.mappingPath, field.name], + mappingPath: [ + ...current.mappingPath, + field.isTemporal() + ? formatPartialField(field.name, 'fullDate') + : field.name, + ], label: [ fieldSearchLabel(current.table.label), ...current.relationshipLabels, @@ -401,7 +411,7 @@ export function searchFields({ const rankMatches = matches([rank.name, rankLabel]); current.table.fields - .filter((field) => !field.isRelationship) + .filter((field) => !field.isVirtual && !field.isRelationship) .filter((field) => { const isHidden = current.isHidden || @@ -455,6 +465,9 @@ export function searchFields({ current.table.fields .filter((field): field is Relationship => field.isRelationship) .filter((relationship) => { + // Someday when we support virtual fields in the Query Builder, + // this should be re-enabled. + if (relationship.isVirtual) return false; const isHidden = current.isHidden || relationship.overrides.isHidden; if (isHidden && !showHiddenFields) return false; if ( From bb7a129bb62dccec9e133dc37ac9295997b9f9b0 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:34:19 -0500 Subject: [PATCH 09/37] fix(field-mapper): use name instead of fullname --- .../lib/components/WbPlanView/__tests__/navigator.test.ts | 4 ++-- .../frontend/js_src/lib/components/WbPlanView/navigator.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index f7450e8599c..d78bf84e12d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -77,7 +77,7 @@ test('searchFields resolves tree ranks and rank fields', () => { expect(speciesResults).toEqual( expect.arrayContaining([ expect.objectContaining({ - mappingPath: ['determinations', '#1', 'taxon', '$Species', 'fullName'], + mappingPath: ['determinations', '#1', 'taxon', '$Species', 'name'], label: expect.stringMatching(/Species$/u), }), ]) @@ -85,7 +85,7 @@ test('searchFields resolves tree ranks and rank fields', () => { expect(speciesResults).not.toEqual( expect.arrayContaining([ expect.objectContaining({ - mappingPath: ['determinations', '#1', 'taxon', '$Species', 'name'], + mappingPath: ['determinations', '#1', 'taxon', '$Species', 'fullName'], }), ]) ); diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index b25c2d52b4d..556920d66ef 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -423,11 +423,11 @@ export function searchFields({ !spec.isNoRestrictions() && !spec.includeReadOnly && field.overrides.isReadOnly && - !(rankMatches && field.name === 'fullName') + !(rankMatches && field.name === 'name') ) return false; return rankMatches - ? field.name === 'fullName' + ? field.name === 'name' : matches([ field.name, fieldSearchLabel(field.label), From e2415720845d02a58ac2017d8f1003fd112cb98a Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:40:56 -0500 Subject: [PATCH 10/37] fix(field-mapper): remove redundant table name --- .../WbPlanView/__tests__/navigator.test.ts | 23 +++++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 3 --- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index d78bf84e12d..ece2d1eeb55 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -34,6 +34,29 @@ test('searchFields matches relationship names and returns complete paths', () => ); }); +test('searchFields does not prefix downstream fields with the leaf table', () => { + const results = searchFields({ + baseTableName: 'CollectionObject', + search: 'Country', + spec: navigatorSpecs.wbPlanView, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: [ + 'collectingEvent', + 'locality', + 'geography', + '$Country', + 'name', + ], + label: expect.not.stringMatching(/^Geography →/u), + }), + ]) + ); +}); + test('searchFields includes aggregate and formatted relationship options', () => { const aggregateResults = searchFields({ baseTableName: 'CollectionObject', diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 556920d66ef..de9e8f68490 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -380,7 +380,6 @@ export function searchFields({ : field.name, ], label: [ - fieldSearchLabel(current.table.label), ...current.relationshipLabels, fieldSearchLabel(field.label), ].join(' → '), @@ -444,7 +443,6 @@ export function searchFields({ addResult({ mappingPath: [...rankPath, field.name], label: [ - fieldSearchLabel(current.table.label), ...current.relationshipLabels, rankMatches ? rankLabel @@ -522,7 +520,6 @@ export function searchFields({ addResult({ mappingPath: [...relationshipPath, formattedEntry], label: [ - fieldSearchLabel(current.table.label), ...current.relationshipLabels, fieldSearchLabel(relationship.label), relationshipIsToManyField From 6ffe33716e86169e2eb333066dfef1c66ffa9fd9 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:24:54 -0500 Subject: [PATCH 11/37] fix(field-mapper): search tree on any rank --- .../WbPlanView/__tests__/navigator.test.ts | 29 ++++++++++++++++++- .../lib/components/WbPlanView/navigator.ts | 23 +++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index ece2d1eeb55..de23a897421 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -115,12 +115,39 @@ test('searchFields resolves tree ranks and rank fields', () => { expect(fullNameResults).toEqual( expect.arrayContaining([ expect.objectContaining({ - mappingPath: expect.arrayContaining(['$Species', 'fullName']), + mappingPath: expect.arrayContaining(['$-any', 'fullName']), }), ]) ); }); +test('searchFields uses any rank for tree field-name matches', () => { + const results = searchFields({ + baseTableName: 'Preparation', + search: 'Ordinal', + spec: navigatorSpecs.queryBuilder, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: [ + 'storage', + '$-any', + 'storageAttachments', + '#1', + 'ordinal', + ], + }), + ]) + ); + expect( + results.some(({ mappingPath }) => + mappingPath.some((part) => /^\$/.test(part) && part !== '$-any') + ) + ).toBe(false); +}); + test('searchFields prefers the collecting event route to collectors', () => { const results = searchFields({ baseTableName: 'CollectionObject', diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index de9e8f68490..a076048978f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -401,13 +401,18 @@ export function searchFields({ ? [formatTreeDefinition(definition.name)] : []; ranks.slice(1).forEach((rank) => { + const rankLabel = fieldSearchLabel(rank.title ?? rank.name); + const rankMatches = matches([rank.name, rankLabel]); + + // A field-name match applies to every rank. Use one canonical path + // instead of returning the same field once for every concrete rank. + if (!rankMatches && rank !== ranks[1]) return; + const rankPath = [ ...current.mappingPath, ...definitionPath, - formatTreeRank(rank.name), + formatTreeRank(rankMatches ? rank.name : anyTreeRank), ]; - const rankLabel = fieldSearchLabel(rank.title ?? rank.name); - const rankMatches = matches([rank.name, rankLabel]); current.table.fields .filter((field) => !field.isVirtual && !field.isRelationship) @@ -444,9 +449,9 @@ export function searchFields({ mappingPath: [...rankPath, field.name], label: [ ...current.relationshipLabels, - rankMatches - ? rankLabel - : `${rankLabel} - ${fieldSearchLabel(field.label)}`, + ...(rankMatches + ? [rankLabel] + : [fieldSearchLabel(field.label)]), ].join(' → '), isHidden, joinCount: current.joinCount, @@ -511,8 +516,14 @@ export function searchFields({ const relationshipIsToManyField = relationshipIsToMany(relationship) || relationshipIsRemoteToOne(relationship); + const treeRankPath = + isTreeTable(current.table.name) && + !valueIsTreeMeta(current.mappingPath.at(-1)) + ? [formatTreeRank(anyTreeRank)] + : []; const relationshipPath = [ ...current.mappingPath, + ...treeRankPath, relationship.name, ...(relationshipIsToManyField ? [formatToManyIndex(1)] : []), ]; From b13d682f3b24e74b2a4f7e3627f07c372267c93d Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:33:02 -0500 Subject: [PATCH 12/37] fix(field-mapper): style search field properly --- .../lib/components/WbPlanView/CustomSelectElement.tsx | 1 + .../lib/components/WbPlanView/MapperComponents.tsx | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx index f8251e51fd0..5d411eb186d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx @@ -700,6 +700,7 @@ export function CustomSelectElement({ className={` border-brand-300 h-fit flex-1 cursor-pointer overflow-x-hidden rounded-b border bg-[color:var(--custom-select-b1)] + ${fieldSearch ? 'border-t-0' : ''} ${has('preview') ? 'z-[2]' : ''} ${has('scroll') ? 'overflow-y-scroll' : 'overflow-y-auto'} ${has('shadow') ? 'max-h-[theme(spacing.64)] shadow-md' : ''} diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index 2129ffbd45e..83cf6b548e0 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -209,10 +209,15 @@ export function MappingView({ const fieldSearch = baseTableName !== undefined && typeof handleSelectSearchResult === 'function' ? ( -
+
Date: Wed, 30 Sep 2026 20:38:03 -0500 Subject: [PATCH 13/37] fix(field-mapper): exclude internal tables from search --- .../WbPlanView/__tests__/navigator.test.ts | 22 +++++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 18 +++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index de23a897421..11f6cee4d24 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -269,6 +269,28 @@ test('searchFields excludes virtual fields and relationships', () => { ).toBe(false); }); +test('searchFields excludes SpecifyUser and Workbench tables', () => { + for (const [search, tableName, relationshipName] of [ + ['specifyuser', 'SpecifyUser', 'specifyUser'], + ['workbench', 'Workbench', 'workbench'], + ] as const) { + const results = searchFields({ + baseTableName: 'CollectionObject', + search, + spec: navigatorSpecs.queryBuilder, + }); + + expect(results.every((result) => result.tableName !== tableName)).toBe( + true + ); + expect( + results.every( + ({ mappingPath }) => !mappingPath.includes(relationshipName) + ) + ).toBe(true); + } +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index a076048978f..c83781acada 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -240,6 +240,11 @@ export type FieldSearchResult = { const fieldSearchDepthLimit = 6; const fieldSearchResultLimit = 500; const fieldSearchVisitedStateLimit = 10_000; +// There are some tables that should not be searchable. +const fieldSearchExcludedTables: ReadonlySet = new Set([ + 'SpecifyUser', + 'Workbench', +]); type FieldSearchPathPreference = { readonly baseTableName: keyof Tables; @@ -301,7 +306,12 @@ export function searchFields({ readonly spec: NavigatorSpec; }): RA { const normalizedSearch = search.trim().toLocaleLowerCase(); - if (normalizedSearch.length === 0 || limit <= 0) return []; + if ( + normalizedSearch.length === 0 || + limit <= 0 || + fieldSearchExcludedTables.has(baseTableName) + ) + return []; type QueueItem = { readonly table: SpecifyTable; @@ -470,7 +480,11 @@ export function searchFields({ .filter((relationship) => { // Someday when we support virtual fields in the Query Builder, // this should be re-enabled. - if (relationship.isVirtual) return false; + if ( + relationship.isVirtual || + fieldSearchExcludedTables.has(relationship.relatedTable.name) + ) + return false; const isHidden = current.isHidden || relationship.overrides.isHidden; if (isHidden && !showHiddenFields) return false; if ( From 770730053eec08f06dc2ae6d0833f8b09d2d35de Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:39:50 -0500 Subject: [PATCH 14/37] fix(field-mapper): support for limited self-relationships --- .../WbPlanView/__tests__/navigator.test.ts | 16 ++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index 11f6cee4d24..8e8abb71b7d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -291,6 +291,22 @@ test('searchFields excludes SpecifyUser and Workbench tables', () => { } }); +test('searchFields includes matching self-referential relationships', () => { + const results = searchFields({ + baseTableName: 'Preparation', + search: 'organ', + spec: navigatorSpecs.queryBuilder, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['preparedByAgent', 'organization', '-formatted'], + }), + ]) + ); +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index c83781acada..59f92c6d8dd 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -526,7 +526,6 @@ export function searchFields({ ); }) .forEach((relationship) => { - if (current.tablePath.includes(relationship.relatedTable.name)) return; const relationshipIsToManyField = relationshipIsToMany(relationship) || relationshipIsRemoteToOne(relationship); @@ -556,6 +555,11 @@ export function searchFields({ tableName: relationship.relatedTable.name, searchRank: 1, }); + + // Return matching self-referential relationships, but do not traverse + // through them or the search could revisit the same table forever. + if (current.tablePath.includes(relationship.relatedTable.name)) return; + queue.push({ table: relationship.relatedTable, mappingPath: relationshipPath, From ab506b0438459c83babf7391eed9763be495dfb7 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:48:31 -0500 Subject: [PATCH 15/37] fix(field-mapper): make search resolve (formatted) tree paths --- .../WbPlanView/__tests__/navigator.test.ts | 16 ++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index 8e8abb71b7d..46ce6943bd3 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -307,6 +307,22 @@ test('searchFields includes matching self-referential relationships', () => { ); }); +test('searchFields uses any rank for formatted tree relationships', () => { + const results = searchFields({ + baseTableName: 'Preparation', + search: 'storage', + spec: navigatorSpecs.queryBuilder, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['storage', '$-any', '-formatted'], + }), + ]) + ); +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 59f92c6d8dd..26fce5e4eb2 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -542,7 +542,13 @@ export function searchFields({ ]; if (matches([relationship.name, fieldSearchLabel(relationship.label)])) addResult({ - mappingPath: [...relationshipPath, formattedEntry], + mappingPath: [ + ...relationshipPath, + ...(isTreeTable(relationship.relatedTable.name) + ? [formatTreeRank(anyTreeRank)] + : []), + formattedEntry, + ], label: [ ...current.relationshipLabels, fieldSearchLabel(relationship.label), From e4305435834a9ae1323fad2a122fb1eaa735080c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:03:58 -0500 Subject: [PATCH 16/37] feat(field-mapper): clarify search label --- .../js_src/lib/components/WbPlanView/MapperComponents.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index 83cf6b548e0..55c6f170ed1 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -6,6 +6,7 @@ import { useCachedState } from '../../hooks/useCachedState'; import { useId } from '../../hooks/useId'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; +import { expressSearchConfigText } from '../../localization/expressSearchConfig'; import { headerText } from '../../localization/header'; import { schemaText } from '../../localization/schema'; import { wbPlanText } from '../../localization/wbPlan'; @@ -219,7 +220,7 @@ export function MappingView({ aria-label={commonText.search()} className="w-full rounded-none border-0 !ring-0 !shadow-none focus:border-0 focus:!ring-0" id="field-search" - placeholder={commonText.search()} + placeholder={expressSearchConfigText.searchFieldsTab()} value={search} onValueChange={setSearch} /> From de95bea97725651cc1847dce610a52546a3d9ef9 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:06:57 -0500 Subject: [PATCH 17/37] fix(field-mapper): add result cap after sorting --- .../frontend/js_src/lib/components/WbPlanView/navigator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 26fce5e4eb2..9ee8ea5d319 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -339,7 +339,7 @@ export function searchFields({ let visitedStateCount = 0; const addResult = (result: FieldSearchResult): void => { - if (results.length < fieldSearchResultLimit) results.push(result); + results.push(result); }; const matches = (values: readonly string[]): boolean => @@ -605,7 +605,7 @@ export function searchFields({ a.label.localeCompare(b.label) ); }) - .slice(offset, offset + limit); + .slice(offset, Math.min(offset + limit, fieldSearchResultLimit)); } /** From e9993a2c8a56fb3f4bc4091b80a643e9f6023040 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:09:35 -0500 Subject: [PATCH 18/37] feat(field-mapper): add debounce for search --- .../lib/components/WbPlanView/MapperComponents.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index 55c6f170ed1..2e88163080a 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -152,6 +152,7 @@ export function ValidationResults(props: { } const defaultValue = 300; +const fieldSearchDebounceRate = 200; export function MappingView({ mappingElementProps, @@ -176,18 +177,26 @@ export function MappingView({ null ); const [search, setSearch] = React.useState(''); + const [debouncedSearch, setDebouncedSearch] = React.useState(''); const [visibleResultCount, setVisibleResultCount] = React.useState(10); + React.useEffect(() => { + const timeout = globalThis.setTimeout( + () => setDebouncedSearch(search), + fieldSearchDebounceRate + ); + return (): void => globalThis.clearTimeout(timeout); + }, [search]); const searchResults = React.useMemo( () => baseTableName === undefined ? [] : searchFields({ baseTableName, - search, + search: debouncedSearch, showHiddenFields, spec: searchSpec, }), - [baseTableName, search, searchSpec, showHiddenFields] + [baseTableName, debouncedSearch, searchSpec, showHiddenFields] ); React.useEffect(() => { setVisibleResultCount(10); From 9a763f212720f1c89d55167598010933decd36de Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:11:57 -0500 Subject: [PATCH 19/37] fix(field-mapper): make search show ID use ID instead of schema captions --- .../WbPlanView/__tests__/navigator.test.ts | 20 +++++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index 46ce6943bd3..b4c8216e648 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -3,6 +3,7 @@ import { theories } from '../../../tests/utils'; import { localized } from '../../../utils/types'; import { getMappingLineData, searchFields } from '../navigator'; import { navigatorSpecs } from '../navigatorSpecs'; +import { strictGetTable } from '../../DataModel/tables'; requireContext(); @@ -323,6 +324,25 @@ test('searchFields uses any rank for formatted tree relationships', () => { ); }); +test('searchFields labels ID fields as ID', () => { + const idFieldName = strictGetTable('CollectionObject').idField.name; + const results = searchFields({ + baseTableName: 'CollectionObject', + search: idFieldName, + showHiddenFields: true, + spec: navigatorSpecs.queryBuilder, + }); + + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: [idFieldName], + label: 'ID', + }), + ]) + ); +}); + // TEST: break this test into smaller tests theories(getMappingLineData, [ { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 9ee8ea5d319..7d53525b715 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -391,7 +391,9 @@ export function searchFields({ ], label: [ ...current.relationshipLabels, - fieldSearchLabel(field.label), + field.name === current.table.idField.name + ? commonText.id() + : fieldSearchLabel(field.label), ].join(' → '), isHidden, joinCount: current.joinCount, From 659c816db9c45a9c23fc7e5a9536894428e07843 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:19:05 -0500 Subject: [PATCH 20/37] fix(field-mapper): remove listbox --- .../js_src/lib/components/WbPlanView/MapperComponents.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index 2e88163080a..5ec719aa150 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -237,7 +237,6 @@ export function MappingView({
{ const element = event.currentTarget; if ( From 12f8e86c443de7a6f69221a5b6053fd26c32f197 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:20:50 -0500 Subject: [PATCH 21/37] fix(field-mapper): fix pagination logic --- .../WbPlanView/__tests__/navigator.test.ts | 20 +++++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index b4c8216e648..2fb341be4b2 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -209,6 +209,26 @@ test('searchFields supports bounded result windows', () => { expect(nextResults).toEqual(allResults.slice(3, 6)); }); +test('searchFields supports offsets beyond the result page limit', () => { + const resultBeforeLimit = searchFields({ + baseTableName: 'CollectionObject', + search: 'a', + limit: 1, + offset: 499, + spec: navigatorSpecs.queryBuilder, + }); + const offsetResults = searchFields({ + baseTableName: 'CollectionObject', + search: 'a', + limit: 1, + offset: 500, + spec: navigatorSpecs.queryBuilder, + }); + + expect(resultBeforeLimit).toHaveLength(1); + expect(offsetResults).toHaveLength(1); +}); + test('searchFields tolerates hidden-field searches across unconfigured trees', () => { expect(() => searchFields({ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 7d53525b715..1c43d3533ca 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -607,7 +607,7 @@ export function searchFields({ a.label.localeCompare(b.label) ); }) - .slice(offset, Math.min(offset + limit, fieldSearchResultLimit)); + .slice(offset, offset + Math.min(limit, fieldSearchResultLimit)); } /** From 9739fc7038961e4507dc8ad29e72c925f82f4821 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 14:33:16 +0200 Subject: [PATCH 22/37] Refactor: Create dedicated files for field search --- .../WbPlanView/FieldSearchComponent.tsx | 111 +++++ .../WbPlanView/MapperComponents.tsx | 94 +--- .../lib/components/WbPlanView/fieldSearch.ts | 405 ++++++++++++++++++ .../lib/components/WbPlanView/navigator.ts | 384 +---------------- 4 files changed, 529 insertions(+), 465 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx new file mode 100644 index 00000000000..0b74fc92513 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { expressSearchConfigText } from '../../localization/expressSearchConfig'; +import type { Tables } from '../DataModel/types'; +import { Input } from '../Atoms/Form'; +import { TableIcon } from '../Molecules/TableIcon'; +import type { MappingPath } from './Mapper'; +import { searchFields } from './fieldSearch'; +import type { NavigatorSpec } from './navigatorSpecs'; + +const fieldSearchDebounceRate = 200; + +export function FieldSearch({ + baseTableName, + showHiddenFields, + searchSpec, + onSelectSearchResult: handleSelectSearchResult, +}: { + readonly baseTableName: keyof Tables | undefined; + readonly showHiddenFields: boolean | undefined; + readonly searchSpec: NavigatorSpec; + readonly onSelectSearchResult: + | ((mappingPath: MappingPath) => void) + | undefined; +}): JSX.Element | null { + const [search, setSearch] = React.useState(''); + const [debouncedSearch, setDebouncedSearch] = React.useState(''); + const [visibleResultCount, setVisibleResultCount] = React.useState(10); + React.useEffect(() => { + const timeout = globalThis.setTimeout( + () => setDebouncedSearch(search), + fieldSearchDebounceRate + ); + return (): void => globalThis.clearTimeout(timeout); + }, [search]); + const searchResults = React.useMemo( + () => + baseTableName === undefined + ? [] + : searchFields({ + baseTableName, + search: debouncedSearch, + showHiddenFields, + spec: searchSpec, + }), + [baseTableName, debouncedSearch, searchSpec, showHiddenFields] + ); + React.useEffect(() => { + setVisibleResultCount(10); + }, [baseTableName, search, searchSpec, showHiddenFields]); + + if ( + baseTableName === undefined || + typeof handleSelectSearchResult !== 'function' + ) + return null; + + return ( +
+ + {search.length > 0 && ( +
{ + const element = event.currentTarget; + if ( + element.scrollTop + element.clientHeight >= + element.scrollHeight - 8 + ) + setVisibleResultCount((count) => + Math.min(count + 10, searchResults.length) + ); + }} + > + {searchResults.length === 0 ? ( +
{commonText.noResults()}
+ ) : ( + searchResults.slice(0, visibleResultCount).map((result) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index 5ec719aa150..a61fecfe73d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -6,7 +6,6 @@ import { useCachedState } from '../../hooks/useCachedState'; import { useId } from '../../hooks/useId'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; -import { expressSearchConfigText } from '../../localization/expressSearchConfig'; import { headerText } from '../../localization/header'; import { schemaText } from '../../localization/schema'; import { wbPlanText } from '../../localization/wbPlan'; @@ -24,6 +23,7 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { TableIcon } from '../Molecules/TableIcon'; import { userPreferences } from '../Preferences/userPreferences'; import { ButtonWithConfirmation } from './Components'; +import { FieldSearch } from './FieldSearchComponent'; import type { MapperComponentData, MappingElementProps, @@ -34,7 +34,7 @@ import { type MappingPath, DEFAULT_BATCH_EDIT_PREFS, } from './Mapper'; -import { getMappingLineData, searchFields } from './navigator'; +import { getMappingLineData } from './navigator'; import type { NavigatorSpec } from './navigatorSpecs'; import { navigatorSpecs } from './navigatorSpecs'; import type { @@ -152,7 +152,6 @@ export function ValidationResults(props: { } const defaultValue = 300; -const fieldSearchDebounceRate = 200; export function MappingView({ mappingElementProps, @@ -176,31 +175,6 @@ export function MappingView({ const [mappingView, setMappingView] = React.useState( null ); - const [search, setSearch] = React.useState(''); - const [debouncedSearch, setDebouncedSearch] = React.useState(''); - const [visibleResultCount, setVisibleResultCount] = React.useState(10); - React.useEffect(() => { - const timeout = globalThis.setTimeout( - () => setDebouncedSearch(search), - fieldSearchDebounceRate - ); - return (): void => globalThis.clearTimeout(timeout); - }, [search]); - const searchResults = React.useMemo( - () => - baseTableName === undefined - ? [] - : searchFields({ - baseTableName, - search: debouncedSearch, - showHiddenFields, - spec: searchSpec, - }), - [baseTableName, debouncedSearch, searchSpec, showHiddenFields] - ); - React.useEffect(() => { - setVisibleResultCount(10); - }, [baseTableName, search, searchSpec, showHiddenFields]); React.useEffect(() => { if (globalThis.ResizeObserver === undefined || mappingView === null) return undefined; @@ -216,64 +190,18 @@ export function MappingView({ return (): void => resizeObserver.disconnect(); }, [mappingView, setMappingViewHeight]); - const fieldSearch = - baseTableName !== undefined && - typeof handleSelectSearchResult === 'function' ? ( -
- - {search.length > 0 && ( -
{ - const element = event.currentTarget; - if ( - element.scrollTop + element.clientHeight >= - element.scrollHeight - 8 - ) - setVisibleResultCount((count) => - Math.min(count + 10, searchResults.length) - ); - }} - > - {searchResults.length === 0 ? ( -
{commonText.noResults()}
- ) : ( - searchResults.slice(0, visibleResultCount).map((result) => ( - - )) - )} -
- )} -
- ) : undefined; + const fieldSearch = ( + + ); const mappingElementPropsWithSearch = mappingElementProps.map( (mappingElement, index) => - index === 0 && fieldSearch !== undefined + index === 0 && fieldSearch !== null ? { ...mappingElement, fieldSearch } : mappingElement ); diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts new file mode 100644 index 00000000000..24d1832b011 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts @@ -0,0 +1,405 @@ +import { commonText } from '../../localization/common'; +import { queryText } from '../../localization/query'; +import type { RA } from '../../utils/types'; +import type { AnyTree } from '../DataModel/helperTypes'; +import type { Relationship } from '../DataModel/specifyField'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { strictGetTable } from '../DataModel/tables'; +import type { Tables } from '../DataModel/types'; +import { getTreeDefinitions, isTreeTable } from '../InitialContext/treeRanks'; +import { hasTablePermission, hasTreeAccess } from '../Permissions/helpers'; +import type { MappingPath } from './Mapper'; +import { + anyTreeRank, + formatPartialField, + formatToManyIndex, + formatTreeDefinition, + formatTreeRank, + formattedEntry, + relationshipIsRemoteToOne, + relationshipIsToMany, + valueIsTreeMeta, +} from './mappingHelpers'; +import type { NavigatorSpec } from './navigatorSpecs'; + +export type FieldSearchResult = { + readonly mappingPath: MappingPath; + readonly label: string; + readonly isHidden: boolean; + readonly joinCount: number; + readonly tableName: keyof Tables; + readonly searchRank: number; +}; + +const fieldSearchDepthLimit = 6; +const fieldSearchResultLimit = 500; +const fieldSearchVisitedStateLimit = 10_000; +// There are some tables that should not be searchable. +const fieldSearchExcludedTables: ReadonlySet = new Set([ + 'SpecifyUser', + 'Workbench', +]); + +type FieldSearchPathPreference = { + readonly baseTableName: keyof Tables; + readonly path: readonly string[]; + readonly priority: number; +}; + +// Lower priorities are shown first. Keep all matching paths; these only affect +// ordering. Paths omit to-many indexes and special mapping fields. +const fieldSearchPathPreferences: readonly FieldSearchPathPreference[] = [ + { + baseTableName: 'CollectionObject', + path: ['collectingEvent', 'collectors'], + priority: -100, + }, + { + baseTableName: 'CollectionObject', + path: ['cataloger', 'collectors'], + priority: 100, + }, +]; + +const fieldSearchLabel = (value: unknown): string => `${value}`; + +const normalizeFieldSearchPath = (path: MappingPath): readonly string[] => + path.filter( + (part) => + !part.startsWith('#') && !part.startsWith('$') && part !== formattedEntry + ); + +const getFieldSearchPathPriority = ( + baseTableName: keyof Tables, + mappingPath: MappingPath +): number => { + const normalizedPath = normalizeFieldSearchPath(mappingPath); + return ( + fieldSearchPathPreferences.find( + ({ baseTableName: preferenceBaseTableName, path }) => + preferenceBaseTableName === baseTableName && + path.every((part, index) => normalizedPath[index] === part) + )?.priority ?? 0 + ); +}; + +/** Find selectable fields in downstream relationships in join order. */ +export function searchFields({ + baseTableName, + limit = fieldSearchResultLimit, + offset = 0, + search, + showHiddenFields = false, + spec, +}: { + readonly baseTableName: keyof Tables; + readonly limit?: number; + readonly offset?: number; + readonly search: string; + readonly showHiddenFields?: boolean; + readonly spec: NavigatorSpec; +}): RA { + const normalizedSearch = search.trim().toLocaleLowerCase(); + if ( + normalizedSearch.length === 0 || + limit <= 0 || + fieldSearchExcludedTables.has(baseTableName) + ) + return []; + + type QueueItem = { + readonly table: SpecifyTable; + readonly mappingPath: MappingPath; + readonly tablePath: readonly string[]; + readonly relationshipLabels: readonly string[]; + readonly relationshipNames: readonly string[]; + readonly isHidden: boolean; + readonly joinCount: number; + }; + + const queue: QueueItem[] = [ + { + table: strictGetTable(baseTableName), + mappingPath: [], + tablePath: [baseTableName], + relationshipLabels: [], + relationshipNames: [], + isHidden: false, + joinCount: 0, + }, + ]; + const results: FieldSearchResult[] = []; + let queueIndex = 0; + let visitedStateCount = 0; + + const addResult = (result: FieldSearchResult): void => { + results.push(result); + }; + + const matches = (values: readonly string[]): boolean => + values.some((value) => + value.toLocaleLowerCase().includes(normalizedSearch) + ); + + while ( + queueIndex < queue.length && + visitedStateCount < fieldSearchVisitedStateLimit + ) { + const current = queue[queueIndex++]!; + visitedStateCount += 1; + const fields = [current.table.idField, ...current.table.fields]; + + fields.forEach((field) => { + if ( + field.isVirtual || + field.isRelationship || + isTreeTable(current.table.name) + ) + return; + const isHidden = + current.isHidden || + (spec.useSchemaOverrides ? field.overrides.isHidden : field.isHidden); + if (isHidden && !showHiddenFields) return; + if ( + !spec.isNoRestrictions() && + !spec.includeReadOnly && + field.overrides.isReadOnly + ) + return; + if ( + !matches([ + field.name, + fieldSearchLabel(field.label), + ...current.relationshipLabels, + ...current.relationshipNames, + ]) + ) + return; + + addResult({ + mappingPath: [ + ...current.mappingPath, + field.isTemporal() + ? formatPartialField(field.name, 'fullDate') + : field.name, + ], + label: [ + ...current.relationshipLabels, + field.name === current.table.idField.name + ? commonText.id() + : fieldSearchLabel(field.label), + ].join(' → '), + isHidden, + joinCount: current.joinCount, + tableName: current.table.name, + searchRank: 1, + }); + }); + + if ( + isTreeTable(current.table.name) && + hasTreeAccess(current.table.name, 'read') + ) { + const definitions = getTreeDefinitions(current.table.name, 'all') ?? []; + definitions.forEach(({ definition, ranks }) => { + const definitionPath = + spec.useSpecificTreeInterface && definitions.length > 1 + ? [formatTreeDefinition(definition.name)] + : []; + ranks.slice(1).forEach((rank) => { + const rankLabel = fieldSearchLabel(rank.title ?? rank.name); + const rankMatches = matches([rank.name, rankLabel]); + + // A field-name match applies to every rank. Use one canonical path + // instead of returning the same field once for every concrete rank. + if (!rankMatches && rank !== ranks[1]) return; + + const rankPath = [ + ...current.mappingPath, + ...definitionPath, + formatTreeRank(rankMatches ? rank.name : anyTreeRank), + ]; + + current.table.fields + .filter((field) => !field.isVirtual && !field.isRelationship) + .filter((field) => { + const isHidden = + current.isHidden || + (spec.useSchemaOverrides + ? field.overrides.isHidden + : field.isHidden); + if (isHidden && !showHiddenFields) return false; + if ( + !spec.isNoRestrictions() && + !spec.includeReadOnly && + field.overrides.isReadOnly && + !(rankMatches && field.name === 'name') + ) + return false; + return rankMatches + ? field.name === 'name' + : matches([ + field.name, + fieldSearchLabel(field.label), + ...current.relationshipLabels, + ...current.relationshipNames, + ]); + }) + .forEach((field) => { + const isHidden = + current.isHidden || + (spec.useSchemaOverrides + ? field.overrides.isHidden + : field.isHidden); + addResult({ + mappingPath: [...rankPath, field.name], + label: [ + ...current.relationshipLabels, + ...(rankMatches + ? [rankLabel] + : [fieldSearchLabel(field.label)]), + ].join(' → '), + isHidden, + joinCount: current.joinCount, + tableName: current.table.name, + searchRank: rankMatches ? 0 : 1, + }); + }); + }); + }); + } + + if (current.joinCount >= fieldSearchDepthLimit) continue; + + current.table.fields + .filter((field): field is Relationship => field.isRelationship) + .filter((relationship) => { + // Someday when we support virtual fields in the Query Builder, + // this should be re-enabled. + if ( + relationship.isVirtual || + fieldSearchExcludedTables.has(relationship.relatedTable.name) + ) + return false; + const isHidden = current.isHidden || relationship.overrides.isHidden; + if (isHidden && !showHiddenFields) return false; + if ( + !spec.isNoRestrictions() && + !spec.includeReadOnly && + relationship.overrides.isReadOnly + ) + return false; + if ( + spec.hasActionPermission() && + spec.ensurePermission() !== undefined && + (isTreeTable(current.table.name) + ? !hasTreeAccess( + current.table.name as AnyTree['tableName'], + spec.ensurePermission()! + ) + : !hasTablePermission( + relationship.relatedTable.name, + spec.ensurePermission()! + )) + ) + return false; + if ( + !spec.allowNestedToMany && + (relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship)) && + current.mappingPath.some((part) => part.startsWith('#')) + ) + return false; + if ( + !spec.includeToManyToTree && + (relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship)) && + isTreeTable(relationship.relatedTable.name) + ) + return false; + return ( + !isTreeTable(current.table.name) || spec.includeRelationshipsFromTree + ); + }) + .forEach((relationship) => { + const relationshipIsToManyField = + relationshipIsToMany(relationship) || + relationshipIsRemoteToOne(relationship); + const treeRankPath = + isTreeTable(current.table.name) && + !valueIsTreeMeta(current.mappingPath.at(-1)) + ? [formatTreeRank(anyTreeRank)] + : []; + const relationshipPath = [ + ...current.mappingPath, + ...treeRankPath, + relationship.name, + ...(relationshipIsToManyField ? [formatToManyIndex(1)] : []), + ]; + if (matches([relationship.name, fieldSearchLabel(relationship.label)])) + addResult({ + mappingPath: [ + ...relationshipPath, + ...(isTreeTable(relationship.relatedTable.name) + ? [formatTreeRank(anyTreeRank)] + : []), + formattedEntry, + ], + label: [ + ...current.relationshipLabels, + fieldSearchLabel(relationship.label), + relationshipIsToManyField + ? queryText.aggregatedInline() + : queryText.formattedInline(), + ].join(' → '), + isHidden: current.isHidden || relationship.overrides.isHidden, + joinCount: current.joinCount + 1, + tableName: relationship.relatedTable.name, + searchRank: 1, + }); + + // Return matching self-referential relationships, but do not traverse + // through them or the search could revisit the same table forever. + if (current.tablePath.includes(relationship.relatedTable.name)) return; + + queue.push({ + table: relationship.relatedTable, + mappingPath: relationshipPath, + tablePath: [...current.tablePath, relationship.relatedTable.name], + relationshipLabels: [ + ...current.relationshipLabels, + fieldSearchLabel(relationship.label), + ], + relationshipNames: [...current.relationshipNames, relationship.name], + isHidden: current.isHidden || relationship.overrides.isHidden, + joinCount: current.joinCount + 1, + }); + }); + } + + const specialPath = ['determinations', 'taxon']; + return results + .sort((a, b) => { + const hiddenOrder = Number(a.isHidden) - Number(b.isHidden); + if (hiddenOrder !== 0) return hiddenOrder; + const depthOrder = a.joinCount - b.joinCount; + if (depthOrder !== 0) return depthOrder; + const pathPriorityOrder = + getFieldSearchPathPriority(baseTableName, a.mappingPath) - + getFieldSearchPathPriority(baseTableName, b.mappingPath); + if (pathPriorityOrder !== 0) return pathPriorityOrder; + const searchRankOrder = a.searchRank - b.searchRank; + if (searchRankOrder !== 0) return searchRankOrder; + const specialOrder = (path: MappingPath): number => + baseTableName === 'CollectionObject' && + path.includes(specialPath[0]) && + path.includes(specialPath[1]) + ? 0 + : 1; + return ( + specialOrder(a.mappingPath) - specialOrder(b.mappingPath) || + a.label.localeCompare(b.label) + ); + }) + .slice(offset, offset + Math.min(limit, fieldSearchResultLimit)); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 1c43d3533ca..41174276fe2 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -45,6 +45,8 @@ import { } from './mappingHelpers'; import { getMaxToManyIndex, isCircularRelationship } from './modelHelpers'; import type { NavigatorSpec } from './navigatorSpecs'; +export { searchFields } from './fieldSearch'; +export type { FieldSearchResult } from './fieldSearch'; const geoPaleoDisciplines: readonly string[] = [ 'geology', @@ -228,388 +230,6 @@ export type MappingLineData = Pick< readonly defaultValue: string; }; -export type FieldSearchResult = { - readonly mappingPath: MappingPath; - readonly label: string; - readonly isHidden: boolean; - readonly joinCount: number; - readonly tableName: keyof Tables; - readonly searchRank: number; -}; - -const fieldSearchDepthLimit = 6; -const fieldSearchResultLimit = 500; -const fieldSearchVisitedStateLimit = 10_000; -// There are some tables that should not be searchable. -const fieldSearchExcludedTables: ReadonlySet = new Set([ - 'SpecifyUser', - 'Workbench', -]); - -type FieldSearchPathPreference = { - readonly baseTableName: keyof Tables; - readonly path: readonly string[]; - readonly priority: number; -}; - -// Lower priorities are shown first. Keep all matching paths; these only affect -// ordering. Paths omit to-many indexes and special mapping fields. -const fieldSearchPathPreferences: readonly FieldSearchPathPreference[] = [ - { - baseTableName: 'CollectionObject', - path: ['collectingEvent', 'collectors'], - priority: -100, - }, - { - baseTableName: 'CollectionObject', - path: ['cataloger', 'collectors'], - priority: 100, - }, -]; - -const fieldSearchLabel = (value: unknown): string => `${value}`; - -const normalizeFieldSearchPath = (path: MappingPath): readonly string[] => - path.filter( - (part) => - !part.startsWith('#') && !part.startsWith('$') && part !== formattedEntry - ); - -const getFieldSearchPathPriority = ( - baseTableName: keyof Tables, - mappingPath: MappingPath -): number => { - const normalizedPath = normalizeFieldSearchPath(mappingPath); - return ( - fieldSearchPathPreferences.find( - ({ baseTableName: preferenceBaseTableName, path }) => - preferenceBaseTableName === baseTableName && - path.every((part, index) => normalizedPath[index] === part) - )?.priority ?? 0 - ); -}; - -/** Find selectable fields in downstream relationships in join order. */ -export function searchFields({ - baseTableName, - limit = fieldSearchResultLimit, - offset = 0, - search, - showHiddenFields = false, - spec, -}: { - readonly baseTableName: keyof Tables; - readonly limit?: number; - readonly offset?: number; - readonly search: string; - readonly showHiddenFields?: boolean; - readonly spec: NavigatorSpec; -}): RA { - const normalizedSearch = search.trim().toLocaleLowerCase(); - if ( - normalizedSearch.length === 0 || - limit <= 0 || - fieldSearchExcludedTables.has(baseTableName) - ) - return []; - - type QueueItem = { - readonly table: SpecifyTable; - readonly mappingPath: MappingPath; - readonly tablePath: readonly string[]; - readonly relationshipLabels: readonly string[]; - readonly relationshipNames: readonly string[]; - readonly isHidden: boolean; - readonly joinCount: number; - }; - - const queue: QueueItem[] = [ - { - table: strictGetTable(baseTableName), - mappingPath: [], - tablePath: [baseTableName], - relationshipLabels: [], - relationshipNames: [], - isHidden: false, - joinCount: 0, - }, - ]; - const results: FieldSearchResult[] = []; - let queueIndex = 0; - let visitedStateCount = 0; - - const addResult = (result: FieldSearchResult): void => { - results.push(result); - }; - - const matches = (values: readonly string[]): boolean => - values.some((value) => - value.toLocaleLowerCase().includes(normalizedSearch) - ); - - while ( - queueIndex < queue.length && - visitedStateCount < fieldSearchVisitedStateLimit - ) { - const current = queue[queueIndex++]!; - visitedStateCount += 1; - const fields = [current.table.idField, ...current.table.fields]; - - fields.forEach((field) => { - if ( - field.isVirtual || - field.isRelationship || - isTreeTable(current.table.name) - ) - return; - const isHidden = - current.isHidden || - (spec.useSchemaOverrides ? field.overrides.isHidden : field.isHidden); - if (isHidden && !showHiddenFields) return; - if ( - !spec.isNoRestrictions() && - !spec.includeReadOnly && - field.overrides.isReadOnly - ) - return; - if ( - !matches([ - field.name, - fieldSearchLabel(field.label), - ...current.relationshipLabels, - ...current.relationshipNames, - ]) - ) - return; - - addResult({ - mappingPath: [ - ...current.mappingPath, - field.isTemporal() - ? formatPartialField(field.name, 'fullDate') - : field.name, - ], - label: [ - ...current.relationshipLabels, - field.name === current.table.idField.name - ? commonText.id() - : fieldSearchLabel(field.label), - ].join(' → '), - isHidden, - joinCount: current.joinCount, - tableName: current.table.name, - searchRank: 1, - }); - }); - - if ( - isTreeTable(current.table.name) && - hasTreeAccess(current.table.name, 'read') - ) { - const definitions = getTreeDefinitions(current.table.name, 'all') ?? []; - definitions.forEach(({ definition, ranks }) => { - const definitionPath = - spec.useSpecificTreeInterface && definitions.length > 1 - ? [formatTreeDefinition(definition.name)] - : []; - ranks.slice(1).forEach((rank) => { - const rankLabel = fieldSearchLabel(rank.title ?? rank.name); - const rankMatches = matches([rank.name, rankLabel]); - - // A field-name match applies to every rank. Use one canonical path - // instead of returning the same field once for every concrete rank. - if (!rankMatches && rank !== ranks[1]) return; - - const rankPath = [ - ...current.mappingPath, - ...definitionPath, - formatTreeRank(rankMatches ? rank.name : anyTreeRank), - ]; - - current.table.fields - .filter((field) => !field.isVirtual && !field.isRelationship) - .filter((field) => { - const isHidden = - current.isHidden || - (spec.useSchemaOverrides - ? field.overrides.isHidden - : field.isHidden); - if (isHidden && !showHiddenFields) return false; - if ( - !spec.isNoRestrictions() && - !spec.includeReadOnly && - field.overrides.isReadOnly && - !(rankMatches && field.name === 'name') - ) - return false; - return rankMatches - ? field.name === 'name' - : matches([ - field.name, - fieldSearchLabel(field.label), - ...current.relationshipLabels, - ...current.relationshipNames, - ]); - }) - .forEach((field) => { - const isHidden = - current.isHidden || - (spec.useSchemaOverrides - ? field.overrides.isHidden - : field.isHidden); - addResult({ - mappingPath: [...rankPath, field.name], - label: [ - ...current.relationshipLabels, - ...(rankMatches - ? [rankLabel] - : [fieldSearchLabel(field.label)]), - ].join(' → '), - isHidden, - joinCount: current.joinCount, - tableName: current.table.name, - searchRank: rankMatches ? 0 : 1, - }); - }); - }); - }); - } - - if (current.joinCount >= fieldSearchDepthLimit) continue; - - current.table.fields - .filter((field): field is Relationship => field.isRelationship) - .filter((relationship) => { - // Someday when we support virtual fields in the Query Builder, - // this should be re-enabled. - if ( - relationship.isVirtual || - fieldSearchExcludedTables.has(relationship.relatedTable.name) - ) - return false; - const isHidden = current.isHidden || relationship.overrides.isHidden; - if (isHidden && !showHiddenFields) return false; - if ( - !spec.isNoRestrictions() && - !spec.includeReadOnly && - relationship.overrides.isReadOnly - ) - return false; - if ( - spec.hasActionPermission() && - spec.ensurePermission() !== undefined && - (isTreeTable(current.table.name) - ? !hasTreeAccess( - current.table.name as AnyTree['tableName'], - spec.ensurePermission()! - ) - : !hasTablePermission( - relationship.relatedTable.name, - spec.ensurePermission()! - )) - ) - return false; - if ( - !spec.allowNestedToMany && - (relationshipIsToMany(relationship) || - relationshipIsRemoteToOne(relationship)) && - current.mappingPath.some((part) => part.startsWith('#')) - ) - return false; - if ( - !spec.includeToManyToTree && - (relationshipIsToMany(relationship) || - relationshipIsRemoteToOne(relationship)) && - isTreeTable(relationship.relatedTable.name) - ) - return false; - return ( - !isTreeTable(current.table.name) || spec.includeRelationshipsFromTree - ); - }) - .forEach((relationship) => { - const relationshipIsToManyField = - relationshipIsToMany(relationship) || - relationshipIsRemoteToOne(relationship); - const treeRankPath = - isTreeTable(current.table.name) && - !valueIsTreeMeta(current.mappingPath.at(-1)) - ? [formatTreeRank(anyTreeRank)] - : []; - const relationshipPath = [ - ...current.mappingPath, - ...treeRankPath, - relationship.name, - ...(relationshipIsToManyField ? [formatToManyIndex(1)] : []), - ]; - if (matches([relationship.name, fieldSearchLabel(relationship.label)])) - addResult({ - mappingPath: [ - ...relationshipPath, - ...(isTreeTable(relationship.relatedTable.name) - ? [formatTreeRank(anyTreeRank)] - : []), - formattedEntry, - ], - label: [ - ...current.relationshipLabels, - fieldSearchLabel(relationship.label), - relationshipIsToManyField - ? queryText.aggregatedInline() - : queryText.formattedInline(), - ].join(' → '), - isHidden: current.isHidden || relationship.overrides.isHidden, - joinCount: current.joinCount + 1, - tableName: relationship.relatedTable.name, - searchRank: 1, - }); - - // Return matching self-referential relationships, but do not traverse - // through them or the search could revisit the same table forever. - if (current.tablePath.includes(relationship.relatedTable.name)) return; - - queue.push({ - table: relationship.relatedTable, - mappingPath: relationshipPath, - tablePath: [...current.tablePath, relationship.relatedTable.name], - relationshipLabels: [ - ...current.relationshipLabels, - fieldSearchLabel(relationship.label), - ], - relationshipNames: [...current.relationshipNames, relationship.name], - isHidden: current.isHidden || relationship.overrides.isHidden, - joinCount: current.joinCount + 1, - }); - }); - } - - const specialPath = ['determinations', 'taxon']; - return results - .sort((a, b) => { - const hiddenOrder = Number(a.isHidden) - Number(b.isHidden); - if (hiddenOrder !== 0) return hiddenOrder; - const depthOrder = a.joinCount - b.joinCount; - if (depthOrder !== 0) return depthOrder; - const pathPriorityOrder = - getFieldSearchPathPriority(baseTableName, a.mappingPath) - - getFieldSearchPathPriority(baseTableName, b.mappingPath); - if (pathPriorityOrder !== 0) return pathPriorityOrder; - const searchRankOrder = a.searchRank - b.searchRank; - if (searchRankOrder !== 0) return searchRankOrder; - const specialOrder = (path: MappingPath): number => - baseTableName === 'CollectionObject' && - path.includes(specialPath[0]) && - path.includes(specialPath[1]) - ? 0 - : 1; - return ( - specialOrder(a.mappingPath) - specialOrder(b.mappingPath) || - a.label.localeCompare(b.label) - ); - }) - .slice(offset, offset + Math.min(limit, fieldSearchResultLimit)); -} - /** * Get data required to build a mapping line from a source mapping path * Handles circular dependencies and must match tables From 0f6085046eac49200f6e93633d4405b70d2fbf0b Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:38:35 +0200 Subject: [PATCH 23/37] Potential fix for pull request finding 'CodeQL / Comparison between inconvertible types' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../js_src/lib/components/WbPlanView/MapperComponents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index a61fecfe73d..4f09096986f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -201,7 +201,7 @@ export function MappingView({ const mappingElementPropsWithSearch = mappingElementProps.map( (mappingElement, index) => - index === 0 && fieldSearch !== null + index === 0 ? { ...mappingElement, fieldSearch } : mappingElement ); From 5bc4c20739ffa63734cac40ac5338a949f0632cc Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:40:45 +0200 Subject: [PATCH 24/37] Update specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../lib/components/WbPlanView/fieldSearch.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts index 24d1832b011..43c51dbc9ca 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts @@ -289,18 +289,14 @@ export function searchFields({ relationship.overrides.isReadOnly ) return false; + const permission = spec.ensurePermission(); + const relatedName = relationship.relatedTable.name; if ( spec.hasActionPermission() && - spec.ensurePermission() !== undefined && - (isTreeTable(current.table.name) - ? !hasTreeAccess( - current.table.name as AnyTree['tableName'], - spec.ensurePermission()! - ) - : !hasTablePermission( - relationship.relatedTable.name, - spec.ensurePermission()! - )) + permission !== undefined && + (isTreeTable(relatedName) + ? !hasTreeAccess(relatedName, permission) + : !hasTablePermission(relatedName, permission)) ) return false; if ( From 0cfe7cdcc0aa6d582a72381a35e555182255f5e7 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 15:00:37 +0200 Subject: [PATCH 25/37] Fix: Remove import --- .../frontend/js_src/lib/components/WbPlanView/fieldSearch.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts index 43c51dbc9ca..da900c03a6a 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts @@ -1,7 +1,6 @@ import { commonText } from '../../localization/common'; import { queryText } from '../../localization/query'; import type { RA } from '../../utils/types'; -import type { AnyTree } from '../DataModel/helperTypes'; import type { Relationship } from '../DataModel/specifyField'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; From 69caf37a73426a20950ebd81a0f256b919aa66c9 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 15:54:26 +0200 Subject: [PATCH 26/37] Fix: Keyboard and short lists cannot load more results. --- .../WbPlanView/FieldSearchComponent.tsx | 55 +++++++---- .../__tests__/FieldSearchComponent.test.tsx | 98 +++++++++++++++++++ .../js_src/lib/localization/common.ts | 3 + 3 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx index 0b74fc92513..d9deb89f0fa 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { commonText } from '../../localization/common'; import { expressSearchConfigText } from '../../localization/expressSearchConfig'; import type { Tables } from '../DataModel/types'; +import { Button } from '../Atoms/Button'; import { Input } from '../Atoms/Form'; import { TableIcon } from '../Molecules/TableIcon'; import type { MappingPath } from './Mapper'; @@ -10,6 +11,7 @@ import { searchFields } from './fieldSearch'; import type { NavigatorSpec } from './navigatorSpecs'; const fieldSearchDebounceRate = 200; +const fieldSearchPageSize = 10; export function FieldSearch({ baseTableName, @@ -26,7 +28,8 @@ export function FieldSearch({ }): JSX.Element | null { const [search, setSearch] = React.useState(''); const [debouncedSearch, setDebouncedSearch] = React.useState(''); - const [visibleResultCount, setVisibleResultCount] = React.useState(10); + const [visibleResultCount, setVisibleResultCount] = + React.useState(fieldSearchPageSize); React.useEffect(() => { const timeout = globalThis.setTimeout( () => setDebouncedSearch(search), @@ -47,7 +50,7 @@ export function FieldSearch({ [baseTableName, debouncedSearch, searchSpec, showHiddenFields] ); React.useEffect(() => { - setVisibleResultCount(10); + setVisibleResultCount(fieldSearchPageSize); }, [baseTableName, search, searchSpec, showHiddenFields]); if ( @@ -56,6 +59,12 @@ export function FieldSearch({ ) return null; + const hasMoreResults = searchResults.length > visibleResultCount; + const handleShowMore = (): void => + setVisibleResultCount((count) => + Math.min(count + fieldSearchPageSize, searchResults.length) + ); + return (
{commonText.noResults()}
) : ( - searchResults.slice(0, visibleResultCount).map((result) => ( - - )) + <> + {searchResults.slice(0, visibleResultCount).map((result) => ( + + ))} + {hasMoreResults && ( + + {commonText.showMore()} + + )} + )}
)} diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx new file mode 100644 index 00000000000..1a586a85c2b --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx @@ -0,0 +1,98 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { commonText } from '../../../localization/common'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { FieldSearch } from '../FieldSearchComponent'; +import { searchFields } from '../fieldSearch'; +import { navigatorSpecs } from '../navigatorSpecs'; + +requireContext(); + +const resultPageSize = 10; + +const renderFieldSearch = () => { + const handleSelect = jest.fn(); + const { user } = mount( + + ); + return { handleSelect, user }; +}; + +const getShowMore = (): HTMLElement => + screen.getByRole('button', { name: commonText.showMore() }); + +/** Count the result buttons, excluding the "show more" button */ +const countResults = (): number => + screen + .getAllByRole('button') + .filter((button) => button.textContent !== commonText.showMore()).length; + +const waitForResults = async (): Promise => { + // Wait out the search debounce + await waitFor(() => expect(countResults()).toBeGreaterThan(0)); +}; + +test('shows a "show more" button when more results are available', async () => { + const { user } = renderFieldSearch(); + await user.type(screen.getByRole('textbox'), 'catalog'); + await waitForResults(); + + expect(countResults()).toBe(resultPageSize); + expect(getShowMore()).toBeInTheDocument(); +}); + +test('"show more" reveals the next batch without scrolling', async () => { + const { user } = renderFieldSearch(); + await user.type(screen.getByRole('textbox'), 'catalog'); + await waitForResults(); + + await user.click(getShowMore()); + expect(countResults()).toBe(resultPageSize * 2); + + await user.click(getShowMore()); + expect(countResults()).toBe(resultPageSize * 3); +}); + +test('"show more" stops once every result is visible', async () => { + const search = 'catalogNumber'; + const totalResults = searchFields({ + baseTableName: 'CollectionObject', + search, + spec: navigatorSpecs.wbPlanView, + }).length; + expect(totalResults).toBeGreaterThan(resultPageSize); + + const { user } = renderFieldSearch(); + await user.type(screen.getByRole('textbox'), search); + await waitForResults(); + + let clickCount = 0; + while ( + screen.queryByRole('button', { name: commonText.showMore() }) !== null + ) { + // eslint-disable-next-line no-await-in-loop + await user.click(getShowMore()); + clickCount += 1; + expect(clickCount).toBeLessThan(totalResults); + } + + // The last batch is capped at the total rather than overshooting it + expect(countResults()).toBe(totalResults); +}); + +test('"show more" does not select a result', async () => { + const { handleSelect, user } = renderFieldSearch(); + await user.type(screen.getByRole('textbox'), 'catalog'); + await waitForResults(); + + await user.click(getShowMore()); + expect(handleSelect).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toHaveValue('catalog'); +}); diff --git a/specifyweb/frontend/js_src/lib/localization/common.ts b/specifyweb/frontend/js_src/lib/localization/common.ts index b670bcf578c..3f6fc5163b3 100644 --- a/specifyweb/frontend/js_src/lib/localization/common.ts +++ b/specifyweb/frontend/js_src/lib/localization/common.ts @@ -365,6 +365,9 @@ export const commonText = createDictionary({ 'hr-hr': 'Nema rezultata', nb: 'Ingen resultater', }, + showMore: { + 'en-us': 'Show More', + }, notApplicable: { 'en-us': 'N/A', 'ru-ru': 'Н/Д', From fe7de4e4797bca75531ac14f0df31f9a50e89a86 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 16:04:36 +0200 Subject: [PATCH 27/37] Feat: Allow arrow navigation. --- .../lib/components/Molecules/AutoComplete.tsx | 22 +-- .../WbPlanView/FieldSearchComponent.tsx | 137 +++++++++++++++--- .../js_src/lib/utils/keyboardNavigation.ts | 26 ++++ 3 files changed, 151 insertions(+), 34 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/utils/keyboardNavigation.ts diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx index f9f3c83f412..b3afb4e8a46 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/AutoComplete.tsx @@ -9,6 +9,7 @@ import { useTriggerState } from '../../hooks/useTriggerState'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; import { listen } from '../../utils/events'; +import { getListKeyboardNavigationIndex } from '../../utils/keyboardNavigation'; import type { RA } from '../../utils/types'; import { DialogContext } from '../Atoms/Button'; import { className } from '../Atoms/className'; @@ -280,22 +281,21 @@ export function AutoComplete({ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); event.stopPropagation(); - const direction = event.key === 'ArrowDown' ? 1 : -1; - const nextIndex = - activeIndex === -1 - ? direction === 1 - ? 0 - : itemSource.length - 1 - : Math.min( - itemSource.length - 1, - Math.max(0, activeIndex + direction) - ); + const nextIndex = getListKeyboardNavigationIndex({ + activeIndex, + itemCount: itemSource.length, + key: event.key, + }); setActiveIndex(nextIndex); virtualizer.scrollToIndex(nextIndex); } else if (event.key === 'Home' || event.key === 'End') { event.preventDefault(); event.stopPropagation(); - const nextIndex = event.key === 'Home' ? 0 : itemSource.length - 1; + const nextIndex = getListKeyboardNavigationIndex({ + activeIndex, + itemCount: itemSource.length, + key: event.key, + }); setActiveIndex(nextIndex); virtualizer.scrollToIndex(nextIndex); } else if (event.key === 'Enter' && activeIndex >= 0) { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx index d9deb89f0fa..f3dd5d8d66e 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { commonText } from '../../localization/common'; import { expressSearchConfigText } from '../../localization/expressSearchConfig'; +import { getListKeyboardNavigationIndex } from '../../utils/keyboardNavigation'; import type { Tables } from '../DataModel/types'; import { Button } from '../Atoms/Button'; import { Input } from '../Atoms/Form'; @@ -26,10 +27,16 @@ export function FieldSearch({ | ((mappingPath: MappingPath) => void) | undefined; }): JSX.Element | null { + const fieldSearchId = React.useId(); + const resultsListId = `${fieldSearchId}-results`; const [search, setSearch] = React.useState(''); const [debouncedSearch, setDebouncedSearch] = React.useState(''); const [visibleResultCount, setVisibleResultCount] = React.useState(fieldSearchPageSize); + const [activeResultIndex, setActiveResultIndex] = React.useState< + number | undefined + >(undefined); + const [isDismissed, setIsDismissed] = React.useState(false); React.useEffect(() => { const timeout = globalThis.setTimeout( () => setDebouncedSearch(search), @@ -51,7 +58,18 @@ export function FieldSearch({ ); React.useEffect(() => { setVisibleResultCount(fieldSearchPageSize); + setActiveResultIndex(undefined); + setIsDismissed(false); }, [baseTableName, search, searchSpec, showHiddenFields]); + const visibleResults = searchResults.slice(0, visibleResultCount); + const isExpanded = search.length > 0 && !isDismissed; + React.useEffect(() => { + if (activeResultIndex === undefined || !isExpanded) return; + const activeResult = document.getElementById( + `${fieldSearchId}-result-${activeResultIndex}` + ); + activeResult?.scrollIntoView?.({ block: 'nearest' }); + }, [activeResultIndex, fieldSearchId, isExpanded]); if ( baseTableName === undefined || @@ -64,6 +82,56 @@ export function FieldSearch({ setVisibleResultCount((count) => Math.min(count + fieldSearchPageSize, searchResults.length) ); + const handleSelect = (resultIndex: number): void => { + const result = searchResults[resultIndex]; + if (result === undefined) return; + handleSelectSearchResult(result.mappingPath); + setSearch(''); + setActiveResultIndex(undefined); + }; + const handleKeyDown = ( + event: React.KeyboardEvent + ): void => { + if (event.key === 'Escape' && search.length > 0) { + event.preventDefault(); + event.stopPropagation(); + setIsDismissed(true); + setActiveResultIndex(undefined); + return; + } + + if ( + (event.key === 'Enter' || event.key === 'ArrowRight') && + activeResultIndex !== undefined + ) { + event.preventDefault(); + event.stopPropagation(); + handleSelect(activeResultIndex); + return; + } + + if ( + !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) || + searchResults.length === 0 + ) + return; + + event.preventDefault(); + event.stopPropagation(); + setIsDismissed(false); + const shouldShowMore = + event.key === 'ArrowDown' && + activeResultIndex === visibleResults.length - 1 && + hasMoreResults; + if (shouldShowMore) handleShowMore(); + const nextIndex = getListKeyboardNavigationIndex({ + activeIndex: activeResultIndex ?? -1, + itemCount: visibleResults.length + (shouldShowMore ? 1 : 0), + key: event.key, + wrap: true, + }); + setActiveResultIndex(nextIndex === -1 ? undefined : nextIndex); + }; return (
- {search.length > 0 && ( + {isExpanded && (
{ const element = event.currentTarget; @@ -93,33 +170,47 @@ export function FieldSearch({ handleShowMore(); }} > - {searchResults.length === 0 ? ( -
{commonText.noResults()}
- ) : ( - <> - {searchResults.slice(0, visibleResultCount).map((result) => ( +
+ {searchResults.length === 0 ? ( +
{commonText.noResults()}
+ ) : ( + visibleResults.map((result, index) => ( - ))} - {hasMoreResults && ( - - {commonText.showMore()} - - )} - + )) + )} +
+ {hasMoreResults && ( + + {commonText.showMore()} + )}
)} diff --git a/specifyweb/frontend/js_src/lib/utils/keyboardNavigation.ts b/specifyweb/frontend/js_src/lib/utils/keyboardNavigation.ts new file mode 100644 index 00000000000..a056b21ee28 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/utils/keyboardNavigation.ts @@ -0,0 +1,26 @@ +export function getListKeyboardNavigationIndex({ + activeIndex, + itemCount, + key, + wrap = false, +}: { + readonly activeIndex: number; + readonly itemCount: number; + readonly key: string; + readonly wrap?: boolean; +}): number { + if (key === 'Home') return 0; + if (key === 'End') return itemCount - 1; + + const direction = key === 'ArrowDown' ? 1 : -1; + if (activeIndex === -1) return direction === 1 ? 0 : itemCount - 1; + + const nextIndex = activeIndex + direction; + if (wrap) + return nextIndex < 0 + ? itemCount - 1 + : nextIndex >= itemCount + ? 0 + : nextIndex; + return Math.min(itemCount - 1, Math.max(0, nextIndex)); +} From b1b02440fa838f2e197655e2b4e43124aa88dfad Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 16:10:23 +0200 Subject: [PATCH 28/37] Tests: Add arrow navigation tests --- .../__tests__/FieldSearchComponent.test.tsx | 118 ++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx index 1a586a85c2b..e65914fa548 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx @@ -28,20 +28,18 @@ const renderFieldSearch = () => { const getShowMore = (): HTMLElement => screen.getByRole('button', { name: commonText.showMore() }); -/** Count the result buttons, excluding the "show more" button */ -const countResults = (): number => - screen - .getAllByRole('button') - .filter((button) => button.textContent !== commonText.showMore()).length; +const countResults = (): number => screen.queryAllByRole('option').length; const waitForResults = async (): Promise => { // Wait out the search debounce await waitFor(() => expect(countResults()).toBeGreaterThan(0)); }; +const getSearchInput = (): HTMLElement => screen.getByRole('combobox'); + test('shows a "show more" button when more results are available', async () => { const { user } = renderFieldSearch(); - await user.type(screen.getByRole('textbox'), 'catalog'); + await user.type(getSearchInput(), 'catalog'); await waitForResults(); expect(countResults()).toBe(resultPageSize); @@ -50,7 +48,7 @@ test('shows a "show more" button when more results are available', async () => { test('"show more" reveals the next batch without scrolling', async () => { const { user } = renderFieldSearch(); - await user.type(screen.getByRole('textbox'), 'catalog'); + await user.type(getSearchInput(), 'catalog'); await waitForResults(); await user.click(getShowMore()); @@ -70,7 +68,7 @@ test('"show more" stops once every result is visible', async () => { expect(totalResults).toBeGreaterThan(resultPageSize); const { user } = renderFieldSearch(); - await user.type(screen.getByRole('textbox'), search); + await user.type(getSearchInput(), search); await waitForResults(); let clickCount = 0; @@ -89,10 +87,110 @@ test('"show more" stops once every result is visible', async () => { test('"show more" does not select a result', async () => { const { handleSelect, user } = renderFieldSearch(); - await user.type(screen.getByRole('textbox'), 'catalog'); + await user.type(getSearchInput(), 'catalog'); await waitForResults(); await user.click(getShowMore()); expect(handleSelect).not.toHaveBeenCalled(); - expect(screen.getByRole('textbox')).toHaveValue('catalog'); + expect(getSearchInput()).toHaveValue('catalog'); +}); + +test('exposes results as a keyboard-controlled listbox', async () => { + const { user } = renderFieldSearch(); + const input = getSearchInput(); + await user.type(input, 'catalog'); + await waitForResults(); + + const options = screen.getAllByRole('option'); + expect(input).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('listbox')).toHaveAttribute( + 'id', + input.getAttribute('aria-controls') + ); + options.forEach((option) => expect(option).toHaveAttribute('tabindex', '-1')); + + await user.keyboard('{ArrowDown}'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[0]).toHaveClass('bg-gray-200'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + + await user.keyboard('{ArrowDown}'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[0]).not.toHaveClass('bg-gray-200'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveClass('bg-gray-200'); + + await user.keyboard('{ArrowUp}'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); +}); + +test('selects the active result with Enter', async () => { + const { handleSelect, user } = renderFieldSearch(); + await user.type(getSearchInput(), 'catalog'); + await waitForResults(); + + const expectedResult = searchFields({ + baseTableName: 'CollectionObject', + search: 'catalog', + spec: navigatorSpecs.wbPlanView, + })[0]; + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + expect(handleSelect).toHaveBeenCalledWith(expectedResult.mappingPath); + expect(getSearchInput()).toHaveValue(''); +}); + +test('selects the active result with ArrowRight', async () => { + const { handleSelect, user } = renderFieldSearch(); + await user.type(getSearchInput(), 'catalog'); + await waitForResults(); + + const expectedResult = searchFields({ + baseTableName: 'CollectionObject', + search: 'catalog', + spec: navigatorSpecs.wbPlanView, + })[1]; + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + + expect(handleSelect).toHaveBeenCalledWith(expectedResult.mappingPath); + expect(getSearchInput()).toHaveValue(''); +}); + +test('dismisses results with Escape and reopens them with an arrow key', async () => { + const { user } = renderFieldSearch(); + const input = getSearchInput(); + await user.type(input, 'catalog'); + await waitForResults(); + + await user.keyboard('{Escape}'); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('listbox')).toBeNull(); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getAllByRole('option')[0]).toHaveAttribute( + 'aria-selected', + 'true' + ); +}); + +test('ArrowDown reveals and activates the next result page', async () => { + const { user } = renderFieldSearch(); + await user.type(getSearchInput(), 'catalog'); + await waitForResults(); + + for (let index = 0; index < resultPageSize; index += 1) { + // Each keyboard call must render the state used by the next key handler. + // eslint-disable-next-line no-await-in-loop + await user.keyboard('{ArrowDown}'); + } + expect(screen.getAllByRole('option')).toHaveLength(resultPageSize); + + await user.keyboard('{ArrowDown}'); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(resultPageSize * 2); + expect(options[resultPageSize]).toHaveAttribute('aria-selected', 'true'); }); From 52fc6fb7ca56e66e3d1ddb0ef825e34b90431160 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:19:31 +0200 Subject: [PATCH 29/37] Update specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../js_src/lib/components/QueryBuilder/Wrapped.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index dc4e0b3c7e4..ef5f36c62de 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -471,9 +471,17 @@ function Wrapped({ })} showHiddenFields={showHiddenFields} searchSpec={navigatorSpecs.queryBuilder} - onSelectSearchResult={(mappingPath): void => - dispatch({ type: 'SetMappingViewAction', mappingPath }) - } + onSelectSearchResult={(mappingPath): void => { + dispatch({ type: 'SetMappingViewAction', mappingPath }); + if ( + !isReadOnly && + mappingPathIsComplete(mappingPath) && + !getMappedFieldsBind(mappingPath.slice(0, -1)).includes( + mappingPath.at(-1)! + ) + ) + handleAddField(mappingPath); + }} > {isReadOnly ? undefined : ( Date: Tue, 13 Oct 2026 16:20:00 +0200 Subject: [PATCH 30/37] Update specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../js_src/lib/components/WbPlanView/Mapper.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx index d7295d54139..8fdbd11a5fc 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx @@ -549,9 +549,16 @@ export function Mapper(props: { }, })} showHiddenFields={state.showHiddenFields} - onSelectSearchResult={(mappingPath): void => + onSelectSearchResult={(mappingPath): void => { dispatch({ type: 'SetMappingViewAction', mappingPath }) - } + if ( + !isReadOnly && + state.lines.length > 0 && + mappingPathIsComplete(mappingPath) && + getMappedFieldsBind(mappingPath).length === 0 + ) + dispatch({ type: 'MappingViewMapAction' }) + }} > Date: Tue, 13 Oct 2026 16:34:48 +0200 Subject: [PATCH 31/37] Fix: Avoid a full graph traversal on every query. --- .../components/WbPlanView/FieldSearchComponent.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx index f3dd5d8d66e..7eb654da46c 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx @@ -13,6 +13,14 @@ import type { NavigatorSpec } from './navigatorSpecs'; const fieldSearchDebounceRate = 200; const fieldSearchPageSize = 10; +/* + * Searching for a single character matches almost every field, which makes the + * traversal walk the entire relationship graph for results that are not useful. + */ +const fieldSearchMinLength = 2; + +const isSearchLongEnough = (search: string): boolean => + search.trim().length >= fieldSearchMinLength; export function FieldSearch({ baseTableName, @@ -46,7 +54,7 @@ export function FieldSearch({ }, [search]); const searchResults = React.useMemo( () => - baseTableName === undefined + baseTableName === undefined || !isSearchLongEnough(debouncedSearch) ? [] : searchFields({ baseTableName, @@ -62,7 +70,7 @@ export function FieldSearch({ setIsDismissed(false); }, [baseTableName, search, searchSpec, showHiddenFields]); const visibleResults = searchResults.slice(0, visibleResultCount); - const isExpanded = search.length > 0 && !isDismissed; + const isExpanded = isSearchLongEnough(search) && !isDismissed; React.useEffect(() => { if (activeResultIndex === undefined || !isExpanded) return; const activeResult = document.getElementById( @@ -92,7 +100,7 @@ export function FieldSearch({ const handleKeyDown = ( event: React.KeyboardEvent ): void => { - if (event.key === 'Escape' && search.length > 0) { + if (event.key === 'Escape' && isExpanded) { event.preventDefault(); event.stopPropagation(); setIsDismissed(true); From 9cbbe5fe18bac5682a7e3521fc06f94e4d31bc1e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 16:35:32 +0200 Subject: [PATCH 32/37] Test: Add test for graph travers avoidance on every query. --- .../__tests__/FieldSearchComponent.test.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx index e65914fa548..bddb7b7abd5 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { commonText } from '../../../localization/common'; @@ -8,6 +8,15 @@ import { FieldSearch } from '../FieldSearchComponent'; import { searchFields } from '../fieldSearch'; import { navigatorSpecs } from '../navigatorSpecs'; +jest.mock('../fieldSearch', () => { + const actual = jest.requireActual('../fieldSearch'); + return { ...actual, searchFields: jest.fn(actual.searchFields) }; +}); + +const searchFieldsSpy = searchFields as jest.MockedFunction< + typeof searchFields +>; + requireContext(); const resultPageSize = 10; @@ -37,6 +46,34 @@ const waitForResults = async (): Promise => { const getSearchInput = (): HTMLElement => screen.getByRole('combobox'); +test('does not search until the query is long enough', async () => { + const { user } = renderFieldSearch(); + const input = getSearchInput(); + await user.type(input, 'c'); + + // Wait out the search debounce to make sure no search is dispatched + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + }); + + expect(searchFieldsSpy).not.toHaveBeenCalled(); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(screen.queryByText(commonText.noResults())).toBeNull(); +}); + +test('searches once the query is long enough', async () => { + const { user } = renderFieldSearch(); + const input = getSearchInput(); + await user.type(input, 'ca'); + await waitForResults(); + + expect(searchFieldsSpy).toHaveBeenCalled(); + expect(input).toHaveAttribute('aria-expanded', 'true'); +}); + test('shows a "show more" button when more results are available', async () => { const { user } = renderFieldSearch(); await user.type(getSearchInput(), 'catalog'); From 4660435926a32f0fcd8092b99ba0c406a03bfc83 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 19:37:39 +0200 Subject: [PATCH 33/37] Fix: Bound the queue size, not only the dequeue count. --- .../frontend/js_src/lib/components/WbPlanView/fieldSearch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts index da900c03a6a..da4a1cef2d3 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts @@ -357,6 +357,7 @@ export function searchFields({ // through them or the search could revisit the same table forever. if (current.tablePath.includes(relationship.relatedTable.name)) return; + if (queue.length >= fieldSearchVisitedStateLimit) return; queue.push({ table: relationship.relatedTable, mappingPath: relationshipPath, From c2fa99a20bfaeb67a97c9a01d08170f4042a7b64 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 19:43:23 +0200 Subject: [PATCH 34/37] Remove weblate token check --- .github/workflows/test.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37b6908e070..36ee58a26fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -224,13 +224,6 @@ jobs: git push fi - - name: Test Weblate API addon access - env: - WEBLATE_API_TOKEN: ${{ secrets.WEBLATE_API_TOKEN }} - run: | - curl --fail-with-body --show-error \ - -H "Authorization: Token $WEBLATE_API_TOKEN" \ - https://hosted.weblate.org/api/addons/34603/ # This step must be run after pushing to github or else it will fail in # cases when new components are to be created (Weblate requires files to # be pushed before component can be created) From 9c708cbe0992f79c0a22737dbef134ba48054143 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 19:59:57 +0200 Subject: [PATCH 35/37] FFix: Hide results while the query is debouncing. --- .../js_src/lib/components/WbPlanView/FieldSearchComponent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx index 7eb654da46c..a39c614bc3f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx @@ -70,7 +70,8 @@ export function FieldSearch({ setIsDismissed(false); }, [baseTableName, search, searchSpec, showHiddenFields]); const visibleResults = searchResults.slice(0, visibleResultCount); - const isExpanded = isSearchLongEnough(search) && !isDismissed; + const isExpanded = + search === debouncedSearch && isSearchLongEnough(search) && !isDismissed; React.useEffect(() => { if (activeResultIndex === undefined || !isExpanded) return; const activeResult = document.getElementById( From 8e622705f8be563328dcd469c21ed431ff73aa21 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 13 Oct 2026 20:01:07 +0200 Subject: [PATCH 36/37] Fix: The search-result map action can overwrite a mapped focused line. --- .../frontend/js_src/lib/components/WbPlanView/Mapper.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx index 8fdbd11a5fc..acdf1429792 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx @@ -550,14 +550,17 @@ export function Mapper(props: { })} showHiddenFields={state.showHiddenFields} onSelectSearchResult={(mappingPath): void => { - dispatch({ type: 'SetMappingViewAction', mappingPath }) + dispatch({ type: 'SetMappingViewAction', mappingPath }); if ( !isReadOnly && state.lines.length > 0 && + !mappingPathIsComplete( + state.lines[state.focusedLine]?.mappingPath ?? [emptyMapping] + ) && mappingPathIsComplete(mappingPath) && getMappedFieldsBind(mappingPath).length === 0 ) - dispatch({ type: 'MappingViewMapAction' }) + dispatch({ type: 'MappingViewMapAction' }); }} > Date: Tue, 13 Oct 2026 20:12:55 +0200 Subject: [PATCH 37/37] Update specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../frontend/js_src/lib/components/WbPlanView/fieldSearch.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts index da4a1cef2d3..43207118415 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts @@ -195,9 +195,12 @@ export function searchFields({ }); }); + const treePermission = spec.hasActionPermission() + ? spec.ensurePermission() + : undefined; if ( isTreeTable(current.table.name) && - hasTreeAccess(current.table.name, 'read') + hasTreeAccess(current.table.name, treePermission ?? 'read') ) { const definitions = getTreeDefinitions(current.table.name, 'all') ?? []; definitions.forEach(({ definition, ranks }) => {