Skip to content

Commit

Permalink
Feat/heal search debounce (#1232)
Browse files Browse the repository at this point in the history
* feat(healSearchDebounce): Initial commit

* feat(healSearchDebounce): Wrote unit test and organized code

* feat(healSearchDebounce): Ran eslint on changed files

* feat(healSearchDebounce): Changed import of DebounceSearch for Jest test

* test(addReactTestingLibrary): Refactored and separated out code so tests can be written and so debouncing works

* feat(healSearchDebounce): Renamed function dbs to debounceSearch for clarity

* feat(healSearchDebounce): Renamed organized functions only used for search into a search folder and authored a test for doDebounceSearch

* feat(healSearchDebounce): Created test for doDebounceSearch.ts

* test(addReactTestingLibrary): Removed console.log

* feat(healSearchDebounce): Converted JS function to TS and changed array to object to avoid needing spread operator

* feat(healSearchDebounce): Ran eslint on changed files

* feat(healSearchDebounce): Fixed TS errors

* feat(healSearchDebounce): Formatted code

* feat(healSearchDebounce): Removed old comment

* feat(healSearchDebounce): Refactored test to use a test object

* feat(healSearchDebounce): Removed duplicated code, added import for FilterState in doSearchFilterSort.ts

* feat(healSearchDebounce): ran eslint and fixed imports for doSearchFilterSort
  • Loading branch information
jarvisraymond-uchicago authored Mar 9, 2023
1 parent 94e0235 commit 73bbbf8
Show file tree
Hide file tree
Showing 6 changed files with 271 additions and 162 deletions.
240 changes: 78 additions & 162 deletions src/Discovery/Discovery.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { useState, useEffect, ReactNode } from 'react';
import React, {
useState, useEffect, ReactNode, useMemo,
} from 'react';
import * as JsSearch from 'js-search';
import {
Tag, Popover, Space, Collapse, Button, Dropdown, Menu, Pagination, Tooltip,
Tag, Popover, Space, Collapse, Button, Dropdown, Pagination, Tooltip,
} from 'antd';
import {
LockOutlined,
Expand All @@ -18,9 +20,9 @@ import {
MinusCircleOutlined,
} from '@ant-design/icons';
import Checkbox from 'antd/lib/checkbox/Checkbox';
import MenuItem from 'antd/lib/menu/MenuItem';
import { debounce } from 'lodash';
import doDebounceSearch from './Utils/Search/doDebounceSearch';
import { DiscoveryConfig } from './DiscoveryConfig';
import './Discovery.css';
import DiscoverySummary from './DiscoverySummary';
import DiscoveryTagViewer from './DiscoveryTagViewer';
import DiscoveryDropdownTagViewer from './DiscoveryDropdownTagViewer';
Expand All @@ -29,7 +31,8 @@ import DiscoveryAdvancedSearchPanel from './DiscoveryAdvancedSearchPanel';
import { ReduxDiscoveryActionBar, ReduxDiscoveryDetails } from './reduxer';
import DiscoveryMDSSearch from './DiscoveryMDSSearch';
import DiscoveryAccessibilityLinks from './DiscoveryAccessibilityLinks';
import { JsxElement } from 'typescript';
import doSearchFilterSort from './Utils/Search/doSearchFilterSort';
import './Discovery.css';

export const accessibleFieldName = '__accessible';

Expand Down Expand Up @@ -106,54 +109,54 @@ const accessibleDataFilterToggle = () => {

export const renderFieldContent = (content: any, contentType: 'string' | 'paragraphs' | 'number' | 'link' | 'tags' = 'string', config: DiscoveryConfig): React.ReactNode => {
switch (contentType) {
case 'string':
if (Array.isArray(content)) {
return content.join(', ');
}
return content;
case 'number':
if (Array.isArray(content)) {
return content.join(', ');
}
return content.toLocaleString();
case 'paragraphs':
return content.split('\n').map((paragraph, i) => <p key={i}>{paragraph}</p>);
case 'link':
case 'string':
if (Array.isArray(content)) {
return content.join(', ');
}
return content;
case 'number':
if (Array.isArray(content)) {
return content.join(', ');
}
return content.toLocaleString();
case 'paragraphs':
return content.split('\n').map((paragraph, i) => <p key={i}>{paragraph}</p>);
case 'link':
return (
<a
onClick={(ev) => ev.stopPropagation()}
onKeyPress={(ev) => ev.stopPropagation()}
href={content}
target='_blank'
rel='noreferrer'
>
{content}
</a>
);
case 'tags':
if (!content || !content.map) {
return null;
}
return content.map(({ name, category }) => {
const color = getTagColor(category, config);
return (
<a
onClick={(ev) => ev.stopPropagation()}
onKeyPress={(ev) => ev.stopPropagation()}
href={content}
target='_blank'
rel='noreferrer'
<Tag
key={name}
role='button'
tabIndex={0}
className='discovery-header__tag-btn discovery-tag discovery-tag--selected'
aria-label={name}
style={{
backgroundColor: color,
borderColor: color,
}}
>
{content}
</a>
{name}
</Tag>
);
case 'tags':
if (!content || !content.map) {
return null;
}
return content.map(({ name, category }) => {
const color = getTagColor(category, config);
return (
<Tag
key={name}
role='button'
tabIndex={0}
className='discovery-header__tag-btn discovery-tag discovery-tag--selected'
aria-label={name}
style={{
backgroundColor: color,
borderColor: color,
}}
>
{name}
</Tag>
);
});
default:
throw new Error(`Unrecognized content type ${contentType}. Check the 'study_page_fields' section of the Discovery config.`);
});
default:
throw new Error(`Unrecognized content type ${contentType}. Check the 'study_page_fields' section of the Discovery config.`);
}
};

Expand All @@ -178,82 +181,17 @@ const highlightSearchTerm = (value: string, searchTerm: string, highlighClassNam
};
};

const filterByTags = (studies: any[], selectedTags: any, config: DiscoveryConfig): any[] => {
// if no tags selected, show all studies
if (Object.values(selectedTags).every((selected) => !selected)) {
return studies;
}
const tagField = config.minimalFieldMapping.tagsListFieldName;
return studies.filter((study) => study[tagField]?.some((tag) => selectedTags[tag.name]));
};

interface FilterState {
export interface FilterState {
[key: string]: { [value: string]: boolean }
}

const filterByAdvSearch = (studies: any[], advSearchFilterState: FilterState, config: DiscoveryConfig, filterMultiSelectionLogic: string): any[] => {
// if no filters active, show all studies
const noFiltersActive = Object.values(advSearchFilterState).every((selectedValues) => {
if (Object.values(selectedValues).length === 0) {
return true;
}
if (Object.values(selectedValues).every((selected) => !selected)) {
return true;
}
return false;
});
if (noFiltersActive) {
return studies;
}

// Combine within filters as AND
if (filterMultiSelectionLogic === 'AND') {
return studies.filter((study) => Object.keys(advSearchFilterState).every((filterName) => {
const filterValues = Object.keys(advSearchFilterState[filterName]);
// Handle the edge case where no values in this filter are selected
if (filterValues.length === 0) {
return true;
}
if (!config.features.advSearchFilters) {
return false;
}
const studyFilters = study[config.features.advSearchFilters.field];
if (!studyFilters || !studyFilters.length) {
return false;
}

const studyFilterValues = studyFilters.filter(({ key }) => key === filterName)
.map(({ value }) => value);
return filterValues.every((value) => studyFilterValues.includes(value));
}));
}

// Combine within filters as OR
return studies.filter((study) => Object.keys(advSearchFilterState).some((filterName) => {
const filterValues = Object.keys(advSearchFilterState[filterName]);
// Handle the edge case where no values in this filter are selected
if (filterValues.length === 0) {
return true;
}
if (!config.features.advSearchFilters) {
return false;
}
const studyFilters = study[config.features.advSearchFilters.field];
if (!studyFilters || !studyFilters.length) {
return false;
}

return studyFilters.some(({ key, value }) => key === filterName && filterValues.includes(value));
}));
};

export interface DiscoveryResource {
[accessibleFieldName]: AccessLevel,
[any: string]: any,
tags?: { name: string, category: string }[]
}

interface Props {
export interface Props {
config: DiscoveryConfig,
studies: DiscoveryResource[],
studyRegistrationValidationField: string,
Expand All @@ -276,8 +214,8 @@ interface Props {

const Discovery: React.FunctionComponent<Props> = (props: Props) => {
const { config } = props;

const [jsSearch, setJsSearch] = useState(null);
const [executedSearchesCount, setExecutedSearchesCount] = useState(0);
const [accessibilityFilterVisible, setAccessibilityFilterVisible] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [filtersVisible, setFiltersVisible] = useState(false);
Expand All @@ -299,52 +237,30 @@ const Discovery: React.FunctionComponent<Props> = (props: Props) => {
props.onSearchChange(value);
};

const doSearchFilterSort = () => {
let filteredResources = props.studies;
if (jsSearch && props.searchTerm) {
filteredResources = jsSearch.search(props.searchTerm);
}
filteredResources = filterByTags(
filteredResources,
props.selectedTags,
config,
);

if (config.features.advSearchFilters && config.features.advSearchFilters.enabled) {
filteredResources = filterByAdvSearch(
filteredResources,
filterState,
config,
filterMultiSelectionLogic,
);
}

if (props.config.features.authorization.enabled) {
filteredResources = filteredResources.filter(
(resource) => props.accessFilters[resource[accessibleFieldName]],
);
}

filteredResources = filteredResources.sort(
(a, b) => {
if (props.accessSortDirection === AccessSortDirection.DESCENDING) {
return a[accessibleFieldName] - b[accessibleFieldName];
} if (props.accessSortDirection === AccessSortDirection.ASCENDING) {
return b[accessibleFieldName] - a[accessibleFieldName];
}
return 0;
},
);
setVisibleResources(filteredResources);
const debouncingDelayInMilliseconds = 500;
const memoizedDebouncedSearch = useMemo(
() => debounce(doSearchFilterSort, debouncingDelayInMilliseconds),
[],
);
const parametersForDoSearchFilterSort = {
props,
jsSearch,
config,
setVisibleResources,
filterState,
filterMultiSelectionLogic,
accessibleFieldName,
AccessSortDirection,
};

useEffect(doSearchFilterSort,
[props.searchTerm,
props.accessSortDirection,
props.studies,
props.pagination,
props.accessFilters,
props.selectedTags,
useEffect(
() => doDebounceSearch(parametersForDoSearchFilterSort, memoizedDebouncedSearch, executedSearchesCount, setExecutedSearchesCount), [
props.searchTerm,
props.accessSortDirection,
props.studies,
props.pagination,
props.accessFilters,
props.selectedTags,
filterMultiSelectionLogic,
filterState],
);
Expand Down
37 changes: 37 additions & 0 deletions src/Discovery/Utils/Search/doDebounceSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import doDebounceSearch from './doDebounceSearch';
import doSearchFilterSort from './doSearchFilterSort';

jest.mock('./doSearchFilterSort');

describe('doDebounceSearch', () => {
let memoizedDebouncedSearch;
let setExecutedSearchesCount;
beforeEach(() => {
memoizedDebouncedSearch = jest.fn(() => {});
setExecutedSearchesCount = jest.fn(() => {});
});

afterEach(() => {
jest.clearAllMocks();
});

it('should execute doSearchFilterSort initially without debouncing', () => {
doDebounceSearch({ test: 'test' }, memoizedDebouncedSearch, 0, setExecutedSearchesCount);
expect(doSearchFilterSort).toHaveBeenCalledWith({ test: 'test' });
expect(memoizedDebouncedSearch).not.toHaveBeenCalled();
expect(setExecutedSearchesCount).toHaveBeenCalledWith(1);

jest.clearAllMocks();
doDebounceSearch({ test: 'test2' }, memoizedDebouncedSearch, 1, setExecutedSearchesCount);
expect(doSearchFilterSort).toHaveBeenCalledWith({ test: 'test2' });
expect(memoizedDebouncedSearch).not.toHaveBeenCalled();
expect(setExecutedSearchesCount).toHaveBeenCalledWith(2);
});

it('should debounce the doSearchFilterSort call when executedSearchesCount >= 2', () => {
doDebounceSearch({ test: 'test3' }, memoizedDebouncedSearch, 2, setExecutedSearchesCount);
expect(doSearchFilterSort).not.toHaveBeenCalled();
expect(memoizedDebouncedSearch).toHaveBeenCalledWith({ test: 'test3' });
expect(setExecutedSearchesCount).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions src/Discovery/Utils/Search/doDebounceSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import doSearchFilterSort from './doSearchFilterSort';

const doDebounceSearch = (
parametersForDoSearchFilterSort: {},
memoizedDebouncedSearch: (...args: any[]) => void,
executedSearchesCount: number,
setExecutedSearchesCount: (count: number) => void,
) => {
const initialSearchesWithoutDebounce = 2;
// Execute searches initially without debounce to decrease page load time
if (executedSearchesCount < initialSearchesWithoutDebounce) {
setExecutedSearchesCount(executedSearchesCount + 1);
return doSearchFilterSort(parametersForDoSearchFilterSort);
}
// Otherwise debounce the calls
return memoizedDebouncedSearch(parametersForDoSearchFilterSort);
};
export default doDebounceSearch;
Loading

0 comments on commit 73bbbf8

Please sign in to comment.