From 5faff066bf97fb8368465d8ff007c88be0746fe4 Mon Sep 17 00:00:00 2001 From: make-github-pseudonymous-again <5165674+make-github-pseudonymous-again@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:24:18 +0100 Subject: [PATCH] :construction: progress: Make all tests pass. --- imports/_test/fixtures.ts | 10 ++ imports/api/GenericQueryHook.ts | 2 +- imports/api/collection/patients.ts | 9 + imports/api/endpoint/patients/update.ts | 4 +- imports/api/makeObservedQueryHook.ts | 20 ++- imports/api/makeQuery.ts | 8 +- imports/api/patients/virtualFields.ts | 2 +- imports/api/publication/stopSubscription.ts | 47 ++++-- imports/api/publication/subscribe.ts | 23 +++ imports/api/publication/useFind.ts | 8 +- imports/api/query/watch.ts | 7 +- imports/ui/App.tsx | 27 ++- imports/ui/accessibility/addTooltip.tsx | 2 +- .../ui/availability/useAvailability.tests.ts | 2 + imports/ui/button/FixedFab.tsx | 8 +- imports/ui/button/LoadingIconButton.tsx | 8 +- .../ui/documents/HealthOneReportContents.tsx | 3 + imports/ui/hooks/useRandom.ts | 11 +- .../patients/PatientPersonalInformation.tsx | 4 +- ...atientPersonalInformationButtonsStatic.tsx | 50 ++++-- .../PatientPersonalInformationStatic.tsx | 157 +++++++++++++----- .../ui/patients/ReactivePatientChip.tests.tsx | 2 +- .../useObservedPatientsWithChanges.ts | 8 + .../usePatientPersonalInformationReducer.ts | 78 +++++++-- .../ui/search/FullTextSearchResultsRoutes.tsx | 7 +- imports/ui/tags/TagDetails.tsx | 8 +- imports/ui/users/ChangePasswordPopover.tsx | 7 +- imports/ui/users/Dashboard.tsx | 49 +++--- imports/ui/users/LoginPopover.tsx | 12 +- imports/ui/users/RegisterPopover.tsx | 12 +- imports/ui/users/SignInForm.tsx | 45 +++-- .../patient/personal-information.app-tests.ts | 19 +-- 32 files changed, 464 insertions(+), 195 deletions(-) create mode 100644 imports/ui/patients/useObservedPatientsWithChanges.ts diff --git a/imports/_test/fixtures.ts b/imports/_test/fixtures.ts index b1ab77a1b..6a2fb3a66 100644 --- a/imports/_test/fixtures.ts +++ b/imports/_test/fixtures.ts @@ -20,6 +20,9 @@ import type Document from '../api/Document'; import type Selector from '../api/query/Selector'; import appIsReady from '../app/isReady'; import isAppTest from '../app/isAppTest'; +import sleep from '../lib/async/sleep'; +import {_navigate} from '../ui/App'; +import {getWatchStreamCount} from '../api/query/watch'; export { default as randomId, @@ -86,6 +89,13 @@ export const client = (title, fn) => { const cleanup = async () => { await logout(); unmount(); + _navigate('/_test/reset') + await sleep(5); + const n = getWatchStreamCount(); + if (n !== 0) { + console.warn(`ChangeStream watch count is different from 0 (got ${n})!`); + } + _navigate('/') await call(reset); }; diff --git a/imports/api/GenericQueryHook.ts b/imports/api/GenericQueryHook.ts index e646f4a2f..e8d86df2b 100644 --- a/imports/api/GenericQueryHook.ts +++ b/imports/api/GenericQueryHook.ts @@ -9,7 +9,7 @@ type GenericQueryHookReturnType = { }; type GenericQueryHook = ( - query: UserQuery, + query: UserQuery | null, deps: DependencyList, ) => GenericQueryHookReturnType; diff --git a/imports/api/collection/patients.ts b/imports/api/collection/patients.ts index b9578bd59..f82c99b34 100644 --- a/imports/api/collection/patients.ts +++ b/imports/api/collection/patients.ts @@ -88,6 +88,15 @@ export const patientFields = patientIdFields .merge(patientTagFields); export type PatientFields = schema.infer; +export const patientUpdate = patientFields.merge( + schema.object({ + sex: patientFields.shape.sex.nullable(), + deathdateModifiedAt: patientFields.shape.deathdateModifiedAt.nullable(), + deathdate: patientFields.shape.deathdate.nullable(), + }) +).partial(); +export type PatientUpdate = schema.infer; + export const patientComputedFields = schema.object({ normalizedName: schema.string(), }); diff --git a/imports/api/endpoint/patients/update.ts b/imports/api/endpoint/patients/update.ts index 4f985577b..74e2c3d7b 100644 --- a/imports/api/endpoint/patients/update.ts +++ b/imports/api/endpoint/patients/update.ts @@ -1,7 +1,7 @@ import {AuthenticationLoggedIn} from '../../Authentication'; import schema from '../../../lib/schema'; -import {patientFields, Patients} from '../../collection/patients'; +import {patientUpdate, Patients} from '../../collection/patients'; import {computeUpdate, patients} from '../../patients'; import type TransactionDriver from '../../transaction/TransactionDriver'; @@ -13,7 +13,7 @@ const {sanitize, updateIndex, updateTags} = patients; export default define({ name: '/api/patients/update', authentication: AuthenticationLoggedIn, - schema: schema.tuple([schema.string(), patientFields.partial().strict()]), + schema: schema.tuple([schema.string(), patientUpdate.strict()]), async transaction(db: TransactionDriver, patientId, newfields) { const owner = this.userId; diff --git a/imports/api/makeObservedQueryHook.ts b/imports/api/makeObservedQueryHook.ts index 3734f2676..6d9269218 100644 --- a/imports/api/makeObservedQueryHook.ts +++ b/imports/api/makeObservedQueryHook.ts @@ -15,9 +15,10 @@ const makeObservedQueryHook = ( Collection: ObservedQueryCacheCollection, publication: Publication<[string, UserQuery, ObserveOptions | null]>, + observe: ObserveOptions | null = null, ): GenericQueryHook => - (query: UserQuery, deps: DependencyList) => { - const [loading, setLoading] = useState(true); + (query: UserQuery | null, deps: DependencyList) => { + const [loading, setLoading] = useState(query !== null); const [results, setResults] = useState([]); const [dirty, setDirty] = useState(false); const handleRef = useRef(null); @@ -25,6 +26,13 @@ const makeObservedQueryHook = const effectWillTrigger = useChanged(deps); useEffect(() => { + if (query === null) { + setLoading(false); + setResults([]); + setDirty(false); + return; + } + const id = {}; handleRef.current = id; setDirty(false); @@ -32,7 +40,7 @@ const makeObservedQueryHook = const timestamp = Date.now(); const key = JSON.stringify({timestamp, query}); - const handle = subscribe(publication, key, query, null, { + const handle = subscribe(publication, key, query, observe, { onStop() { if (handleRef.current === id) { setDirty(true); @@ -55,7 +63,11 @@ const makeObservedQueryHook = }; }, deps); - return { + return query === null ? { + loading: false, + results: [], + dirty: false, + } : { loading: effectWillTrigger || loading, results, dirty: !effectWillTrigger && dirty, diff --git a/imports/api/makeQuery.ts b/imports/api/makeQuery.ts index 276e00472..b62f93a20 100644 --- a/imports/api/makeQuery.ts +++ b/imports/api/makeQuery.ts @@ -14,12 +14,12 @@ const makeQuery = collection: Collection, publication: Publication<[UserQuery]>, ) => - (query: UserQuery, deps: DependencyList) => { - const isLoading = useSubscription(publication, query); + (query: UserQuery | null, deps: DependencyList) => { + const isLoading = useSubscription(query === null ? null : publication, query); const loadingSubscription = isLoading(); - const [selector, options] = queryToSelectorOptionsPair(query); + const [selector, options] = query === null ? [] : queryToSelectorOptionsPair(query); const {loading: loadingResults, results} = useCursor( - () => collection.find(selector, options), + () => query === null ? null : collection.find(selector, options), deps, ); const loading = loadingSubscription || loadingResults; diff --git a/imports/api/patients/virtualFields.ts b/imports/api/patients/virtualFields.ts index d76fd01e4..c71a2925d 100644 --- a/imports/api/patients/virtualFields.ts +++ b/imports/api/patients/virtualFields.ts @@ -1,7 +1,7 @@ import {type PatientDocument} from '../collection/patients'; import eidParseBirthdate from '../eidParseBirthdate'; -const virtualFields = (patient: PatientDocument) => { +const virtualFields = (patient: Omit & {deathdate?: Date | null}) => { const birthdate = eidParseBirthdate(patient.birthdate ?? ''); const deathdateModifiedAt = patient.deathdateModifiedAt ?? undefined; const deathdateLegal = patient.deathdate ?? undefined; diff --git a/imports/api/publication/stopSubscription.ts b/imports/api/publication/stopSubscription.ts index 35ab14f7f..0afebfe49 100644 --- a/imports/api/publication/stopSubscription.ts +++ b/imports/api/publication/stopSubscription.ts @@ -14,13 +14,24 @@ const stopSubscription = ( ) => { const entry = get(key); if (entry === undefined) { - // console.debug({ - // what: 'stopSubscription', - // msg: `subscription ${key} already stopped`, - // }); + console.debug({ + what: 'stopSubscription', + msg: `subscription ${key} already stopped`, + key, + }); return; } + //console.debug(JSON.stringify({ + //what: 'stopSubscription', + //msg: 'request', + //key, + //id: entry.internals.id, + //name: entry.internals.name, + //params: entry.internals.params, + //refCount: entry.refCount, + //}, undefined, 2)); + --entry.refCount; assert(entry.refCount >= 0, `Negative refCount for ${key}.`); if (onReady !== undefined) entry.onReady.delete(onReady); @@ -36,26 +47,26 @@ const stopSubscription = ( if (entry.refCount === 0) { const sub = entry.internals; - // console.debug({ - // what: 'stopSubscription', - // msg: 'refCount === 0', - // id: sub.id, - // name: sub.name, - // params: sub.params, - // }); + //console.debug(JSON.stringify({ + //what: 'stopSubscription', + //msg: 'refCount === 0', + //id: sub.id, + //name: sub.name, + //params: sub.params, + //}, undefined, 2)); sub.inactive = true; const prev = _gcQueue.get(sub.id); if (prev !== undefined) prev.cancel(); const next = defer(() => { if (sub.inactive) { - // console.debug({ - // what: 'stopSubscription', - // msg: 'unsub', - // id: sub.id, - // name: sub.name, - // params: sub.params, - // }); + //console.debug(JSON.stringify({ + //what: 'stopSubscription', + //msg: 'delete', + //id: sub.id, + //name: sub.name, + //params: sub.params, + //}, undefined, 2)); set(key, undefined); handle.stop(); debugMeteorSubscriptions(); diff --git a/imports/api/publication/subscribe.ts b/imports/api/publication/subscribe.ts index 75d901a5d..a026a5098 100644 --- a/imports/api/publication/subscribe.ts +++ b/imports/api/publication/subscribe.ts @@ -70,6 +70,12 @@ const subscribe = ( ...args: [...A, SubscriptionCallbacks?] ): SubscriptionHandle => { const [params, callbacks] = _parseCallbacks(args); + //console.debug(JSON.stringify({ + //what: 'subscribe', + //msg: 'request', + //name, + //params, + //}, undefined, 2)); const key = identify(name, params); const entry = get(key); let handle: Meteor.SubscriptionHandle; @@ -87,7 +93,24 @@ const subscribe = ( }); const internals = subscriptionInternals(handle); set(key, {handle, internals, refCount: 1, onReady, onStop}); + + //console.debug(JSON.stringify({ + //what: 'subscribe', + //msg: 'create', + //key, + //id: internals.id, + //name: internals.name, + //params: internals.params, + //}, undefined, 2)); } else { + //console.debug(JSON.stringify({ + //what: 'subscribe', + //msg: 'recycle', + //key, + //id: entry.internals.id, + //name: entry.internals.name, + //params: entry.internals.params, + //}, undefined, 2)); ++entry.refCount; handle = entry.handle; entry.internals.inactive = false; diff --git a/imports/api/publication/useFind.ts b/imports/api/publication/useFind.ts index 9ee49940c..33623cc98 100644 --- a/imports/api/publication/useFind.ts +++ b/imports/api/publication/useFind.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import {type Mongo} from 'meteor/mongo'; -import {useMemo, useEffect, type DependencyList, useState} from 'react'; +import {useMemo, useEffect, type DependencyList, useState, useDeferredValue} from 'react'; import type Document from '../Document'; @@ -30,6 +30,7 @@ const useFindClient = ( cursor .observeAsync({ addedAt(document, atIndex, _before) { + assert(!stopped, 'addedAt called after stop'); if (initializing) { assert( atIndex === init.length, @@ -47,6 +48,7 @@ const useFindClient = ( changedAt(newDocument, _oldDocument, atIndex) { assert(!initializing, `changedAt called during init`); + assert(!stopped, 'changedAt called after stop'); setResults((data) => [ ...data.slice(0, atIndex), newDocument, @@ -56,6 +58,7 @@ const useFindClient = ( removedAt(_oldDocument, atIndex) { assert(!initializing, `removedAt called during init`); + assert(!stopped, 'removedAt called after stop'); setResults((data) => [ ...data.slice(0, atIndex), ...data.slice(atIndex + 1), @@ -64,6 +67,7 @@ const useFindClient = ( movedTo(_document, fromIndex, toIndex, _before) { assert(!initializing, `movedTo called during init`); + assert(!stopped, 'movedTo called after stop'); setResults((data) => { const doc = data[fromIndex]!; const copy = [ @@ -97,7 +101,7 @@ const useFindClient = ( }; }, [cursor]); - return {loading, results}; + return useDeferredValue(useMemo(() => ({loading, results}), [loading, results])); }; const useFind = useFindClient; diff --git a/imports/api/query/watch.ts b/imports/api/query/watch.ts index 85c83e17d..95d9f4b3b 100644 --- a/imports/api/query/watch.ts +++ b/imports/api/query/watch.ts @@ -115,6 +115,7 @@ const _optionsToPipeline = (options: Options) => options.project === undefined ? [] : [{$project: options.project}]; let _watchStreamCount = 0; +let _maxWatchStreamCount = 0; export const getWatchStreamCount = () => _watchStreamCount; @@ -145,12 +146,14 @@ const _watchStream = ( let open = true; ++_watchStreamCount; - console.debug({_watchStreamCount}); + if (_watchStreamCount > _maxWatchStreamCount) { + _maxWatchStreamCount = _watchStreamCount; + console.debug({_watchStreamCount}); + } stream.on('close', () => { if (open) { open = false; --_watchStreamCount; - console.debug({_watchStreamCount}); } }); diff --git a/imports/ui/App.tsx b/imports/ui/App.tsx index 063198083..73b61e638 100644 --- a/imports/ui/App.tsx +++ b/imports/ui/App.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {BrowserRouter, Routes, Route} from 'react-router-dom'; +import {BrowserRouter, Routes, Route, useNavigate, NavigateFunction, Path} from 'react-router-dom'; import {CacheProvider} from '@emotion/react'; import createCache from '@emotion/cache'; @@ -26,16 +26,27 @@ export const muiCache = createCache({ prepend: true, }); +export let _navigate: NavigateFunction = (_to: Partial | string | number) => { + // TODO: This gets called in non-full-app client tests. + console.warn('Using unitialized test-only _navigate function call.'); +}; + +const _Routes = ({children}) => { + _navigate = useNavigate(); + return ( + test-only reset page} path="/_test/reset" /> + + ) +}; + const Router = isTest() - ? ({children}) => ( + ? ({children}) => { + return ( - - test} path="/_test" /> - - + <_Routes>{children} - ) - : BrowserRouter; + ) + } : BrowserRouter; const App = () => { const theme = useUserTheme(); diff --git a/imports/ui/accessibility/addTooltip.tsx b/imports/ui/accessibility/addTooltip.tsx index 0454cfe03..c4ac88b21 100644 --- a/imports/ui/accessibility/addTooltip.tsx +++ b/imports/ui/accessibility/addTooltip.tsx @@ -14,7 +14,7 @@ const addTooltip = ( React.forwardRef(({tooltip, ...rest}: Props, ref) => { const title = transform(rest, tooltip); - return title ? ( + return title !== undefined ? ( diff --git a/imports/ui/availability/useAvailability.tests.ts b/imports/ui/availability/useAvailability.tests.ts index 131b662d8..4fcecc39a 100644 --- a/imports/ui/availability/useAvailability.tests.ts +++ b/imports/ui/availability/useAvailability.tests.ts @@ -158,6 +158,8 @@ client(__filename, () => { const end = addMilliseconds(datetime, duration); assert.deepEqual(dropOwners(dropIds(result.current.results)), [ + // NOTE: Sometimes, only the first item is present when running the + // test. slot(beginningOfTime(), begin, 0), slot(begin, end, 1), slot(end, endOfTime(), 0), diff --git a/imports/ui/button/FixedFab.tsx b/imports/ui/button/FixedFab.tsx index ea45b7de8..f312e9b37 100644 --- a/imports/ui/button/FixedFab.tsx +++ b/imports/ui/button/FixedFab.tsx @@ -1,4 +1,4 @@ -import React, {type CSSProperties} from 'react'; +import React, {MutableRefObject, type CSSProperties} from 'react'; import {styled, type Theme, useTheme} from '@mui/material/styles'; import Fab, {type FabProps} from '@mui/material/Fab'; @@ -44,7 +44,7 @@ const Progress = styled(CircularProgress)({ zIndex: 1, }); -const FixedFab = React.forwardRef( +const FixedFab = React.forwardRef( ( { col = DEFAULT_COL, @@ -66,8 +66,8 @@ const FixedFab = React.forwardRef(
} + component={component as 'button'} disabled={Boolean(disabled) || pending} {...rest} /> diff --git a/imports/ui/button/LoadingIconButton.tsx b/imports/ui/button/LoadingIconButton.tsx index 8a89528da..0d79f37ea 100644 --- a/imports/ui/button/LoadingIconButton.tsx +++ b/imports/ui/button/LoadingIconButton.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {MutableRefObject} from 'react'; import {styled} from '@mui/material/styles'; import CircularProgress from '@mui/material/CircularProgress'; @@ -67,7 +67,7 @@ const Progress = styled(CircularProgress)(({size}: {size: number}) => ({ const DEFAULT_SIZE = 'medium'; -const LoadingIconButton = React.forwardRef( +const LoadingIconButton = React.forwardRef( ( { loading = false, @@ -81,8 +81,8 @@ const LoadingIconButton = React.forwardRef( return ( } + component={component as 'button'} disabled={loading || disabled} size={size} {...rest} diff --git a/imports/ui/documents/HealthOneReportContents.tsx b/imports/ui/documents/HealthOneReportContents.tsx index b8e77ec62..a96615576 100644 --- a/imports/ui/documents/HealthOneReportContents.tsx +++ b/imports/ui/documents/HealthOneReportContents.tsx @@ -23,6 +23,9 @@ type Props = { }; const HealthOneReportContents = ({document}: Props) => { + // TODO: Change type expectations and display loading indicator. + if (document.text === undefined) return null; + return ( {document.text.join('\n').trim()} diff --git a/imports/ui/hooks/useRandom.ts b/imports/ui/hooks/useRandom.ts index d86f004e2..67b2c7598 100644 --- a/imports/ui/hooks/useRandom.ts +++ b/imports/ui/hooks/useRandom.ts @@ -1,12 +1,15 @@ -import {useState} from 'react'; +import {useState, useCallback} from 'react'; const useRandom = (): [number, () => void] => { const [value, setValue] = useState(Math.random()); + + const update = useCallback(() => { + setValue(Math.random()); + }, [setValue]); + return [ value, - () => { - setValue(Math.random()); - }, + update, ]; }; diff --git a/imports/ui/patients/PatientPersonalInformation.tsx b/imports/ui/patients/PatientPersonalInformation.tsx index 3f2c2af19..3d83ebb6a 100644 --- a/imports/ui/patients/PatientPersonalInformation.tsx +++ b/imports/ui/patients/PatientPersonalInformation.tsx @@ -16,7 +16,7 @@ const PatientPersonalInformation = ({ }: PatientPersonalInformationProps) => { const init = {}; const query = {filter: {_id: patientId}}; - const deps = [JSON.stringify(query)]; + const deps = [patientId]; const {loading, found, fields: patient} = usePatient(init, query, deps); @@ -26,7 +26,7 @@ const PatientPersonalInformation = ({ return Patient not found.; } - return ; + return ; }; export default PatientPersonalInformation; diff --git a/imports/ui/patients/PatientPersonalInformationButtonsStatic.tsx b/imports/ui/patients/PatientPersonalInformationButtonsStatic.tsx index 40593ea7d..4e53e9443 100644 --- a/imports/ui/patients/PatientPersonalInformationButtonsStatic.tsx +++ b/imports/ui/patients/PatientPersonalInformationButtonsStatic.tsx @@ -24,26 +24,35 @@ import debounceSnackbar from '../snackbar/debounceSnackbar'; import {documentDiff} from '../../api/update'; import {type reducer} from './usePatientPersonalInformationReducer'; +import MergeType from '@mui/icons-material/MergeType'; type PatientPersonalInformationButtonsStaticProps = { + readonly readOnly: boolean; + readonly loading?: boolean; readonly dirty: boolean; readonly editing: boolean; readonly dispatch: Dispatch>; - readonly patient: PatientDocument; - readonly patientInit: PatientDocument; + readonly patient: Omit & {deathdate?: Date | null}; + readonly patientInit?: Omit & {deathdate?: Date | null}; + readonly initChanged?: boolean; + readonly refresh: () => void; }; const PatientPersonalInformationButtonsStatic = ({ dispatch, + loading, dirty, editing, + readOnly, patient, patientInit, + initChanged, + refresh }: PatientPersonalInformationButtonsStaticProps) => { const {enqueueSnackbar, closeSnackbar} = useSnackbar(); const [saving, setSaving] = useState(false); - const saveDetails = async (_event) => { + const saveDetails = patientInit === undefined ? undefined : async () => { const feedback = debounceSnackbar({enqueueSnackbar, closeSnackbar}); feedback('Processing...', { variant: 'info', @@ -73,10 +82,20 @@ const PatientPersonalInformationButtonsStatic = ({ return ( <> + + + { - dispatch({type: 'init', payload: patientInit}); + dispatch({type: 'undo'}); }} > { @@ -107,7 +128,8 @@ const PatientPersonalInformationButtonsStatic = ({ { diff --git a/imports/ui/patients/PatientPersonalInformationStatic.tsx b/imports/ui/patients/PatientPersonalInformationStatic.tsx index 404c3c779..1a029f948 100644 --- a/imports/ui/patients/PatientPersonalInformationStatic.tsx +++ b/imports/ui/patients/PatientPersonalInformationStatic.tsx @@ -1,3 +1,5 @@ +import assert from 'assert'; + import React, {useEffect} from 'react'; import {list} from '@iterable-iterator/list'; @@ -66,9 +68,32 @@ import PatientDeletionDialog from './PatientDeletionDialog'; import usePatientPersonalInformationReducer from './usePatientPersonalInformationReducer'; import PatientPersonalInformationButtonsStatic from './PatientPersonalInformationButtonsStatic'; import PatientTagCommentEditionDialog from './PatientTagCommentEditionDialog'; +import useObservedPatientsWithChanges from './useObservedPatientsWithChanges'; +import useRandom from '../hooks/useRandom'; +import LinearProgress from '@mui/material/LinearProgress'; +import Alert from '@mui/material/Alert'; +import {Link} from 'react-router-dom'; +import MergeTypeIcon from '@mui/icons-material/MergeType'; +import UndoIcon from '@mui/icons-material/Undo'; const useStyles = makeStyles()((theme) => ({ - root: { + warning: { + marginTop: theme.spacing(-3), + marginBottom: theme.spacing(4), + }, + inlineIcon: { + verticalAlign: 'bottom', + }, + paper: { + position: 'relative', + }, + progress: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, + grid: { padding: theme.spacing(3), paddingBottom: theme.spacing(5), }, @@ -120,7 +145,7 @@ const ProblemText = styled(Typography)(({theme}) => ({ })); const tagToKey = (x) => x.name; -const tagCreate = (name) => ({name, displayName: name}); +const tagCreate = (name: string) => ({name, displayName: name}); const tagToNode = (x) => ( {x.displayName} {x.comment ? ({x.comment}) : null} @@ -131,11 +156,12 @@ const openUpdateTagCommentDialog = ({ kind, item, dialog, - editing, + readOnly, onSave, }) => - editing - ? async () => { + readOnly + ? undefined + : async () => { const result = await dialog((resolve) => ( ({results: []}); const PatientPersonalInformationStatic = ( - props: PatientPersonalInformationStaticProps, + {loading: loadingLast, found: foundLast, patient: last}: PatientPersonalInformationStaticProps, ) => { const dialog = useDialog(); const importantStringsDict = useImportantStringsDict(); - const [state, dispatch] = usePatientPersonalInformationReducer(props.patient); - const {editing, dirty, deleting, patient} = state; - const {loading = false} = props; + const [key, refresh] = useRandom(); + const [{editing, dirty, deleting, current: patient}, dispatch] = usePatientPersonalInformationReducer(last); + + const {loading: loadingInit, dirty: initChanged, results} = useObservedPatientsWithChanges( + editing ? {filter: {_id: last._id}, limit: 1} : null, + [editing ? last._id : '', key] + ); + + assert(results.length <= 1, 'At most one patient is returned.'); + + const init = results[0]; + const foundInit = init !== undefined; + + useEffect(() => { + if (editing && !loadingInit && foundInit) { + dispatch({type: 'merge', payload: init}); + } + }, [editing, loadingInit, foundInit, JSON.stringify(init)]); useEffect(() => { - if (patient !== props.patient) { - dispatch({type: 'init', payload: props.patient}); + if (!loadingLast && foundLast) { + dispatch({type: 'init', payload: last}); } - }, [JSON.stringify(props.patient)]); + }, [loadingLast, foundLast, JSON.stringify(last)]); usePrompt( 'You are trying to leave the page while in edit mode. Are you sure you want to continue?', @@ -192,21 +233,23 @@ const PatientPersonalInformationStatic = ( const {classes} = useStyles(); - const {value: reifiedNoShows} = useNoShowsForPatient(props.patient._id); + const {value: reifiedNoShows} = useNoShowsForPatient(last._id); const localizeBirthdate = useDateFormat('PPP'); const localizeAge = useDateFormatAge(); const birthdatePickerProps = useBirthdatePickerProps(); - if (loading) { - return Loading...; - } + const readOnly = Boolean(!editing || loadingInit); + + const loading = editing ? loadingInit : loadingLast; + + const found = readOnly ? foundLast : foundInit; - if (!patient) { + if (!found) { return Patient not found.; } - const placeholder = editing ? 'Write some information here' : '?'; + const placeholder = readOnly ? '?' : 'Write some information here'; const minRows = 8; const maxRows = 100; @@ -250,7 +293,7 @@ const PatientPersonalInformationStatic = ( onClick: openUpdateTagCommentDialog({ state: patient.allergies, kind: 'allergy', - editing, + readOnly, item, dialog, onSave: updateAllergies, @@ -262,7 +305,7 @@ const PatientPersonalInformationStatic = ( onClick: openUpdateTagCommentDialog({ state: patient.doctors, kind: 'doctor', - editing, + readOnly, item, dialog, onSave: updateDoctors, @@ -274,7 +317,7 @@ const PatientPersonalInformationStatic = ( onClick: openUpdateTagCommentDialog({ state: patient.insurances, kind: 'insurance', - editing, + readOnly, item, dialog, onSave: updateInsurances, @@ -282,8 +325,18 @@ const PatientPersonalInformationStatic = ( }); return ( - - + <> + {initChanged && ( + + Patient info was updated while editing. + Current state can be consulted at + {`/patient/${last._id}`}. + You can drop all local changes and load the current state by clicking . You can merge local changes with the current state and continue editing by clicking on . + + )} + + {loading && } +
{patient.photo ? ( @@ -308,8 +361,9 @@ const PatientPersonalInformationStatic = ( {displayedAge} )} {isDead && ( +
+
)} {editing && isDead && ( +
{...birthdatePickerProps} + readOnly={readOnly} label="Death date" value={deathdateLegal ?? null} slotProps={{ @@ -346,6 +403,7 @@ const PatientPersonalInformationStatic = ( } }} /> +
)} {!totalNoShow ? null : ( PVPP = {totalNoShow} @@ -362,7 +420,7 @@ const PatientPersonalInformationStatic = ( className={classes.formControl} label="NISS" value={patient.niss} - readOnly={!editing} + readOnly={readOnly} margin="normal" onChange={update('niss')} /> @@ -373,7 +431,7 @@ const PatientPersonalInformationStatic = ( className={classes.formControl} label="Last name" value={patient.lastname} - readOnly={!editing} + readOnly={readOnly} margin="normal" onChange={update('lastname')} /> @@ -384,7 +442,7 @@ const PatientPersonalInformationStatic = ( className={classes.formControl} label="First name" value={patient.firstname} - readOnly={!editing} + readOnly={readOnly} margin="normal" onChange={update('firstname')} /> @@ -397,7 +455,7 @@ const PatientPersonalInformationStatic = ( margin="normal" className={classes.formControl} value={patient.sex || ''} - readOnly={!editing} + readOnly={readOnly} onChange={update('sex')} > @@ -411,6 +469,7 @@ const PatientPersonalInformationStatic = ( {...birthdatePickerProps} + readOnly={readOnly} label="Birth date" value={_birthdate} disabled={!editing} @@ -437,6 +496,7 @@ const PatientPersonalInformationStatic = ( email.address} itemToString={(email) => email.address} @@ -760,19 +822,24 @@ const PatientPersonalInformationStatic = ( { dispatch({type: 'not-deleting'}); }} /> + ); }; diff --git a/imports/ui/patients/ReactivePatientChip.tests.tsx b/imports/ui/patients/ReactivePatientChip.tests.tsx index c385a8bb7..c45d88efc 100644 --- a/imports/ui/patients/ReactivePatientChip.tests.tsx +++ b/imports/ui/patients/ReactivePatientChip.tests.tsx @@ -56,7 +56,7 @@ client(__filename, () => { await Promise.all( patients.map(async (patient) => - findByRole('button', {name: displayName(patient)}, {timeout: 1500}), + findByRole('button', {name: displayName(patient)}, {timeout: 3000}), ), ); }); diff --git a/imports/ui/patients/useObservedPatientsWithChanges.ts b/imports/ui/patients/useObservedPatientsWithChanges.ts new file mode 100644 index 000000000..9b86c28b4 --- /dev/null +++ b/imports/ui/patients/useObservedPatientsWithChanges.ts @@ -0,0 +1,8 @@ +import makeObservedQueryHook from '../../api/makeObservedQueryHook'; +import {PatientsCache} from '../../api/collection/patients/cache'; + +import publication from '../../api/publication/patients/observe'; + +const useObservedPatients = makeObservedQueryHook(PatientsCache, publication, {changed: true}); + +export default useObservedPatients; diff --git a/imports/ui/patients/usePatientPersonalInformationReducer.ts b/imports/ui/patients/usePatientPersonalInformationReducer.ts index bc9f07b7b..f3d369742 100644 --- a/imports/ui/patients/usePatientPersonalInformationReducer.ts +++ b/imports/ui/patients/usePatientPersonalInformationReducer.ts @@ -1,16 +1,19 @@ import {useReducer} from 'react'; import {type PatientDocument} from '../../api/collection/patients'; +import {documentDiff} from '../../api/update'; type State = { - patient: PatientDocument; + init: PatientDocument; + current: Omit & {deathdate?: Date | null}; editing: boolean; dirty: boolean; deleting: boolean; }; -const initialState = (patient: PatientDocument): State => ({ - patient, +const initialState = (init: PatientDocument): State => ({ + init, + current: init, editing: false, dirty: false, deleting: false, @@ -22,28 +25,46 @@ type Action = | {type: 'not-editing'} | {type: 'deleting'} | {type: 'not-deleting'} - | {type: 'init'; payload: any}; + | {type: 'undo'} + | {type: 'merge'; payload: PatientDocument} + | {type: 'init'; payload: PatientDocument}; -export const reducer = (state: State, action: Action) => { +export const reducer = (state: State, action: Action): State => { switch (action.type) { case 'update': { + if (state[action.key] === action.value) { + return state; + } + switch (action.key) { case 'deathdateModifiedAt': { return { ...state, - patient: { - ...state.patient, - [action.key]: action.value, + current: { + ...state.current, + deathdateModifiedAt: action.value, deathdate: null, }, dirty: true, }; } + case 'deathdate': { + return { + ...state, + current: { + ...state.current, + deathdateModifiedAt: new Date(), + deathdate: action.value, + }, + dirty: true, + }; + } + default: { return { ...state, - patient: {...state.patient, [action.key]: action.value}, + current: {...state.current, [action.key]: action.value}, dirty: true, }; } @@ -66,13 +87,48 @@ export const reducer = (state: State, action: Action) => { return {...state, deleting: false}; } - case 'init': { + case 'undo': { return { ...state, editing: false, dirty: false, deleting: false, - patient: action.payload, + current: state.init + }; + } + + case 'merge': { + if (state.init === action.payload) { + return state; + } + + const {init, current} = state; + const changes = documentDiff(init, current); + const dirty = Object.keys(changes).length >= 1; + + return dirty ? { + ...state, + dirty: true, + init: action.payload, + current: { + ...action.payload, + ...changes, + }, + } : { + ...state, + dirty: false, + init: action.payload, + current: action.payload, + }; + } + + case 'init': { + if (state.editing) return state; + + return state.init === action.payload && state.current === action.payload ? state : { + ...state, + init: action.payload, + current: action.payload, }; } diff --git a/imports/ui/search/FullTextSearchResultsRoutes.tsx b/imports/ui/search/FullTextSearchResultsRoutes.tsx index c00e4aa6c..21d307bbc 100644 --- a/imports/ui/search/FullTextSearchResultsRoutes.tsx +++ b/imports/ui/search/FullTextSearchResultsRoutes.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useDeferredValue} from 'react'; import {styled} from '@mui/material/styles'; import {useParams} from 'react-router-dom'; @@ -40,14 +40,15 @@ const FullTextSearchResults = () => { const [key, refresh] = useRandom(); const {query: rawQuery} = useParams(); const query = myDecodeURIComponent(rawQuery); + const deferredQuery = useDeferredValue(query) return ( - Results for query `{query}`. + Results for query `{deferredQuery}`. diff --git a/imports/ui/tags/TagDetails.tsx b/imports/ui/tags/TagDetails.tsx index 89c7353c9..d76a960b5 100644 --- a/imports/ui/tags/TagDetails.tsx +++ b/imports/ui/tags/TagDetails.tsx @@ -1,4 +1,4 @@ -import React, {type DependencyList, useState} from 'react'; +import React, {type DependencyList} from 'react'; import Typography from '@mui/material/Typography'; @@ -6,6 +6,7 @@ import Center from '../grid/Center'; import Loading from '../navigation/Loading'; import NoContent from '../navigation/NoContent'; import type GenericQueryHook from '../../api/GenericQueryHook'; +import useRandom from '../hooks/useRandom'; const ListWithHeader = ({name, Card, List, useItem, listProps}) => { const {loading, item} = useItem(name, [name]); @@ -73,10 +74,7 @@ const TagDetails = (props: Props) => { limit: perpage, }; - const [refreshKey, setRefreshKey] = useState(Math.random()); - const refresh = () => { - setRefreshKey(Math.random()); - }; + const [refreshKey, refresh] = useRandom(); const {loading, results, dirty} = useParents(query, [ name, diff --git a/imports/ui/users/ChangePasswordPopover.tsx b/imports/ui/users/ChangePasswordPopover.tsx index af8bab609..77a246657 100644 --- a/imports/ui/users/ChangePasswordPopover.tsx +++ b/imports/ui/users/ChangePasswordPopover.tsx @@ -12,11 +12,12 @@ import {Popover, Form, RowTextField, RowButton} from './Popover'; type Props = { readonly id: string; - readonly anchorEl?: HTMLElement | null; + readonly anchorEl: HTMLElement; + readonly open: boolean; readonly handleClose: () => void; }; -const ChangePasswordPopover = ({id, anchorEl, handleClose}: Props) => { +const ChangePasswordPopover = ({id, anchorEl, open, handleClose}: Props) => { const {enqueueSnackbar, closeSnackbar} = useSnackbar(); const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); @@ -61,7 +62,7 @@ const ChangePasswordPopover = ({id, anchorEl, handleClose}: Props) => { { } }; +type Mode = 'choice' | 'options' | 'change-password'; + type OptionsPopoverProps = { readonly id: string; - readonly anchorEl?: HTMLElement | null; + readonly anchorEl: HTMLElement; + readonly open: boolean; readonly handleClose: () => void; - readonly changeMode: (mode: string) => void; + readonly changeMode: (mode: Mode) => void; }; const OptionsPopover = ({ id, anchorEl, + open, handleClose, changeMode, }: OptionsPopoverProps) => { @@ -105,7 +109,7 @@ const OptionsPopover = ({ Change password @@ -116,20 +120,20 @@ const OptionsPopover = ({ }; const Dashboard = ({currentUser}) => { - const [mode, setMode] = useState('options'); - const [anchorEl, setAnchorEl] = useState(null); + const anchorRef = useRef(null); + const anchorEl = anchorRef.current; + const [mode, setMode] = useState('choice'); - const handleClick = (event) => { + const handleClick = () => { setMode('options'); - setAnchorEl(event.currentTarget); }; - const handleClose = () => { - setAnchorEl(null); + const changeMode = (newMode: Mode) => { + setMode(newMode); }; - const changeMode = (newMode) => { - setMode(newMode); + const handleClose = () => { + setMode('choice'); }; const dashboardId = useUniqueId('dashboard'); @@ -139,6 +143,7 @@ const Dashboard = ({currentUser}) => { return (
- - )} + {anchorEl !== null && mode === 'change-password' && ( + + />)}
); }; diff --git a/imports/ui/users/LoginPopover.tsx b/imports/ui/users/LoginPopover.tsx index 1f0c93909..afe8e08c0 100644 --- a/imports/ui/users/LoginPopover.tsx +++ b/imports/ui/users/LoginPopover.tsx @@ -13,12 +13,14 @@ import debounceSnackbar from '../snackbar/debounceSnackbar'; import {Popover, Form, RowTextField, RowButton} from './Popover'; type Props = { + readonly id: string; readonly anchorEl: HTMLElement; + readonly open: boolean; readonly handleClose: () => void; - readonly changeMode: (mode: string) => void; + readonly changeMode: (mode: 'choice' | 'login' | 'register') => void; }; -const LoginPopover = ({anchorEl, handleClose, changeMode}: Props) => { +const LoginPopover = ({id, anchorEl, open, handleClose, changeMode}: Props) => { const {enqueueSnackbar, closeSnackbar} = useSnackbar(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -26,7 +28,7 @@ const LoginPopover = ({anchorEl, handleClose, changeMode}: Props) => { const [errorPassword, setErrorPassword] = useState(''); const [loggingIn, setLoggingIn] = useState(false); - const login = async (event) => { + const login = async (event: {preventDefault: () => void;}) => { event.preventDefault(); setLoggingIn(true); const feedback = debounceSnackbar({enqueueSnackbar, closeSnackbar}); @@ -78,9 +80,9 @@ const LoginPopover = ({anchorEl, handleClose, changeMode}: Props) => { return ( void; - readonly changeMode: (mode: string) => void; + readonly changeMode: (mode: 'choice' | 'login' | 'register') => void; }; -const RegisterPopover = ({anchorEl, handleClose, changeMode}: Props) => { +const RegisterPopover = ({id, anchorEl, open, handleClose, changeMode}: Props) => { const {enqueueSnackbar, closeSnackbar} = useSnackbar(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [errorUsername, setErrorUsername] = useState(''); const [errorPassword, setErrorPassword] = useState(''); - const register = async (event) => { + const register = async (event: {preventDefault: () => void;}) => { event.preventDefault(); const feedback = debounceSnackbar({enqueueSnackbar, closeSnackbar}); feedback('Creating account...', { @@ -72,9 +74,9 @@ const RegisterPopover = ({anchorEl, handleClose, changeMode}: Props) => { return ( { - const [anchorEl, setAnchorEl] = useState(null); + const anchorRef = useRef(null); + const anchorEl = anchorRef.current; const [mode, setMode] = useState('choice'); - const handleClick = (event) => { + const handleClick = () => { setMode('login'); - setAnchorEl(event.currentTarget); }; - const handleClose = () => { - setAnchorEl(null); + const changeMode = (newMode: 'choice' | 'login' | 'register') => { + setMode(newMode); }; - const changeMode = (newmode) => { - setMode(newmode); + const handleClose = () => { + setMode('choice'); }; + const signInFormId = useUniqueId('signInForm'); + const loginPopoverId = `${signInFormId}-login`; + const registerPopoverId = `${signInFormId}-register`; + return (
- {mode === 'login' ? ( + {anchorEl !== null && mode === 'login' && ( - ) : ( + )} + {anchorEl !== null && mode === 'register' && ( )}
diff --git a/test/app/client/patient/personal-information.app-tests.ts b/test/app/client/patient/personal-information.app-tests.ts index c84bb94e0..acbcf4046 100644 --- a/test/app/client/patient/personal-information.app-tests.ts +++ b/test/app/client/patient/personal-information.app-tests.ts @@ -1,5 +1,3 @@ -import assert from 'assert'; - import dateFormat from 'date-fns/format'; import { @@ -63,19 +61,20 @@ const editPatient = async ( user, userWithRealisticTypingSpeed, findByRole, - findAllByRole, findByLabelText, } = app; await user.click( - await findByRole('button', {name: /^edit info/i}, {timeout: 5000}), + await findByRole('button', {name: /^edit info$/i}, {timeout: 5000}), ); + await findByRole('button', {name: /^undo$/i}); + if (typeof nn === 'string') { - const matches = await findAllByRole('textbox', {name: 'NISS'}); - const inputs = matches.filter((x) => x.attributes.readonly === undefined); - assert(inputs.length === 1); - const input = inputs[0]; - await fillIn(app, input, nn); + await fillIn( + app, + await findByLabelText('NISS', { selector: 'input:not([readonly])' }), + nn + ); } if (typeof lastname === 'string') { @@ -95,7 +94,7 @@ const editPatient = async ( } if (typeof sex === 'string') { - await user.click(await findByLabelText('Sex')); + await user.click(await findByRole('combobox', {name: 'Sex'})); await user.click(await findByRole('option', {name: sex})); }