Repository navigation
feat(field-mapper): add field search #8612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
grantfitzsimmons
wants to merge
39
commits into
main
Choose a base branch
from
issue-7499
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 9fbc91d
feat(field-mapper): support formatted and aggregated
grantfitzsimmons b840652
feat(field-mapper): add table icons
grantfitzsimmons 3a79d3f
feat(field-mapper): add field priorities
grantfitzsimmons 9243255
fix(field-mapper): handle missing trees
grantfitzsimmons 681e82c
fix(field-mapper): search now shows fields based on context
grantfitzsimmons 52fcd61
feat(field-mapper) limit search results on backend
grantfitzsimmons 67c71dd
fix(field-mapper): stop searching virtual fields
grantfitzsimmons bb7a129
fix(field-mapper): use name instead of fullname
grantfitzsimmons e241572
fix(field-mapper): remove redundant table name
grantfitzsimmons 6ffe337
fix(field-mapper): search tree on any rank
grantfitzsimmons b13d682
fix(field-mapper): style search field properly
grantfitzsimmons a205670
fix(field-mapper): exclude internal tables from search
grantfitzsimmons 7707300
fix(field-mapper): support for limited self-relationships
grantfitzsimmons ab506b0
fix(field-mapper): make search resolve (formatted) tree paths
grantfitzsimmons e430543
feat(field-mapper): clarify search label
grantfitzsimmons de95bea
fix(field-mapper): add result cap after sorting
grantfitzsimmons e9993a2
feat(field-mapper): add debounce for search
grantfitzsimmons 9a763f2
fix(field-mapper): make search show ID
grantfitzsimmons 659c816
fix(field-mapper): remove listbox
grantfitzsimmons 12f8e86
fix(field-mapper): fix pagination logic
grantfitzsimmons 22e09a7
Merge branch 'main' into issue-7499
grantfitzsimmons 9739fc7
Refactor: Create dedicated files for field search
CarolineDenis 0f60850
Potential fix for pull request finding 'CodeQL / Comparison between i…
CarolineDenis 5bc4c20
Update specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSear…
CarolineDenis 0cfe7cd
Fix: Remove import
CarolineDenis 69caf37
Fix: Keyboard and short lists cannot load more results.
CarolineDenis fe7de4e
Feat: Allow arrow navigation.
CarolineDenis b1b0244
Tests: Add arrow navigation tests
CarolineDenis 3ce8121
Merge remote-tracking branch 'origin/main' into issue-7499
CarolineDenis 52fc6fb
Update specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped…
CarolineDenis a0cb3de
Update specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx
CarolineDenis 690149e
Fix: Avoid a full graph traversal on every query.
CarolineDenis 9cbbe5f
Test: Add test for graph travers avoidance on every query.
CarolineDenis 4660435
Fix: Bound the queue size, not only the dequeue count.
CarolineDenis c2fa99a
Remove weblate token check
CarolineDenis 9c708cb
FFix: Hide results while the query is debouncing.
CarolineDenis 8e62270
Fix: The search-result map action can overwrite a mapped focused line.
CarolineDenis 79ed7ae
Update specifyweb/frontend/js_src/lib/components/WbPlanView/fieldSear…
CarolineDenis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
228 changes: 228 additions & 0 deletions
228
specifyweb/frontend/js_src/lib/components/WbPlanView/FieldSearchComponent.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
|
|
||
| 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} | ||
| /> | ||
|
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(); | ||
| }} | ||
|
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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.