diff --git a/data-serving/scripts/setup-db/schemas/day0cases.indexes.json b/data-serving/scripts/setup-db/schemas/day0cases.indexes.json index a7007c6b6..b55dd708a 100644 --- a/data-serving/scripts/setup-db/schemas/day0cases.indexes.json +++ b/data-serving/scripts/setup-db/schemas/day0cases.indexes.json @@ -8,7 +8,8 @@ "location.admin2": "text", "location.admin3": "text", "caseReference.sourceUrl": "text", - "caseStatus": "text" + "caseStatus": "text", + "comment": "text" } }, { @@ -145,6 +146,16 @@ "strength": 2 } }, + { + "name": "commentIdx", + "key": { + "comment": -1 + }, + "collation": { + "locale": "en_US", + "strength": 2 + } + }, { "name": "countryAndDate", "key": { diff --git a/verification/curator-service/ui/cypress/e2e/components/LinelistTableTest.spec.ts b/verification/curator-service/ui/cypress/e2e/components/LinelistTableTest.spec.ts index 7cc942796..be520b375 100644 --- a/verification/curator-service/ui/cypress/e2e/components/LinelistTableTest.spec.ts +++ b/verification/curator-service/ui/cypress/e2e/components/LinelistTableTest.spec.ts @@ -501,6 +501,7 @@ describe('Linelist table', function () { sourceUrl: 'www.example.com', caseStatus: CaseStatus.Confirmed, occupation: 'Actor', + comment: 'note', }); cy.addCase({ country: 'Germany', @@ -524,6 +525,7 @@ describe('Linelist table', function () { cy.intercept('GET', getDefaultQuery({ limit: 50 })).as('getCases'); cy.intercept('GET', getDefaultQuery({ limit: 50, query: 'Argentina' })).as('getCasesWithSearch1'); cy.intercept('GET', getDefaultQuery({ limit: 50, query: 'Doctor' })).as('getCasesWithSearch2'); + cy.intercept('GET', getDefaultQuery({ limit: 50, query: 'note' })).as('getCasesWithSearch3'); cy.visit('/cases'); cy.wait('@getCases'); @@ -549,5 +551,30 @@ describe('Linelist table', function () { cy.contains('Argentina').should('not.exist'); cy.contains('France').should('not.exist'); cy.contains('Germany').should('exist'); + + cy.get('#clear-search').click(); + cy.wait('@getCases'); + cy.contains('Argentina').should('exist'); + cy.contains('France').should('exist'); + cy.contains('Germany').should('exist'); + + cy.get('#search-field').type('note'); + cy.wait('@getCasesWithSearch3'); + cy.contains('Argentina').should('not.exist'); + cy.contains('France').should('exist'); + cy.contains('Germany').should('not.exist'); + }); + + it('Informs user when uneven number of quotes is present in free-text search', () => { + cy.intercept('GET', getDefaultQuery({ limit: 50, query: '"Bus driver"' })).as('getCasesWithSearch'); + cy.visit('/cases'); + cy.contains('Please make sure you have an even number of quotes.').should('not.exist'); + + cy.get('#search-field').type('"Bus driver'); + cy.contains('Please make sure you have an even number of quotes.').should('exist'); + + cy.get('#search-field').type('"'); + cy.wait('@getCasesWithSearch'); + cy.contains('Please make sure you have an even number of quotes.').should('not.exist'); }); }); diff --git a/verification/curator-service/ui/cypress/support/commands.ts b/verification/curator-service/ui/cypress/support/commands.ts index 4ddc86446..c951c0be8 100644 --- a/verification/curator-service/ui/cypress/support/commands.ts +++ b/verification/curator-service/ui/cypress/support/commands.ts @@ -43,6 +43,7 @@ interface AddCaseProps { gender?: Gender; outcome?: Outcome; uploadIds?: string[]; + comment?: string; } declare global { @@ -111,6 +112,7 @@ export function addCase(opts: AddCaseProps): void { travelHistory: {}, genomeSequences: {}, vaccination: {}, + comment: opts.comment || '', }, }); } diff --git a/verification/curator-service/ui/src/components/App/index.tsx b/verification/curator-service/ui/src/components/App/index.tsx index 0cc3fe81d..44c0e58c4 100644 --- a/verification/curator-service/ui/src/components/App/index.tsx +++ b/verification/curator-service/ui/src/components/App/index.tsx @@ -327,13 +327,14 @@ export default function App(): JSX.Element { }; const onModalClose = (): void => { + const searchQueryObject = new URLSearchParams(searchQuery); navigate( { pathname: location.state && location.state.lastLocation ? location.state.lastLocation : '/cases', - search: searchQuery, + search: searchQueryObject.toString(), }, { state: { lastLocation: '/case/view' } }, ); @@ -371,7 +372,8 @@ export default function App(): JSX.Element { ) return; - dispatch(setSearchQuery(location.search)); + const searchParams = new URLSearchParams(location.search); + dispatch(setSearchQuery(searchParams.toString())); // Save searchQuery to local storage not to lost it when user goes through auth process localStorage.setItem('searchQuery', location.search); diff --git a/verification/curator-service/ui/src/components/DataGuideDialog.tsx b/verification/curator-service/ui/src/components/DataGuideDialog.tsx index d24c7a03c..8a3c92801 100644 --- a/verification/curator-service/ui/src/components/DataGuideDialog.tsx +++ b/verification/curator-service/ui/src/components/DataGuideDialog.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { Box, Portal, Theme, Typography } from '@mui/material'; import { withStyles } from 'tss-react/mui'; -import CloseIcon from '@mui/icons-material/Close'; +import { Close as CloseIcon } from '@mui/icons-material'; import Draggable, { ControlPosition } from 'react-draggable'; // As per this issue from react-draggable library: https://github.com/react-grid-layout/react-draggable/pull/648 @@ -96,16 +96,49 @@ const SearchGuideDialog = ({ the left and choose ascending or descending. - For full-text search, enter any combination of search terms. -
- Full-text search covers: occupation, admin0, admin1, admin2, admin3, sourceUrl and caseStatus. -
- Search terms must be exact (example: "German" will not match "Germany"). -
- Full-text search matches cases that contain any ot the search terms, not a combination. -
- No special characters apart from "." are allowed and if the "." is used in a search term - given search term must be contained within quotation marks. + For full-text search, enter any + combination of search terms. Rules for full-text + search: +
+
You can use the icons on the right to navigate diff --git a/verification/curator-service/ui/src/components/FiltersDialog/index.tsx b/verification/curator-service/ui/src/components/FiltersDialog/index.tsx index 810a28c8d..a18d3c69e 100644 --- a/verification/curator-service/ui/src/components/FiltersDialog/index.tsx +++ b/verification/curator-service/ui/src/components/FiltersDialog/index.tsx @@ -17,7 +17,7 @@ import { useMediaQuery, } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; -import { filtersToURL, URLToFilters } from '../util/searchQuery'; +import { URLToFilters } from '../util/searchQuery'; import { hasAnyRole } from '../util/helperFunctions'; import { useAppSelector, useAppDispatch } from '../../hooks/redux'; import { fetchCountries } from '../../redux/filters/thunk'; @@ -153,13 +153,19 @@ export default function FiltersDialog({ handleSetModalAlert(); dispatch(setModalOpen(false)); - const searchQuery = filtersToURL(values); + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value) searchParams.set(key, value); + } + const q = (new URLSearchParams(location.search)).get('q') + if (q) searchParams.set('q', q); + const searchParamsString = searchParams.toString(); - sendCustomGtmEvent('filters_applied', { query: searchQuery }); + sendCustomGtmEvent('filters_applied', { query: searchParamsString }); navigate({ pathname: '/cases', - search: searchQuery, + search: searchParamsString, }); }, }); diff --git a/verification/curator-service/ui/src/components/SearchBar.tsx b/verification/curator-service/ui/src/components/SearchBar.tsx index a4ebf82e2..f45287740 100644 --- a/verification/curator-service/ui/src/components/SearchBar.tsx +++ b/verification/curator-service/ui/src/components/SearchBar.tsx @@ -15,7 +15,6 @@ import clsx from 'clsx'; import DataGuideDialog from './DataGuideDialog'; import { useDebounce } from '../hooks/useDebounce'; import FiltersDialog from './FiltersDialog'; -import { searchQueryToURL, URLToSearchQuery } from './util/searchQuery'; import { useLocation, useNavigate } from 'react-router-dom'; import { KeyboardEvent, ChangeEvent } from 'react'; import { useAppSelector, useAppDispatch } from '../hooks/redux'; @@ -85,9 +84,7 @@ export default function SearchBar({ const [isUserTyping, setIsUserTyping] = useState(false); const [isDataGuideOpen, setIsDataGuideOpen] = useState(false); const [searchInput, setSearchInput] = useState( - location.search.includes('?q=') - ? URLToSearchQuery(location.search) - : '', + new URLSearchParams(location.search).get('q') || '', ); const [modalAlert, setModalAlert] = useState(false); const guideButtonRef = React.useRef(null); @@ -103,28 +100,30 @@ export default function SearchBar({ } }, [filtersBreadcrumb]); + useEffect(() => { + const q = new URLSearchParams(location.search).get('q') || ''; + if (q !== searchInput) setSearchInput(q); + }, [location.search]); + // Set search query debounce to 1000ms const debouncedSearch = useDebounce(searchInput, 2000); - // Update search input based on search query - useEffect(() => { - if (!location.search.includes('?q=')) { - setSearchInput(''); - return; - } + const handleNavigating = (q: string) => { + const searchParams = new URLSearchParams(location.search); + q !== '' ? searchParams.set('q', q) : searchParams.delete('q'); - setSearchInput(URLToSearchQuery(location.search)); - }, [location.search]); + navigate({ + pathname: '/cases', + search: searchParams.toString(), + }); + }; // Apply filter parameters after delay useEffect(() => { if (!isUserTyping) return; - setIsUserTyping(false); - navigate({ - pathname: '/cases', - search: searchQueryToURL(debouncedSearch), - }); + + handleNavigating(debouncedSearch); //eslint-disable-next-line }, [debouncedSearch]); @@ -136,10 +135,8 @@ export default function SearchBar({ if (ev.key === 'Enter') { ev.preventDefault(); setIsUserTyping(false); - navigate({ - pathname: '/cases', - search: searchQueryToURL(searchInput), - }); + + handleNavigating(searchInput); } }; @@ -174,16 +171,24 @@ export default function SearchBar({ return searchStringStrippedOutColon; } + const renderSearchErrorMessage = () => { + if (searchError) { + return 'Incorrect entry. ":" characters have been removed. Please use filters instead.'; + } else { + const quoteCount = searchInput.split('"').length - 1; + if (quoteCount % 2 !== 0) { + return 'Incorrect entry. Please make sure you have an even number of quotes.'; + } + } + }; + return ( <>
({ errorMessage: { @@ -98,14 +101,14 @@ export default function ViewCase(props: Props): JSX.Element { {loading && } {errorMessage && ( - {errorMessage} - + )} {c && ( { + subs != '' && i % 2 ? quoted.push(subs) : notQuoted.push(subs); + }); + } else notQuoted.push(q); + + const regex = /"([^"]+)"|(\S{1,})/g; + // Make sure that terms in quotes will be highlighted as one search term + for (const quotedEntry of quoted) { + let match; + let accumulator: string[] = []; + while ((match = regex.exec(quotedEntry))) { + accumulator.push(match[match[1] ? 1 : 2]); + } + searchQueryArray.push(accumulator.join(' ')); + } + for (const notQuotedEntry of notQuoted) { + let match; + while ((match = regex.exec(notQuotedEntry))) { + searchQueryArray.push(match[match[1] ? 1 : 2]); + } } - return searchQueryArray; } words(searchQuery);