Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d20746a
feat(field-mapper): add search for field names
grantfitzsimmons Sep 29, 2026
9fbc91d
feat(field-mapper): support formatted and aggregated
grantfitzsimmons Sep 29, 2026
b840652
feat(field-mapper): add table icons
grantfitzsimmons Sep 29, 2026
3a79d3f
feat(field-mapper): add field priorities
grantfitzsimmons Sep 30, 2026
9243255
fix(field-mapper): handle missing trees
grantfitzsimmons Sep 30, 2026
681e82c
fix(field-mapper): search now shows fields based on context
grantfitzsimmons Sep 30, 2026
52fcd61
feat(field-mapper) limit search results on backend
grantfitzsimmons Sep 30, 2026
67c71dd
fix(field-mapper): stop searching virtual fields
grantfitzsimmons Sep 30, 2026
bb7a129
fix(field-mapper): use name instead of fullname
grantfitzsimmons Sep 30, 2026
e241572
fix(field-mapper): remove redundant table name
grantfitzsimmons Sep 30, 2026
6ffe337
fix(field-mapper): search tree on any rank
grantfitzsimmons Oct 1, 2026
b13d682
fix(field-mapper): style search field properly
grantfitzsimmons Oct 1, 2026
a205670
fix(field-mapper): exclude internal tables from search
grantfitzsimmons Oct 1, 2026
7707300
fix(field-mapper): support for limited self-relationships
grantfitzsimmons Oct 1, 2026
ab506b0
fix(field-mapper): make search resolve (formatted) tree paths
grantfitzsimmons Oct 1, 2026
e430543
feat(field-mapper): clarify search label
grantfitzsimmons Oct 1, 2026
de95bea
fix(field-mapper): add result cap after sorting
grantfitzsimmons Oct 1, 2026
e9993a2
feat(field-mapper): add debounce for search
grantfitzsimmons Oct 1, 2026
9a763f2
fix(field-mapper): make search show ID
grantfitzsimmons Oct 1, 2026
659c816
fix(field-mapper): remove listbox
grantfitzsimmons Oct 1, 2026
12f8e86
fix(field-mapper): fix pagination logic
grantfitzsimmons Oct 1, 2026
22e09a7
Merge branch 'main' into issue-7499
grantfitzsimmons Oct 1, 2026
9739fc7
Refactor: Create dedicated files for field search
CarolineDenis Oct 13, 2026
0f60850
Potential fix for pull request finding 'CodeQL / Comparison between i…
CarolineDenis Oct 13, 2026
5bc4c20
Update specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSear…
CarolineDenis Oct 13, 2026
0cfe7cd
Fix: Remove import
CarolineDenis Oct 13, 2026
69caf37
Fix: Keyboard and short lists cannot load more results.
CarolineDenis Oct 13, 2026
fe7de4e
Feat: Allow arrow navigation.
CarolineDenis Oct 13, 2026
b1b0244
Tests: Add arrow navigation tests
CarolineDenis Oct 13, 2026
3ce8121
Merge remote-tracking branch 'origin/main' into issue-7499
CarolineDenis Oct 13, 2026
52fc6fb
Update specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped…
CarolineDenis Oct 13, 2026
a0cb3de
Update specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx
CarolineDenis Oct 13, 2026
690149e
Fix: Avoid a full graph traversal on every query.
CarolineDenis Oct 13, 2026
9cbbe5f
Test: Add test for graph travers avoidance on every query.
CarolineDenis Oct 13, 2026
4660435
Fix: Bound the queue size, not only the dequeue count.
CarolineDenis Oct 13, 2026
c2fa99a
Remove weblate token check
CarolineDenis Oct 13, 2026
9c708cb
FFix: Hide results while the query is debouncing.
CarolineDenis Oct 13, 2026
8e62270
Fix: The search-result map action can overwrite a mapped focused line.
CarolineDenis Oct 13, 2026
79ed7ae
Update specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSear…
CarolineDenis Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -280,22 +281,21 @@ export function AutoComplete<T>({
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) {
Expand Down
14 changes: 14 additions & 0 deletions specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ function Wrapped({
<div className="flex snap-start flex-col gap-4 overflow-y-auto">
{showMappingView && (
<MappingView
baseTableName={state.baseTableName}
mappingElementProps={getMappingLineProps({
mappingLineData: getMappingLineData({
baseTableName: state.baseTableName,
Expand Down Expand Up @@ -468,6 +469,19 @@ function Wrapped({
});
},
})}
showHiddenFields={showHiddenFields}
searchSpec={navigatorSpecs.queryBuilder}
onSelectSearchResult={(mappingPath): void => {
dispatch({ type: 'SetMappingViewAction', mappingPath });
if (
!isReadOnly &&
mappingPathIsComplete(mappingPath) &&
!getMappedFieldsBind(mappingPath.slice(0, -1)).includes(
mappingPath.at(-1)!
)
)
handleAddField(mappingPath);
}}
>
{isReadOnly ? undefined : (
<Button.Small
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const getInitialState = ({
});

type Actions =
| Action<'SetMappingViewAction', { readonly mappingPath: MappingPath }>
| Action<
'ChangeFieldAction',
{ readonly line: number; readonly field: QueryField }
Expand Down Expand Up @@ -91,6 +92,10 @@ type Actions =
| Action<'SavedQueryAction'>;

export const reducer = generateReducer<MainState, Actions>({
SetMappingViewAction: ({ state, action }) => ({
...state,
mappingView: action.mappingPath,
}),
ResetStateAction: ({ action: { state } }) => ({
...state,
openedElement: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ type CustomSelectElementPropsBase = {
readonly onClose?: () => void;
readonly customSelectOptionGroups?: IR<CustomSelectElementOptionGroupProps>;
readonly autoMapperSuggestions?: JSX.Element;
readonly fieldSearch?: JSX.Element;
};

export type CustomSelectElementPropsClosed = CustomSelectElementPropsBase & {
Expand Down Expand Up @@ -467,6 +468,7 @@ export function CustomSelectElement({
onClose: handleClose,
previewOption,
autoMapperSuggestions,
fieldSearch,
validation,
}: CustomSelectElementPropsClosed | CustomSelectElementPropsOpen): JSX.Element {
const has = React.useCallback(
Expand Down Expand Up @@ -698,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' : ''}
Expand Down Expand Up @@ -748,7 +751,7 @@ export function CustomSelectElement({
<article
aria-live={has('interactive') ? 'polite' : 'off'}
className={`
custom-select relative flex h-8 flex-col
custom-select relative flex ${fieldSearch ? 'h-auto w-full' : 'h-8'} flex-col
custom-select-${upperToKebab(customSelectType)}
${customSelectClassNames[customSelectType] ?? ''}
`}
Expand Down Expand Up @@ -833,6 +836,7 @@ export function CustomSelectElement({
>
{autoMapperSuggestions}
{header}
{fieldSearch}
{preview}
{optionsShadow}
{customSelectOptions}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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';
import { TableIcon } from '../Molecules/TableIcon';
import type { MappingPath } from './Mapper';
import { searchFields } from './fieldSearch';
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,
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 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),
fieldSearchDebounceRate
);
return (): void => globalThis.clearTimeout(timeout);
}, [search]);
const searchResults = React.useMemo(
() =>
baseTableName === undefined || !isSearchLongEnough(debouncedSearch)
? []
: searchFields({
baseTableName,
search: debouncedSearch,
showHiddenFields,
spec: searchSpec,
}),
[baseTableName, debouncedSearch, searchSpec, showHiddenFields]
);
React.useEffect(() => {
setVisibleResultCount(fieldSearchPageSize);
setActiveResultIndex(undefined);
setIsDismissed(false);
}, [baseTableName, search, searchSpec, showHiddenFields]);
const visibleResults = searchResults.slice(0, visibleResultCount);
const isExpanded =
search === debouncedSearch && isSearchLongEnough(search) && !isDismissed;
React.useEffect(() => {
if (activeResultIndex === undefined || !isExpanded) return;
const activeResult = document.getElementById(
`${fieldSearchId}-result-${activeResultIndex}`
);
activeResult?.scrollIntoView?.({ block: 'nearest' });
}, [activeResultIndex, fieldSearchId, isExpanded]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (
baseTableName === undefined ||
typeof handleSelectSearchResult !== 'function'
)
return null;

const hasMoreResults = searchResults.length > visibleResultCount;
const handleShowMore = (): void =>
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<HTMLInputElement>
): void => {
if (event.key === 'Escape' && isExpanded) {
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 (
<div
className="
relative flex w-full flex-col border border-brand-300
bg-white dark:bg-neutral-800
"
>
<Input.Text
aria-activedescendant={
activeResultIndex === undefined || !isExpanded
? undefined
: `${fieldSearchId}-result-${activeResultIndex}`
}
aria-autocomplete="list"
aria-controls={resultsListId}
aria-expanded={isExpanded}
aria-label={commonText.search()}
className="w-full rounded-none border-0 !ring-0 !shadow-none focus:border-0 focus:!ring-0"
id={`${fieldSearchId}-input`}
placeholder={expressSearchConfigText.searchFieldsTab()}
role="combobox"
value={search}
onKeyDown={handleKeyDown}
onValueChange={setSearch}
/>
Comment thread
CarolineDenis marked this conversation as resolved.
{isExpanded && (
<div
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"
onScroll={(event): void => {
const element = event.currentTarget;
if (
element.scrollTop + element.clientHeight >=
element.scrollHeight - 8
)
handleShowMore();
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<div
aria-label={commonText.search()}
id={resultsListId}
role="listbox"
>
{searchResults.length === 0 ? (
<div className="p-2">{commonText.noResults()}</div>
) : (
visibleResults.map((result, index) => (
<button
aria-selected={activeResultIndex === index}
className={`
flex w-full items-center gap-2 p-2 text-left
hover:bg-gray-200 dark:hover:bg-neutral-700
${
activeResultIndex === index
? 'bg-gray-200 dark:bg-neutral-700'
: ''
}
`}
id={`${fieldSearchId}-result-${index}`}
key={result.mappingPath.join('.')}
role="option"
tabIndex={-1}
type="button"
onClick={(): void => handleSelect(index)}
onMouseEnter={(): void => setActiveResultIndex(index)}
>
<TableIcon label={false} name={result.tableName} />
{result.label}
</button>
))
)}
</div>
{hasMoreResults && (
<Button.LikeLink
className="w-full justify-center p-2"
onClick={handleShowMore}
>
{commonText.showMore()}
</Button.LikeLink>
)}
</div>
)}
</div>
);
}
Loading
Loading