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:
+
+
+ -
+ Full-text search covers: occupation, admin0,
+ admin1, admin2, admin3, sourceUrl, comment
+ and caseStatus.
+
+ -
+ Search terms must be exact (example:{' '}
+
+ German
+ {' '}
+ will not match{' '}
+
+ Germany
+
+ ).
+
+ -
+ Full-text search matches cases that contain
+ any of the search terms, not a combination.
+
+ -
+ To search for a combination of terms, wrap
+ the combination in quotation marks (example:{' '}
+
+ "Bus driver"
+
+ ).
+
+ -
+ No special characters apart from dot are
+ allowed. Search terms with dot must be
+ contained within quotation marks (example:{' '}
+
+ "global.health"
+
+ ).
+
+
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);