feat(filters): save worklist query filters to session storage so that they persist between navigation to the viewer and back (#3749)

Co-authored-by: ladeirarodolfo <39910206+ladeirarodolfo@users.noreply.github.com>
This commit is contained in:
Joe Boccanfuso 2023-10-30 10:47:43 -04:00 committed by GitHub
parent db395852b6
commit 2a15ef0e44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 322 additions and 9 deletions

View File

@ -3,6 +3,7 @@
describe('OHIF Study List', function () {
context('Desktop resolution', function () {
beforeEach(function () {
cy.window().then(win => win.sessionStorage.clear());
cy.openStudyList();
cy.viewport(1750, 720);
@ -14,6 +15,10 @@ describe('OHIF Study List', function () {
cy.get('@StudyDescription').clear();
});
afterEach(function () {
cy.window().then(win => win.sessionStorage.clear());
});
it('Displays several studies initially', function () {
cy.waitStudyList();
cy.get('@searchResult2').should($list => {
@ -33,6 +38,21 @@ describe('OHIF Study List', function () {
});
});
it('maintains Patient Name filter upon return from viewer', function () {
cy.get('@PatientName').type('Juno');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('Juno');
});
});
it('searches MRN with exact string', function () {
cy.get('@MRN').type('0000003');
//Wait result list to be displayed
@ -43,6 +63,21 @@ describe('OHIF Study List', function () {
});
});
it('maintains MRN filter upon return from viewer', function () {
cy.get('@MRN').type('0000003');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000003');
});
});
it('searches Accession with exact string', function () {
cy.get('@AccessionNumber').type('321');
//Wait result list to be displayed
@ -53,6 +88,21 @@ describe('OHIF Study List', function () {
});
});
it('maintains Accession filter upon return from viewer', function () {
cy.get('@AccessionNumber').type('0000155811');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('0000155811');
});
});
it('searches Description with exact string', function () {
cy.get('@StudyDescription').type('PETCT');
//Wait result list to be displayed
@ -63,6 +113,21 @@ describe('OHIF Study List', function () {
});
});
it('maintains Description filter upon return from viewer', function () {
cy.get('@StudyDescription').type('PETCT');
//Wait result list to be displayed
cy.waitStudyList();
cy.get('[data-cy="studyRow-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]').click();
cy.get(
'[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]'
).click();
cy.get('[data-cy="return-to-work-list"]').click();
cy.get('@searchResult2').should($list => {
expect($list.length).to.be.eq(1);
expect($list).to.contain('PETCT');
});
});
/* Todo: fix react select
it('searches Modality with camel case', function() {
cy.get('@modalities').type('Ct');

View File

@ -26,6 +26,7 @@ import {
AboutModal,
UserPreferences,
LoadingIndicatorProgress,
useSessionStorage,
} from '@ohif/ui';
import i18n from '@ohif/i18n';
@ -60,9 +61,17 @@ function WorkList({
const navigate = useNavigate();
const STUDIES_LIMIT = 101;
const queryFilterValues = _getQueryFilterValues(searchParams);
const [sessionQueryFilterValues, updateSessionQueryFilterValues] = useSessionStorage({
key: 'queryFilterValues',
defaultValue: queryFilterValues,
// ToDo: useSessionStorage currently uses an unload listener to clear the filters from session storage
// so on systems that do not support unload events a user will NOT be able to alter any existing filter
// in the URL, load the page and have it apply.
clearOnUnload: true,
});
const [filterValues, _setFilterValues] = useState({
...defaultFilterValues,
...queryFilterValues,
...sessionQueryFilterValues,
});
const debouncedFilterValues = useDebounce(filterValues, 200);
@ -119,6 +128,7 @@ function WorkList({
val.pageNumber = 1;
}
_setFilterValues(val);
updateSessionQueryFilterValues(val);
setExpandedRows([]);
};
@ -251,6 +261,7 @@ function WorkList({
moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS']).format('hh:mm A');
return {
dataCY: `studyRow-${studyInstanceUid}`,
row: [
{
key: 'patientName',
@ -377,6 +388,7 @@ function WorkList({
disabled={!isValidMode}
endIcon={<Icon name="launch-arrow" />} // launch-arrow | launch-info
onClick={() => {}}
data-cy={`mode-${mode.routeName}-${studyInstanceUid}`}
>
{t(`Modes:${mode.displayName}`)}
</LegacyButton>

View File

@ -0,0 +1,13 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');
module.exports = {
...base,
name: pkg.name,
displayName: pkg.name,
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/app/**/*.test.js"
// ]
};

View File

@ -50,7 +50,9 @@
"react-window": "^1.8.9",
"react-with-direction": "^1.3.1",
"swiper": "^8.4.2",
"webpack": "^5.81.0"
"webpack": "^5.81.0",
"@testing-library/react-hooks": "^3.2.1",
"react-test-renderer": "^16.12.0"
},
"devDependencies": {
"@babel/core": "^7.21.4",

View File

@ -175,7 +175,7 @@ DateRange.propTypes = {
/** YYYYMMDD (19921022) */
startDate: PropTypes.string,
/** YYYYMMDD (19921022) */
endDate: PropTypes.object,
endDate: PropTypes.string,
/** Callback that received { startDate: string(YYYYMMDD), endDate: string(YYYYMMDD)} */
onChange: PropTypes.func.isRequired,
};

View File

@ -43,6 +43,7 @@ function Header({
isReturnEnabled && 'cursor-pointer'
)}
onClick={onClickReturn}
data-cy="return-to-work-list"
>
{isReturnEnabled && (
<Icon

View File

@ -52,7 +52,7 @@ const StudyListFilter = ({
</div>
)}
</div>
<div className="flex flex-row">
<div className="flex h-[34px] flex-row items-center">
{/* TODO revisit the completely rounded style of button used for clearing the study list filter - for now use LegacyButton*/}
{isFiltering && (
<LegacyButton
@ -76,7 +76,7 @@ const StudyListFilter = ({
</Typography>
<Typography
variant="h6"
className="text-primary-light self-end pb-1"
className="text-primary-light"
>
{t('Studies')}
</Typography>

View File

@ -7,10 +7,13 @@ import Icon from '../Icon';
const StudyListTableRow = props => {
const { tableData } = props;
const { row, expandedContent, onClickRow, isExpanded } = tableData;
const { row, expandedContent, onClickRow, isExpanded, dataCY } = tableData;
return (
<>
<tr className="select-none">
<tr
className="select-none"
data-cy={dataCY}
>
<td
className={classnames('border-0 p-0', {
'border-secondary-light bg-primary-dark border-b': isExpanded,
@ -104,6 +107,7 @@ StudyListTableRow.propTypes = {
expandedContent: PropTypes.node.isRequired,
onClickRow: PropTypes.func.isRequired,
isExpanded: PropTypes.bool.isRequired,
dataCY: PropTypes.string,
}),
};

View File

@ -1,3 +1,4 @@
import useResizeObserver from './useResizeObserver';
import useSessionStorage from './useSessionStorage';
export { useResizeObserver };
export { useResizeObserver, useSessionStorage };

View File

@ -0,0 +1,102 @@
import { renderHook, act } from '@testing-library/react-hooks';
import useSessionStorage from './useSessionStorage';
const SESSION_STORAGE_KEY = 'test';
describe('Hook Session Storage', () => {
beforeEach(() => {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
});
it('hook should return state and setState', () => {
const data = { test: 1 };
const { result } = renderHook(() =>
useSessionStorage({ key: SESSION_STORAGE_KEY, defaultValue: data })
);
const [hookState, setHookState] = result.current;
expect(hookState).toStrictEqual(data);
expect(typeof setHookState).toBe('function');
});
it('hook should store data on sessionStorage', () => {
const data = { test: 2 };
renderHook(() => useSessionStorage({ key: SESSION_STORAGE_KEY, defaultValue: data }));
const dataStr = JSON.stringify(data);
const dataSessionStorage = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
expect(dataSessionStorage).toEqual(dataStr);
});
it('hook should return stored data from sessionStorage', () => {
const data = { test: 3 };
const dataToCompare = { test: 4 };
window.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(dataToCompare));
const { result } = renderHook(() =>
useSessionStorage({ key: SESSION_STORAGE_KEY, defaultValue: data })
);
const [hookState, setHookState] = result.current;
expect(hookState).toStrictEqual(dataToCompare);
});
it('hook should provide a setState method which updates its state', () => {
const data = { test: 5 };
const dataToCompare = { test: 6 };
const { result } = renderHook(() =>
useSessionStorage({ key: SESSION_STORAGE_KEY, defaultValue: data })
);
const [hookState, setHookState] = result.current;
act(() => {
setHookState(dataToCompare);
});
const dataToCompareStr = JSON.stringify(dataToCompare);
const dataSessionStorage = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
const [hookStateToCompare] = result.current;
expect(dataSessionStorage).toEqual(dataToCompareStr);
expect(hookStateToCompare).toStrictEqual(dataToCompare);
});
it('hook state must be preserved in case rerender', () => {
const data = { test: 7 };
const { result, rerender } = renderHook(() =>
useSessionStorage({ key: SESSION_STORAGE_KEY, defaultValue: data })
);
rerender();
const [hookState, setHookState] = result.current;
const dataToCompareStr = JSON.stringify(data);
const dataSessionStorage = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
expect(dataSessionStorage).toEqual(dataToCompareStr);
expect(hookState).toStrictEqual(data);
});
it('hook state must be preserved in case multiple operations and rerender', () => {
const data = { test: 8 };
const dataToCompare = { test: 9 };
const { result, rerender } = renderHook(() =>
useSessionStorage({ key: SESSION_STORAGE_KEY, defaultValue: data })
);
const [hookState, setHookState] = result.current;
act(() => {
setHookState(dataToCompare);
});
rerender();
const dataToCompareStr = JSON.stringify(dataToCompare);
const dataSessionStorage = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
const [hookStateToCompare] = result.current;
expect(dataSessionStorage).toEqual(dataToCompareStr);
expect(hookStateToCompare).toStrictEqual(dataToCompare);
});
});

View File

@ -0,0 +1,71 @@
import { useState, useEffect, useCallback } from 'react';
/**
* A map of session storage items that should be cleared out of session storage
* when the page unloads.
*/
const sessionItemsToClearOnUnload: Map<string, string> = new Map<string, string>();
/**
* This callback simulates clearing the various session items when a page unloads.
* When the page is hidden the session storage items are removed but maintained
* in the map above in case the page becomes visible again. So those pages that
* are hidden because they are being unloaded have their session storage disposed
* of for ever. For those pages that are hidden, but later return to visible,
* this callback restores the session storage from the map above.
*/
const visibilityChangeCallback = () => {
if (document.visibilityState === 'hidden') {
Array.from(sessionItemsToClearOnUnload.keys()).forEach(key => {
window.sessionStorage.removeItem(key);
});
} else {
Array.from(sessionItemsToClearOnUnload.keys()).forEach(key => {
window.sessionStorage.setItem(key, sessionItemsToClearOnUnload.get(key));
});
}
};
/**
* Technically there is no memory leak here because the listener needs to
* persist until the page unloads and once the page unloads it will be gone.
*/
document.addEventListener('visibilitychange', visibilityChangeCallback);
type useSessionStorageProps = {
key: string;
defaultValue: unknown;
clearOnUnload: boolean;
};
const useSessionStorage = ({
key,
defaultValue = {},
clearOnUnload = false,
}: useSessionStorageProps) => {
const valueFromStorage = window.sessionStorage.getItem(key);
const storageValue = valueFromStorage ? JSON.parse(valueFromStorage) : defaultValue;
const [sessionItem, setSessionItem] = useState({ ...storageValue });
const updateSessionItem = useCallback(value => {
setSessionItem({ ...value });
const valueAsStr = JSON.stringify(value);
if (!clearOnUnload || document.visibilityState === 'visible') {
window.sessionStorage.setItem(key, valueAsStr);
}
if (clearOnUnload) {
sessionItemsToClearOnUnload.set(key, valueAsStr);
}
}, []);
useEffect(() => {
updateSessionItem(sessionItem);
}, []);
return [sessionItem, updateSessionItem];
};
export default useSessionStorage;

View File

@ -119,6 +119,8 @@ export {
ViewportOverlay,
} from './components';
export { useSessionStorage } from './hooks';
/** These are mostly used in the docs */
export { getIcon, ICONS, addIcon } from './components/Icon/getIcon';
export { BackgroundColor } from './pages/Colors/BackgroundColor';

View File

@ -4860,6 +4860,14 @@
dependencies:
defer-to-connect "^2.0.1"
"@testing-library/react-hooks@^3.2.1":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-3.7.0.tgz#6d75c5255ef49bce39b6465bf6b49e2dac84919e"
integrity sha512-TwfbY6BWtWIHitjT05sbllyLIProcysC0dF0q1bbDa7OHLC6A6rJOYJwZ13hzfz3O4RtOuInmprBozJRyyo7/g==
dependencies:
"@babel/runtime" "^7.12.5"
"@types/testing-library__react-hooks" "^3.4.0"
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@ -5322,6 +5330,13 @@
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react-test-renderer@*":
version "18.0.5"
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-18.0.5.tgz#b67a6ff37acd93d1b971ec4c838f69d52e772db0"
integrity sha512-PsnmF4Hpi61PTRX+dTxkjgDdtZ09kFFgPXczoF+yBfOVxn7xBLPvKP1BUrSasYHmerj33rhoJuvpIMsJuyRqHw==
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.0":
version "4.4.6"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e"
@ -5437,6 +5452,13 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310"
integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==
"@types/testing-library__react-hooks@^3.4.0":
version "3.4.1"
resolved "https://registry.yarnpkg.com/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz#b8d7311c6c1f7db3103e94095fe901f8fef6e433"
integrity sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==
dependencies:
"@types/react-test-renderer" "*"
"@types/tough-cookie@*":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
@ -17331,7 +17353,7 @@ react-is@18.1.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67"
integrity sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.4:
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.4, react-is@^16.8.6:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@ -17528,6 +17550,16 @@ react-style-singleton@^2.2.1:
invariant "^2.2.4"
tslib "^2.0.0"
react-test-renderer@^16.12.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae"
integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg==
dependencies:
object-assign "^4.1.1"
prop-types "^15.6.2"
react-is "^16.8.6"
scheduler "^0.19.1"
react-textarea-autosize@^8.3.2:
version "8.5.2"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.2.tgz#6421df2b5b50b9ca8c5e96fd31be688ea7fa2f9d"
@ -18323,6 +18355,14 @@ saxes@^6.0.0:
dependencies:
xmlchars "^2.2.0"
scheduler@^0.19.1:
version "0.19.1"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"
integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"