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) 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/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index f7e26264e1d..ef5f36c62de 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -416,6 +416,7 @@ function Wrapped({
{showMappingView && ( { + dispatch({ type: 'SetMappingViewAction', mappingPath }); + if ( + !isReadOnly && + mappingPathIsComplete(mappingPath) && + !getMappedFieldsBind(mappingPath.slice(0, -1)).includes( + mappingPath.at(-1)! + ) + ) + handleAddField(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..5d411eb186d 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( @@ -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' : ''} @@ -748,7 +751,7 @@ export function CustomSelectElement({
{autoMapperSuggestions} {header} + {fieldSearch} {preview} {optionsShadow} {customSelectOptions} 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..a39c614bc3f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx @@ -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]); + + 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 + ): 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 ( +
+ + {isExpanded && ( +
{ + const element = event.currentTarget; + if ( + element.scrollTop + element.clientHeight >= + element.scrollHeight - 8 + ) + handleShowMore(); + }} + > +
+ {searchResults.length === 0 ? ( +
{commonText.noResults()}
+ ) : ( + visibleResults.map((result, index) => ( + + )) + )} +
+ {hasMoreResults && ( + + {commonText.showMore()} + + )} +
+ )} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx index af719e42aa6..acdf1429792 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 }); + if ( + !isReadOnly && + state.lines.length > 0 && + !mappingPathIsComplete( + state.lines[state.focusedLine]?.mappingPath ?? [emptyMapping] + ) && + mappingPathIsComplete(mappingPath) && + getMappedFieldsBind(mappingPath).length === 0 + ) + dispatch({ type: 'MappingViewMapAction' }); + }} > ; + readonly baseTableName?: keyof Tables; + readonly showHiddenFields?: boolean; + readonly searchSpec?: NavigatorSpec; + readonly onSelectSearchResult?: (mappingPath: MappingPath) => void; readonly children: JSX.Element | undefined; }): JSX.Element | null { // `resize` event listener for the mapping view @@ -180,6 +190,22 @@ export function MappingView({ return (): void => resizeObserver.disconnect(); }, [mappingView, setMappingViewHeight]); + const fieldSearch = ( + + ); + + const mappingElementPropsWithSearch = mappingElementProps.map( + (mappingElement, index) => + index === 0 + ? { ...mappingElement, fieldSearch } + : mappingElement + ); + return (
- +
{children}
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..bddb7b7abd5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/FieldSearchComponent.test.tsx @@ -0,0 +1,233 @@ +import { act, 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'; + +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; + +const renderFieldSearch = () => { + const handleSelect = jest.fn(); + const { user } = mount( + + ); + return { handleSelect, user }; +}; + +const getShowMore = (): HTMLElement => + screen.getByRole('button', { name: commonText.showMore() }); + +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('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'); + 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(getSearchInput(), '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(getSearchInput(), 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(getSearchInput(), 'catalog'); + await waitForResults(); + + await user.click(getShowMore()); + expect(handleSelect).not.toHaveBeenCalled(); + 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'); +}); 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..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 @@ -1,11 +1,368 @@ 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'; +import { strictGetTable } from '../../DataModel/tables'; 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 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', + 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 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.queryBuilder, + }); + + expect(speciesResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['determinations', '#1', 'taxon', '$Species', 'name'], + label: expect.stringMatching(/Species$/u), + }), + ]) + ); + expect(speciesResults).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + mappingPath: ['determinations', '#1', 'taxon', '$Species', 'fullName'], + }), + ]) + ); + expect(fullNameResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + 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', + 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({ + baseTableName: 'CollectionObject', + search: ' ', + spec: navigatorSpecs.wbPlanView, + }) + ).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 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({ + baseTableName: 'CollectionObject', + search: 'modified', + showHiddenFields: true, + spec: navigatorSpecs.wbPlanView, + }) + ).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-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('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('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('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('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/fieldSearch.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts new file mode 100644 index 00000000000..43207118415 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSearch.ts @@ -0,0 +1,404 @@ +import { commonText } from '../../localization/common'; +import { queryText } from '../../localization/query'; +import type { RA } from '../../utils/types'; +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, + }); + }); + + const treePermission = spec.hasActionPermission() + ? spec.ensurePermission() + : undefined; + if ( + isTreeTable(current.table.name) && + hasTreeAccess(current.table.name, treePermission ?? '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; + const permission = spec.ensurePermission(); + const relatedName = relationship.relatedTable.name; + if ( + spec.hasActionPermission() && + permission !== undefined && + (isTreeTable(relatedName) + ? !hasTreeAccess(relatedName, permission) + : !hasTablePermission(relatedName, permission)) + ) + 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; + + if (queue.length >= fieldSearchVisitedStateLimit) 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/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..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', 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': 'Н/Д', 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)); +}