From 97a25467200e08aa873e9a28df6e279b04020452 Mon Sep 17 00:00:00 2001 From: Cosmin Date: Wed, 2 Sep 2026 08:01:51 -0400 Subject: [PATCH 1/3] UHM-9498: build O3 audit trail app --- packages/esm-audit-app/README.md | 111 +++ packages/esm-audit-app/jest.config.js | 3 + packages/esm-audit-app/package.json | 53 ++ packages/esm-audit-app/rspack.config.js | 1 + .../esm-audit-app/src/audit/audit-format.ts | 24 + .../src/audit/audit.component.tsx | 69 ++ .../src/audit/audit.resource.test.tsx | 155 +++++ .../esm-audit-app/src/audit/audit.resource.ts | 349 ++++++++++ packages/esm-audit-app/src/audit/audit.scss | 171 +++++ .../audit/encounter-audit.component.test.tsx | 242 +++++++ .../src/audit/encounter-audit.component.tsx | 204 ++++++ .../src/audit/encounter-filters.component.tsx | 75 ++ .../src/audit/encounter-filters.test.ts | 119 ++++ .../src/audit/encounter-filters.ts | 105 +++ .../src/audit/obs-audit-table.component.tsx | 181 +++++ .../esm-audit-app/src/audit/obs-audit.test.ts | 170 +++++ packages/esm-audit-app/src/audit/obs-audit.ts | 156 +++++ .../audit/patient-activity.component.test.tsx | 166 +++++ .../src/audit/patient-activity.component.tsx | 213 ++++++ .../src/audit/patient-activity.test.ts | 155 +++++ .../src/audit/patient-activity.ts | 156 +++++ .../audit/patient-encounters.component.tsx | 140 ++++ .../audit/patient-record.component.test.tsx | 194 ++++++ .../src/audit/patient-record.component.tsx | 115 +++ .../audit/patient-search.component.test.tsx | 73 ++ .../src/audit/patient-search.component.tsx | 120 ++++ packages/esm-audit-app/src/config-schema.ts | 45 ++ .../src/dashboard-link.component.tsx | 38 + packages/esm-audit-app/src/dashboard.meta.ts | 11 + packages/esm-audit-app/src/declarations.d.tsx | 3 + packages/esm-audit-app/src/index.ts | 33 + .../esm-audit-app/src/root.component.test.tsx | 58 ++ packages/esm-audit-app/src/root.component.tsx | 16 + packages/esm-audit-app/src/routes.json | 20 + packages/esm-audit-app/src/types.ts | 122 ++++ packages/esm-audit-app/translations/en.json | 90 +++ packages/esm-audit-app/translations/es.json | 90 +++ packages/esm-audit-app/translations/fr.json | 90 +++ packages/esm-audit-app/tsconfig.json | 5 + tools/setup-tests.ts | 9 +- yarn.lock | 654 +++++++++++++----- 41 files changed, 4618 insertions(+), 186 deletions(-) create mode 100644 packages/esm-audit-app/README.md create mode 100644 packages/esm-audit-app/jest.config.js create mode 100644 packages/esm-audit-app/package.json create mode 100644 packages/esm-audit-app/rspack.config.js create mode 100644 packages/esm-audit-app/src/audit/audit-format.ts create mode 100644 packages/esm-audit-app/src/audit/audit.component.tsx create mode 100644 packages/esm-audit-app/src/audit/audit.resource.test.tsx create mode 100644 packages/esm-audit-app/src/audit/audit.resource.ts create mode 100644 packages/esm-audit-app/src/audit/audit.scss create mode 100644 packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx create mode 100644 packages/esm-audit-app/src/audit/encounter-audit.component.tsx create mode 100644 packages/esm-audit-app/src/audit/encounter-filters.component.tsx create mode 100644 packages/esm-audit-app/src/audit/encounter-filters.test.ts create mode 100644 packages/esm-audit-app/src/audit/encounter-filters.ts create mode 100644 packages/esm-audit-app/src/audit/obs-audit-table.component.tsx create mode 100644 packages/esm-audit-app/src/audit/obs-audit.test.ts create mode 100644 packages/esm-audit-app/src/audit/obs-audit.ts create mode 100644 packages/esm-audit-app/src/audit/patient-activity.component.test.tsx create mode 100644 packages/esm-audit-app/src/audit/patient-activity.component.tsx create mode 100644 packages/esm-audit-app/src/audit/patient-activity.test.ts create mode 100644 packages/esm-audit-app/src/audit/patient-activity.ts create mode 100644 packages/esm-audit-app/src/audit/patient-encounters.component.tsx create mode 100644 packages/esm-audit-app/src/audit/patient-record.component.test.tsx create mode 100644 packages/esm-audit-app/src/audit/patient-record.component.tsx create mode 100644 packages/esm-audit-app/src/audit/patient-search.component.test.tsx create mode 100644 packages/esm-audit-app/src/audit/patient-search.component.tsx create mode 100644 packages/esm-audit-app/src/config-schema.ts create mode 100644 packages/esm-audit-app/src/dashboard-link.component.tsx create mode 100644 packages/esm-audit-app/src/dashboard.meta.ts create mode 100644 packages/esm-audit-app/src/declarations.d.tsx create mode 100644 packages/esm-audit-app/src/index.ts create mode 100644 packages/esm-audit-app/src/root.component.test.tsx create mode 100644 packages/esm-audit-app/src/root.component.tsx create mode 100644 packages/esm-audit-app/src/routes.json create mode 100644 packages/esm-audit-app/src/types.ts create mode 100644 packages/esm-audit-app/translations/en.json create mode 100644 packages/esm-audit-app/translations/es.json create mode 100644 packages/esm-audit-app/translations/fr.json create mode 100644 packages/esm-audit-app/tsconfig.json diff --git a/packages/esm-audit-app/README.md b/packages/esm-audit-app/README.md new file mode 100644 index 0000000..4d25bae --- /dev/null +++ b/packages/esm-audit-app/README.md @@ -0,0 +1,111 @@ +# @pih/esm-audit-app + +An audit trail for patient encounters and observations in the PIH EMR. + +This is the OpenMRS 3 replacement for the legacy `admin/encounters` pages, which let an auditor +search for a patient and then read, for any one of their encounters, everything that had been +entered, changed or deleted on it and by whom. It reads the same data through the OpenMRS REST API. + +## The audit trail + +The dashboard is a three step drill-down. Each step keeps its position in the page's query string, +so a particular encounter's audit trail can be linked to and the browser's back button walks back +up the drill-down. + +1. **Patient search** — find the patient by name or identifier + (`GET /ws/rest/v1/patient?q=…`). +2. **Encounter list** — every encounter recorded for that patient, most recent first + (`GET /ws/rest/v1/encounter?patient=…&order=desc`), narrowable by encounter type and by the date + the encounter happened. The type dropdown offers only the types this patient's own encounters + use, read from a scan of their encounters with a rep that carries nothing but the type, so it + never offers a type that would return nothing. Deleted encounters are hidden by default; see the + caveat below. +3. **Encounter audit** — how the encounter itself was recorded (created by, changed by, deleted by, + with the reason), its providers, and the full history of its observations + (`GET /ws/rest/v1/encounter/{uuid}` and `GET /ws/rest/v1/obs?encounter={uuid}&includeAll=true`). + +The patient step has a second tab, **Record activity**, which answers "who touched this record": +one row per user with what they created, edited and deleted, and the individual events behind those +counts, each linking to the encounter it happened on. Clicking a user's name narrows the activity +log to that user, and clicking it again — or dismissing the tag beside the log's heading — widens +it back out. The tab reads the same filters as the encounter list, so narrowing by type or date +narrows the activity too. + +### How each observation is classified + +OpenMRS never overwrites an obs: editing one voids the old row and saves a new row that points back +at it through `previousVersion`. The obs search with `includeAll=true` returns every obs row for the +encounter — group members and voided rows included — which is enough to reconstruct what happened. +`src/audit/obs-audit.ts` derives one status per obs, and the table tags them accordingly: + +| Status | What it means | +| ----------------- | --------------------------------------------------------------------------------------------------------------- | +| _(no tag)_ | Entered when the encounter was first saved, and untouched since. | +| **Edited** | The obs points at a `previousVersion`, so its value was changed after it was first recorded. | +| **Added later** | The obs's `dateCreated` differs from the encounter's, so it was saved in a later transaction than the encounter. | +| **Deleted** | The obs is voided and nothing replaced it — the value was removed from the encounter. | +| **Previous value**| The obs is voided and a surviving obs names it as its `previousVersion` — this is the value an edit replaced. | + +Deleted and superseded obs are struck through, as they were on the legacy page, and each version of +a value is listed directly beneath the version that replaced it. Obs group members are indented +under their group. + +### Caveat: deleted encounters + +The REST API's encounter-by-patient search always excludes voided encounters and does not honour +`includeAll`. Listing deleted encounters therefore falls back to the free-text encounter search, +which does honour it, using the patient's preferred identifier as the search phrase; because that +search matches any patient whose name or identifier contains the phrase, the results are narrowed +back down to the patient in hand and paginated on the client. A patient with no identifier at all +cannot have their deleted encounters listed, and the page says so. + +That search also takes no filter parameters, so the encounter type and date range are applied by +the server on the normal path (`encounterType`, `fromdate` and `todate`, which the API compares +against `encounterDatetime` inclusively) and on the client on the deleted-encounter path. +`src/audit/encounter-filters.ts` holds both, so the two paths cannot drift apart. Dates are sent to +the server without a time zone, so it reads them as the facility's own day rather than as UTC +instants. + +Because that search is the only way to see deleted encounters, it is also where the encounter types +come from once deleted encounters are shown — otherwise a type used only by a deleted encounter +would be missing from the dropdown. Both hooks build the same url, so SWR serves the second one +from cache rather than reading it twice. + +### Caveat: how far the activity scan reaches + +Encounters carry their own `auditInfo`, so who created, changed or deleted each one comes free with +the encounter list. Observations do not: they have to be read per encounter, because the +obs-by-encounter search is the only one that honours `includeAll` and so the only one that can see +deleted observations. That is one request per encounter, so the activity view reads only the +`activityScanLimit` most recent encounters — five requests in flight at a time — and says plainly +when it covered part of the record. Filtering by type or date is how an auditor reaches further +back. + +## Enabling the app + +Because `audit-dashboard-link` is declared against `homepage-dashboard-slot` in +`routes.json`, it is attached automatically for every implementation that loads +this module — no `add` entry is needed. The shared `base-config.json` in the +[pihemr](https://github.com/PIH/pihemr) repo only pins where the link sits in +the home nav, via that slot's `order` list. + +A site that does *not* want the dashboard adds `audit-dashboard-link` to the +slot's `remove` list in its own `config.json`. Note that `remove` wins over +`add` when an id appears in both, and that config arrays are replaced rather +than merged, so such an override must repeat the whole `remove` list from +`base-config.json`. + +Reading the audit trail needs the `View Encounters` and `View Observations` privileges, the same +ones the legacy pages required. + +## Configuration + +See the [openmrs-esm-module-config docs](https://wiki.openmrs.org/display/projects/openmrs-esm-module-config) +for information about how to provide configuration files. + +| Key | Default | Description | +| ----------------------- | -------------------------------------------------- | -------------------------------------------------- | +| `patientSearchPageSize` | `10` | Patients per page of search results. | +| `encountersPageSize` | `10` | Encounters per page of a patient's encounter list. | +| `patientChartUrl` | `${openmrsSpaBase}/patient/${patientUuid}/chart` | Where the patient's name in the encounter audit links to. | +| `activityScanLimit` | `50` | How many encounters the activity view reads observations from. | diff --git a/packages/esm-audit-app/jest.config.js b/packages/esm-audit-app/jest.config.js new file mode 100644 index 0000000..0352f62 --- /dev/null +++ b/packages/esm-audit-app/jest.config.js @@ -0,0 +1,3 @@ +const rootConfig = require('../../jest.config.js'); + +module.exports = rootConfig; diff --git a/packages/esm-audit-app/package.json b/packages/esm-audit-app/package.json new file mode 100644 index 0000000..5e44b08 --- /dev/null +++ b/packages/esm-audit-app/package.json @@ -0,0 +1,53 @@ +{ + "name": "@pih/esm-audit-app", + "version": "2.1.0", + "description": "An audit trail for patient encounters and observations in the PIH EMR", + "browser": "dist/pih-esm-audit-app.js", + "main": "src/index.ts", + "source": true, + "license": "MPL-2.0", + "homepage": "https://github.com/PIH/openmrs-esm-pihemr#readme", + "scripts": { + "start": "openmrs develop", + "serve": "rspack serve --mode=development", + "debug": "npm run serve", + "build": "rspack --mode production", + "analyze": "rspack --mode=production --env analyze=true", + "lint": "cross-env eslint src --ext ts,tsx", + "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color", + "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color", + "coverage": "yarn test --coverage", + "typescript": "tsc", + "extract-translations": "i18next 'src/**/*.component.tsx'" + }, + "browserslist": [ + "extends browserslist-config-openmrs" + ], + "keywords": [ + "openmrs" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/PIH/openmrs-esm-pihemr.git" + }, + "bugs": { + "url": "https://github.com/PIH/openmrs-esm-pihemr/issues" + }, + "dependencies": { + "@babel/runtime": "^7.24.0" + }, + "peerDependencies": { + "@openmrs/esm-framework": "8.x", + "i18next": "^19.0.0", + "react": "18.x", + "react-i18next": "16.x", + "react-router-dom": "^6.x", + "swr": "2.x" + }, + "devDependencies": { + "openmrs": "^10.0.1-pre.5263" + } +} diff --git a/packages/esm-audit-app/rspack.config.js b/packages/esm-audit-app/rspack.config.js new file mode 100644 index 0000000..b2bb3b9 --- /dev/null +++ b/packages/esm-audit-app/rspack.config.js @@ -0,0 +1 @@ +module.exports = require('openmrs/default-rspack-config'); diff --git a/packages/esm-audit-app/src/audit/audit-format.ts b/packages/esm-audit-app/src/audit/audit-format.ts new file mode 100644 index 0000000..6706c0a --- /dev/null +++ b/packages/esm-audit-app/src/audit/audit-format.ts @@ -0,0 +1,24 @@ +import { formatDate, formatDatetime, parseDate } from '@openmrs/esm-framework'; +import { type OpenmrsResourceRef } from '../types'; + +/** Formats a REST date, without the "Today" shorthand — an audit trail wants literal dates. */ +export function formatAuditDate(date: string | undefined): string { + return date ? formatDate(parseDate(date), { mode: 'wide', time: false, noToday: true }) : ''; +} + +export function formatAuditDatetime(date: string | undefined): string { + return date ? formatDatetime(parseDate(date), { mode: 'wide', noToday: true }) : ''; +} + +/** + * Renders the "who and when" of an audit entry, e.g. `Cos John — 01 Sep 2026, 11:09 AM`. The user + * reference the API returns for `creator`, `changedBy` and `voidedBy` displays as the person's + * name, or as the username where the account has no person name. + */ +export function formatUserAndDate(user: OpenmrsResourceRef | undefined, date: string | undefined): string { + const formattedDate = formatAuditDatetime(date); + if (user?.display && formattedDate) { + return `${user.display} — ${formattedDate}`; + } + return user?.display ?? formattedDate; +} diff --git a/packages/esm-audit-app/src/audit/audit.component.tsx b/packages/esm-audit-app/src/audit/audit.component.tsx new file mode 100644 index 0000000..e72231e --- /dev/null +++ b/packages/esm-audit-app/src/audit/audit.component.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { PageHeader, PageHeaderContent, PatientSearchPictogram } from '@openmrs/esm-framework'; +import EncounterAudit from './encounter-audit.component'; +import PatientRecord, { type PatientRecordView, patientRecordViews } from './patient-record.component'; +import PatientSearch from './patient-search.component'; +import styles from './audit.scss'; + +/** + * The audit trail is a three step drill-down — find a patient, pick one of their encounters, then + * read what was entered, changed and deleted on it. The current step is held in the query string + * so that a particular encounter's audit trail can be linked to and so that the browser's back + * button walks back up the drill-down. + */ +export default function Audit() { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const patientUuid = searchParams.get('patient'); + const encounterUuid = searchParams.get('encounter'); + const view = searchParams.get('view'); + const patientRecordView: PatientRecordView = patientRecordViews.includes(view as PatientRecordView) + ? (view as PatientRecordView) + : 'encounters'; + + const goTo = useCallback( + (next: { patient?: string; encounter?: string; view?: PatientRecordView }) => { + const params = new URLSearchParams(searchParams); + params.delete('patient'); + params.delete('encounter'); + params.delete('view'); + if (next.patient) { + params.set('patient', next.patient); + } + if (next.encounter) { + params.set('encounter', next.encounter); + } + if (next.view && next.view !== 'encounters') { + params.set('view', next.view); + } + setSearchParams(params); + }, + [searchParams, setSearchParams], + ); + + return ( +
+ + } /> + + {encounterUuid ? ( + goTo({ patient: uuid ?? patientUuid, view: patientRecordView })} + /> + ) : patientUuid ? ( + goTo({})} + onSelectEncounter={(uuid) => goTo({ patient: patientUuid, encounter: uuid, view: patientRecordView })} + onSelectView={(nextView) => goTo({ patient: patientUuid, view: nextView })} + patientUuid={patientUuid} + view={patientRecordView} + /> + ) : ( + goTo({ patient: uuid })} /> + )} +
+ ); +} diff --git a/packages/esm-audit-app/src/audit/audit.resource.test.tsx b/packages/esm-audit-app/src/audit/audit.resource.test.tsx new file mode 100644 index 0000000..f0a8ee0 --- /dev/null +++ b/packages/esm-audit-app/src/audit/audit.resource.test.tsx @@ -0,0 +1,155 @@ +import React, { type PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import { openmrsFetch } from '@openmrs/esm-framework'; +import { type AuditEncounter, type AuditPatient } from '../types'; +import { usePatientEncounters, usePatientEncounterTypes } from './audit.resource'; +import { type EncounterFilters } from './encounter-filters'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); + +const patient: AuditPatient = { + uuid: 'patient-1', + display: 'Y2AHXV - Dave TestPatient', + identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }], +}; + +const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' }; +const checkinType = { uuid: 'type-checkin', display: 'Inscription' }; + +const april: AuditEncounter = { + uuid: 'enc-april', + encounterDatetime: '2026-04-18T09:00:00.000+0000', + patient: { uuid: 'patient-1', display: 'Dave TestPatient' }, + encounterType: consultationType, +}; + +const may: AuditEncounter = { + uuid: 'enc-may', + encounterDatetime: '2026-05-02T09:00:00.000+0000', + voided: true, + patient: { uuid: 'patient-1', display: 'Dave TestPatient' }, + encounterType: checkinType, +}; + +const wrapper = ({ children }: PropsWithChildren) => ( + new Map() }}>{children} +); + +function renderUsePatientEncounters(includeDeleted: boolean, filters: EncounterFilters) { + return renderHook(() => usePatientEncounters(patient, includeDeleted, filters, 1, 10), { wrapper }); +} + +describe('usePatientEncounters', () => { + beforeEach(() => { + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/encounter?q=')) { + return Promise.resolve({ data: { results: [april, may] } }) as ReturnType; + } + return Promise.resolve({ data: { results: [april], totalCount: 1 } }) as ReturnType; + }); + }); + + it('reuses one read of the deleted-encounter search across both hooks', async () => { + const { result } = renderHook( + () => ({ + encounters: usePatientEncounters(patient, true, {}, 1, 10), + types: usePatientEncounterTypes(patient, true), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.types.encounterTypes).toHaveLength(2)); + expect(result.current.encounters.encounters).toHaveLength(2); + expect(mockOpenmrsFetch.mock.calls.filter(([url]) => (url as string).includes('/encounter?q=')).length).toBe(1); + }); + + it('has the server apply the filters when deleted encounters are excluded', async () => { + const { result } = renderUsePatientEncounters(false, { + encounterType: consultationType, + fromDate: '2026-04-01', + toDate: '2026-04-30', + }); + + await waitFor(() => expect(result.current.encounters).toHaveLength(1)); + expect(mockOpenmrsFetch).toHaveBeenCalledWith( + expect.stringContaining( + '&encounterType=type-consultation&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59', + ), + ); + }); + + it('applies the filters on the client when deleted encounters are included', async () => { + const { result } = renderUsePatientEncounters(true, { fromDate: '2026-05-01' }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.encounters.map((encounter) => encounter.uuid)).toEqual(['enc-may']); + expect(result.current.totalCount).toBe(1); + expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/encounter?q=Y2AHXV&includeAll=true')); + }); + + it('returns both encounters when nothing is filtered', async () => { + const { result } = renderUsePatientEncounters(true, {}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.encounters.map((encounter) => encounter.uuid)).toEqual(['enc-may', 'enc-april']); + }); + + it('cannot list deleted encounters for a patient with no identifier', async () => { + const { result } = renderHook( + () => usePatientEncounters({ uuid: 'patient-2', display: 'No Identifier' }, true, {}, 1, 10), + { wrapper }, + ); + + await waitFor(() => expect(result.current.cannotIncludeDeleted).toBe(true)); + expect(result.current.encounters).toEqual([]); + }); +}); + +describe('usePatientEncounterTypes', () => { + it("offers only the types the patient's own encounters use", async () => { + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('totalCount=true')) { + return Promise.resolve({ data: { results: [], totalCount: 0 } }) as ReturnType; + } + return Promise.resolve({ data: { results: [april, { ...april, uuid: 'enc-again' }] } }) as ReturnType< + typeof openmrsFetch + >; + }); + + const { result } = renderHook(() => usePatientEncounterTypes(patient, false), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.encounterTypes).toEqual([consultationType]); + }); + + it('includes types that only deleted encounters use, once those are shown', async () => { + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/encounter?q=')) { + return Promise.resolve({ data: { results: [april, may] } }) as ReturnType; + } + return Promise.resolve({ data: { results: [april] } }) as ReturnType; + }); + + const { result } = renderHook(() => usePatientEncounterTypes(patient, true), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.encounterTypes).toEqual([checkinType, consultationType]); + }); + + it("leaves out other patients' types when reading the deleted-encounter search", async () => { + mockOpenmrsFetch.mockImplementation( + () => + Promise.resolve({ + data: { + results: [april, { ...may, patient: { uuid: 'patient-2', display: 'Someone Else' } }], + }, + }) as ReturnType, + ); + + const { result } = renderHook(() => usePatientEncounterTypes(patient, true), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.encounterTypes).toEqual([consultationType]); + }); +}); diff --git a/packages/esm-audit-app/src/audit/audit.resource.ts b/packages/esm-audit-app/src/audit/audit.resource.ts new file mode 100644 index 0000000..4404b6b --- /dev/null +++ b/packages/esm-audit-app/src/audit/audit.resource.ts @@ -0,0 +1,349 @@ +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { type AuditEncounter, type AuditObs, type AuditPatient, type ObsTreeNode, type PagedResponse } from '../types'; +import { + buildEncounterFilterQuery, + distinctEncounterTypes, + type EncounterFilters, + matchesEncounterFilters, +} from './encounter-filters'; +import { buildObsTree } from './obs-audit'; +import { buildAuditEvents, type ObsByEncounter, summariseByUser } from './patient-activity'; + +const patientRep = + 'custom:(uuid,display,identifiers:(uuid,identifier,preferred,identifierType:(uuid,display))' + + ',person:(display,gender,age,birthdate,dead))'; + +/** + * `patient` is needed to narrow the free-text search used to list deleted encounters, and + * `auditInfo` is what the activity view reads to say who created, changed or deleted each one. + */ +const encounterListRep = + 'custom:(uuid,display,encounterDatetime,voided,patient:(uuid),encounterType:(uuid,display)' + + ',form:(uuid,display),location:(uuid,display)' + + ',encounterProviders:(uuid,voided,provider:(uuid,display),encounterRole:(uuid,display)),auditInfo)'; + +const encounterDetailRep = + 'custom:(uuid,display,encounterDatetime,voided,patient:(uuid,display,identifiers:(uuid,identifier,preferred))' + + ',location:(uuid,display),form:(uuid,display,version),encounterType:(uuid,display)' + + ',visit:(uuid,display,startDatetime,visitType:(uuid,display))' + + ',encounterProviders:(uuid,voided,provider:(uuid,display,identifier),encounterRole:(uuid,display)),auditInfo)'; + +/** + * `value:ref` keeps coded values down to a `{uuid, display}` reference while leaving numeric, text + * and datetime values as the scalars they are. `previousVersion` and `auditInfo` are what make the + * audit trail possible: the former links an obs to the value it replaced, the latter carries the + * creator, the editor and the voiding user with their timestamps. + */ +const obsRep = + 'custom:(uuid,display,obsDatetime,voided,comment,formFieldPath,value:ref' + + ',concept:(uuid,display,descriptions:(description,locale)),obsGroup:(uuid)' + + ',previousVersion:(uuid,display),auditInfo)'; + +/** A stable identity, so the unfiltered reads do not rebuild their memos on every render. */ +const noFilters: EncounterFilters = {}; + +/** Just enough of an obs to say who touched it and when. */ +const activityObsRep = 'custom:(uuid,voided,concept:(uuid,display),previousVersion:(uuid),auditInfo)'; + +/** The REST API caps `limit` server-side, so bulk reads are fetched a page at a time. */ +const bulkPageSize = 100; +const maxBulkPages = 25; + +/** + * Reads every page of a paginated REST endpoint. The `next` link the API returns is built from the + * server's configured URI prefix, which is not always reachable from the browser, so this walks + * `startIndex` itself instead of following the link. + */ +async function fetchAllPages(url: string): Promise> { + const results: Array = []; + const separator = url.includes('?') ? '&' : '?'; + + for (let page = 0; page < maxBulkPages; page++) { + const response = await openmrsFetch>( + `${url}${separator}startIndex=${page * bulkPageSize}&limit=${bulkPageSize}`, + ); + const batch = response?.data?.results ?? []; + results.push(...batch); + if (batch.length < bulkPageSize) { + break; + } + } + + return results; +} + +/** How many encounters' observations to read at once, so a scan does not flood the server. */ +const scanConcurrency = 5; + +/** Runs `worker` over every item, keeping at most `limit` requests in flight. */ +async function mapWithConcurrency(items: Array, limit: number, worker: (item: T) => Promise) { + const results = new Array(items.length); + let next = 0; + + async function run() { + while (next < items.length) { + const index = next++; + results[index] = await worker(items[index]); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run)); + return results; +} + +/** The identifier to show for a patient, and the one to search encounters by. */ +export function getPreferredIdentifier(patient: AuditPatient | undefined): string | undefined { + const identifiers = patient?.identifiers; + if (!identifiers?.length) { + return undefined; + } + return (identifiers.find((identifier) => identifier.preferred) ?? identifiers[0]).identifier; +} + +/** + * Searches patients by name or identifier, one page at a time. The audit trail starts here + * because a patient is what an auditor has to hand — a name, or an EMR ID off a paper form. + */ +export function usePatientSearch(query: string, page: number, pageSize: number) { + const startIndex = (page - 1) * pageSize; + const url = query + ? `${restBaseUrl}/patient?q=${encodeURIComponent(query)}&v=${patientRep}` + + `&startIndex=${startIndex}&limit=${pageSize}&totalCount=true` + : null; + + const { data, error, isLoading } = useSWR>>(url, openmrsFetch); + + return { + patients: data?.data?.results ?? [], + totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0, + error, + isLoading, + }; +} + +export function useAuditPatient(patientUuid: string | null) { + const url = patientUuid ? `${restBaseUrl}/patient/${patientUuid}?v=${patientRep}` : null; + const { data, error, isLoading } = useSWR>(url, openmrsFetch); + + return { patient: data?.data, error, isLoading }; +} + +/** + * The search phrase used to reach a patient's deleted encounters, and the url that reads them. + * Both `usePatientEncounters` and `usePatientEncounterTypes` need that list, so they build the + * same url and SWR serves it to whichever of them asks second. + */ +function encounterSearchPhrase(patient: AuditPatient | undefined): string | undefined { + return getPreferredIdentifier(patient) ?? patient?.person?.display; +} + +/** + * Whether a patient's deleted encounters can be reached at all. The free-text search that finds + * them needs a phrase to search on, so a patient with neither an identifier nor a name has none. + */ +export function canListDeletedEncounters(patient: AuditPatient | undefined): boolean { + return Boolean(encounterSearchPhrase(patient)); +} + +function deletedEncountersUrl(patient: AuditPatient | undefined): string | null { + const searchPhrase = encounterSearchPhrase(patient); + return patient && searchPhrase + ? `${restBaseUrl}/encounter?q=${encodeURIComponent(searchPhrase)}&includeAll=true&v=${encounterListRep}` + : null; +} + +function encountersForPatient(encounters: Array | undefined, patient: AuditPatient | undefined) { + return (encounters ?? []).filter((encounter) => encounter.patient?.uuid === patient?.uuid); +} + +/** + * Every encounter a patient has, in one go rather than a page at a time — what the encounter type + * options and the activity view both need. The two paths mirror `usePatientEncounters`: the + * by-patient search for live encounters, and the free-text search when deleted ones are wanted. + * Both use `encounterListRep`, so a read made for one caller serves the others from cache. + */ +export function useAllPatientEncounters( + patient: AuditPatient | undefined, + includeDeleted: boolean, + filters: EncounterFilters, +) { + const scanUrl = + patient && !includeDeleted + ? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` + + `${buildEncounterFilterQuery(filters)}&order=desc` + : null; + const scanResult = useSWR>(scanUrl, fetchAllPages); + + const bulkResult = useSWR>( + includeDeleted ? deletedEncountersUrl(patient) : null, + fetchAllPages, + ); + + const encounters = useMemo(() => { + if (!includeDeleted) { + return scanResult.data ?? []; + } + return encountersForPatient(bulkResult.data, patient) + .filter((encounter) => matchesEncounterFilters(encounter, filters)) + .sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? '')); + }, [bulkResult.data, filters, includeDeleted, patient, scanResult.data]); + + return { + encounters, + error: includeDeleted ? bulkResult.error : scanResult.error, + isLoading: includeDeleted ? bulkResult.isLoading : scanResult.isLoading, + }; +} + +/** + * The encounter types to offer as filters: the ones this patient's own encounters use, so the + * dropdown never offers a type that would return nothing. Read unfiltered, so choosing a type + * does not narrow the choices left. + */ +export function usePatientEncounterTypes(patient: AuditPatient | undefined, includeDeleted: boolean) { + const { encounters, error, isLoading } = useAllPatientEncounters(patient, includeDeleted, noFilters); + const encounterTypes = useMemo(() => distinctEncounterTypes(encounters), [encounters]); + + return { encounterTypes, error, isLoading }; +} + +/** + * Lists a patient's encounters, optionally narrowed by encounter type and date range. + * + * The API's encounter-by-patient search always excludes voided encounters, so showing deleted + * encounters means falling back to the free-text encounter search, which does honour + * `includeAll`, with the patient's identifier as the search phrase. That search matches any + * patient whose name or identifier contains the phrase, so the results are narrowed back down to + * the patient in hand, and paginated on the client. It also takes no filter parameters, so the + * filters are applied on the client for that path and by the server for the other. + */ +export function usePatientEncounters( + patient: AuditPatient | undefined, + includeDeleted: boolean, + filters: EncounterFilters, + page: number, + pageSize: number, +) { + const startIndex = (page - 1) * pageSize; + + const pagedUrl = + patient && !includeDeleted + ? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` + + `${buildEncounterFilterQuery(filters)}&order=desc&startIndex=${startIndex}&limit=${pageSize}&totalCount=true` + : null; + const pagedResult = useSWR>>(pagedUrl, openmrsFetch); + + const bulkResult = useSWR>( + includeDeleted ? deletedEncountersUrl(patient) : null, + fetchAllPages, + ); + + const allEncounters = useMemo(() => { + if (!includeDeleted) { + return null; + } + return encountersForPatient(bulkResult.data, patient) + .filter((encounter) => matchesEncounterFilters(encounter, filters)) + .sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? '')); + }, [bulkResult.data, filters, includeDeleted, patient]); + + if (includeDeleted) { + return { + encounters: (allEncounters ?? []).slice(startIndex, startIndex + pageSize), + totalCount: allEncounters?.length ?? 0, + error: bulkResult.error, + isLoading: bulkResult.isLoading, + /** True when the patient has no identifier to run the deleted-encounter search with. */ + cannotIncludeDeleted: Boolean(patient) && !canListDeletedEncounters(patient), + }; + } + + return { + encounters: pagedResult.data?.data?.results ?? [], + totalCount: pagedResult.data?.data?.totalCount ?? pagedResult.data?.data?.results?.length ?? 0, + error: pagedResult.error, + isLoading: pagedResult.isLoading, + cannotIncludeDeleted: false, + }; +} + +/** + * Loads one encounter together with every obs ever recorded against it — group members and voided + * obs included, which is what `includeAll` buys — and assembles the audit trail. + */ +export function useEncounterAudit(encounterUuid: string | null): { + encounter: AuditEncounter | undefined; + obsTree: Array; + error: Error | undefined; + isLoading: boolean; +} { + const encounterUrl = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}?v=${encounterDetailRep}` : null; + const encounterResult = useSWR>(encounterUrl, openmrsFetch); + + const obsUrl = encounterUuid ? `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${obsRep}` : null; + const obsResult = useSWR>(obsUrl, fetchAllPages); + + const encounter = encounterResult.data?.data; + const obsTree = useMemo(() => buildObsTree(obsResult.data ?? [], encounter), [obsResult.data, encounter]); + + return { + encounter, + obsTree, + error: encounterResult.error ?? obsResult.error, + isLoading: encounterResult.isLoading || obsResult.isLoading, + }; +} + +/** + * Who touched this patient's record: every creation, edit and deletion across their encounters, + * summarised per user and listed in order. + * + * The encounter rows carry their own `auditInfo`, but the observations do not come with them — + * they have to be read per encounter, since that is the only obs search that honours + * `includeAll` and so the only one that can see deleted observations. That is one request per + * encounter, so the scan is capped at the `scanLimit` most recent encounters and reports what it + * covered; narrowing by type or date is how an auditor reaches further back. + */ +export function usePatientActivity( + patient: AuditPatient | undefined, + includeDeleted: boolean, + filters: EncounterFilters, + scanLimit: number, +) { + const { + encounters, + error: encountersError, + isLoading: isLoadingEncounters, + } = useAllPatientEncounters(patient, includeDeleted, filters); + + const scannedEncounters = useMemo(() => encounters.slice(0, scanLimit), [encounters, scanLimit]); + const scannedUuids = useMemo(() => scannedEncounters.map((encounter) => encounter.uuid), [scannedEncounters]); + + const obsResult = useSWR( + scannedUuids.length ? ['audit-activity-obs', scannedUuids] : null, + async () => { + const perEncounter = await mapWithConcurrency(scannedUuids, scanConcurrency, (encounterUuid) => + fetchAllPages(`${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${activityObsRep}`), + ); + return Object.fromEntries(scannedUuids.map((encounterUuid, index) => [encounterUuid, perEncounter[index]])); + }, + ); + + const events = useMemo( + () => buildAuditEvents(scannedEncounters, obsResult.data ?? {}), + [obsResult.data, scannedEncounters], + ); + const userActivity = useMemo(() => summariseByUser(events), [events]); + + return { + events, + userActivity, + /** How many encounters were read, and how many matched — they differ when the cap bites. */ + scannedCount: scannedEncounters.length, + matchedCount: encounters.length, + error: encountersError ?? obsResult.error, + isLoading: isLoadingEncounters || obsResult.isLoading, + }; +} diff --git a/packages/esm-audit-app/src/audit/audit.scss b/packages/esm-audit-app/src/audit/audit.scss new file mode 100644 index 0000000..69fd340 --- /dev/null +++ b/packages/esm-audit-app/src/audit/audit.scss @@ -0,0 +1,171 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.container { + background-color: $ui-background; + min-height: 100%; + padding-bottom: layout.$spacing-07; +} + +.pageHeader { + margin-bottom: layout.$spacing-05; +} + +.section { + padding: 0 layout.$spacing-05; +} + +.sectionHeading { + @include type.type-style('productive-heading-02'); + color: $text-02; + margin: layout.$spacing-05 0 layout.$spacing-03; +} + +.contextHeader { + display: flex; + flex-direction: column; + gap: layout.$spacing-02; + margin-bottom: layout.$spacing-05; +} + +.contextTitle { + @include type.type-style('productive-heading-03'); + color: $color-gray-100; +} + +.contextSubtitle { + @include type.type-style('body-long-01'); + color: $text-02; +} + +.backButton { + margin-left: -#{layout.$spacing-05}; +} + +.searchInput { + max-width: 30rem; + margin-bottom: layout.$spacing-05; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: layout.$spacing-05; + margin-bottom: layout.$spacing-03; +} + +.tableContainer { + margin-bottom: layout.$spacing-03; +} + +/* The whole row is the target for opening a patient or an encounter. */ +.clickableRow { + cursor: pointer; +} + +.summaryList { + background-color: $ui-01; + margin-bottom: layout.$spacing-05; +} + +.summaryLabel { + @include type.type-style('productive-heading-01'); + color: $text-02; + width: 14rem; +} + +.summaryValue { + @include type.type-style('body-short-01'); + color: $color-gray-100; +} + +/* Voided rows keep the legacy admin page's struck-through, muted look. */ +.voidedRow td { + color: $text-02; + text-decoration: line-through; +} + +.obsConcept { + @include type.type-style('body-short-01'); +} + +.obsDescription { + @include type.type-style('label-01'); + color: $text-02; + padding: 0 0 layout.$spacing-03; + text-decoration: none; +} + +.legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: layout.$spacing-03 layout.$spacing-05; + margin-bottom: layout.$spacing-05; +} + +.legendItem { + @include type.type-style('label-01'); + color: $text-02; + display: flex; + align-items: center; + gap: layout.$spacing-02; +} + +.emptyState { + @include type.type-style('body-long-01'); + color: $text-02; + padding: layout.$spacing-05 0; +} + +.inlineNotification { + max-width: none; + margin-bottom: layout.$spacing-05; +} + +/* A table cell that opens a drill-down: looks like a link, behaves like a button. */ +.linkButton { + @include type.type-style('body-short-01'); + background: none; + border: none; + color: $interactive-01; + cursor: pointer; + padding: 0; + text-align: left; + + &:hover, + &:focus { + text-decoration: underline; + } +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: layout.$spacing-05; + margin-bottom: layout.$spacing-05; +} + +.filterControl { + min-width: 16rem; +} + +/* Sits on the same baseline as the filter inputs, which carry their own labels. */ +.clearFilters { + margin-bottom: layout.$spacing-01; +} + +.logHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: layout.$spacing-03; +} + +/* Marks the user whose events the activity log is showing. */ +.selectedRow td { + background-color: $color-blue-10; +} diff --git a/packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx b/packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx new file mode 100644 index 0000000..966b30d --- /dev/null +++ b/packages/esm-audit-app/src/audit/encounter-audit.component.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SWRConfig } from 'swr'; +import { openmrsFetch, useConfig } from '@openmrs/esm-framework'; +import { type AuditEncounter, type AuditObs } from '../types'; +import EncounterAudit from './encounter-audit.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockUseConfig = jest.mocked(useConfig); + +const encounterCreated = '2026-09-01T11:09:00.000+0000'; +const dayAfterEncounter = '2026-09-02T08:30:00.000+0000'; +const creator = { uuid: 'user-1', display: 'Cos John' }; + +const mockEncounter: AuditEncounter = { + uuid: 'enc-1', + display: 'COVID-19 Admission', + encounterDatetime: encounterCreated, + voided: false, + patient: { + uuid: 'patient-1', + display: 'Y2AHXV - Dave TestPatient', + identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }], + }, + location: { uuid: 'loc-1', display: 'CDI Klinik Ekstèn Jeneral' }, + form: { uuid: 'form-1', display: 'COVID19 Admission', version: '1.0' }, + encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' }, + visit: { uuid: 'visit-1', display: 'Clinic or Hospital Visit' }, + encounterProviders: [ + { + uuid: 'ep-1', + voided: false, + provider: { uuid: 'prov-1', display: 'Louidor Jean paul', identifier: 'MCPRPG' }, + encounterRole: { uuid: 'role-1', display: 'Consulting Clinician' }, + }, + ], + auditInfo: { creator, dateCreated: encounterCreated }, +}; + +const mockObs: Array = [ + { + uuid: 'obs-worker', + concept: { uuid: 'concept-worker', display: 'Health care worker' }, + formFieldPath: 'covid.1-worker', + value: { uuid: 'concept-yes', display: 'Yes' }, + auditInfo: { creator, dateCreated: encounterCreated }, + }, + { + uuid: 'obs-pulse-new', + concept: { uuid: 'concept-pulse', display: 'Pulse' }, + formFieldPath: 'covid.2-pulse', + value: 68, + previousVersion: { uuid: 'obs-pulse-old', display: 'Pulse: 67' }, + auditInfo: { creator, dateCreated: dayAfterEncounter }, + }, + { + uuid: 'obs-pulse-old', + concept: { uuid: 'concept-pulse', display: 'Pulse' }, + formFieldPath: 'covid.2-pulse', + value: 67, + voided: true, + auditInfo: { + creator, + dateCreated: encounterCreated, + voidedBy: creator, + dateVoided: dayAfterEncounter, + voidReason: 'Corrected the reading', + }, + }, + { + uuid: 'obs-systolic', + concept: { uuid: 'concept-systolic', display: 'Systolic' }, + formFieldPath: 'covid.3-systolic', + value: 123, + auditInfo: { creator, dateCreated: dayAfterEncounter }, + }, + { + uuid: 'obs-symptom-group', + concept: { uuid: 'concept-symptom-group', display: 'Sign/Symptom construct' }, + formFieldPath: 'covid.4-symptom', + auditInfo: { creator, dateCreated: encounterCreated }, + }, + { + uuid: 'obs-symptom', + concept: { uuid: 'concept-symptom', display: 'Sign/Symptom name' }, + obsGroup: { uuid: 'obs-symptom-group' }, + value: { uuid: 'concept-fever', display: 'Fever' }, + auditInfo: { creator, dateCreated: encounterCreated }, + }, +]; + +function mockRestApi({ encounter = mockEncounter, obs = mockObs } = {}) { + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/obs?')) { + return Promise.resolve({ data: { results: obs } }) as ReturnType; + } + return Promise.resolve({ data: encounter }) as ReturnType; + }); +} + +function renderEncounterAudit() { + const onBackToEncounters = jest.fn(); + render( + new Map() }}> + + , + ); + return onBackToEncounters; +} + +function obsRowsFor(conceptName: string): Array { + return screen.getAllByRole('row').filter((row) => row.textContent?.startsWith(conceptName)); +} + +describe('', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart' }); + mockRestApi(); + }); + + it('summarises how the encounter was recorded', async () => { + renderEncounterAudit(); + + expect(await screen.findByText('CDI Klinik Ekstèn Jeneral')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Y2AHXV - Dave TestPatient' })).toHaveAttribute( + 'href', + '/openmrs/spa/patient/patient-1/chart', + ); + expect(screen.getByText('Y2AHXV')).toBeInTheDocument(); + expect(screen.getByText('COVID19 Admission v1.0')).toBeInTheDocument(); + expect(screen.getByText('Clinic or Hospital Visit')).toBeInTheDocument(); + const createdByRow = screen.getAllByRole('row').find((row) => row.textContent?.startsWith('Created by')); + expect(createdByRow).toHaveTextContent('Cos John'); + }); + + it('lists the providers on the encounter', async () => { + renderEncounterAudit(); + + const providerRow = (await screen.findAllByRole('row')).find((row) => + row.textContent?.includes('Louidor Jean paul'), + ); + expect(providerRow).toHaveTextContent('Consulting Clinician'); + expect(providerRow).toHaveTextContent('MCPRPG'); + }); + + it('shows an edited obs above the value it replaced', async () => { + renderEncounterAudit(); + await screen.findByText('CDI Klinik Ekstèn Jeneral'); + + const pulseRows = obsRowsFor('Pulse'); + expect(pulseRows).toHaveLength(2); + expect(pulseRows[0]).toHaveTextContent('68'); + expect(pulseRows[0]).toHaveTextContent('Edited'); + expect(pulseRows[0]).toHaveTextContent('was 67'); + expect(pulseRows[1]).toHaveTextContent('67'); + expect(pulseRows[1]).toHaveTextContent('Previous value'); + expect(pulseRows[1]).toHaveTextContent('Corrected the reading'); + }); + + it('flags an obs recorded after the encounter was created, and leaves the rest unflagged', async () => { + renderEncounterAudit(); + await screen.findByText('CDI Klinik Ekstèn Jeneral'); + + expect(obsRowsFor('Systolic')[0]).toHaveTextContent('Added later'); + + const workerRow = obsRowsFor('Health care worker')[0]; + expect(workerRow).toHaveTextContent('Yes'); + expect(workerRow).not.toHaveTextContent('Added later'); + expect(workerRow).not.toHaveTextContent('Edited'); + }); + + it('nests obs group members under their group', async () => { + renderEncounterAudit(); + await screen.findByText('CDI Klinik Ekstèn Jeneral'); + + expect(obsRowsFor('Sign/Symptom construct')).toHaveLength(1); + expect(obsRowsFor('Sign/Symptom name')[0]).toHaveTextContent('Fever'); + }); + + it('hides deleted observations on request', async () => { + renderEncounterAudit(); + await screen.findByText('CDI Klinik Ekstèn Jeneral'); + expect(obsRowsFor('Pulse')).toHaveLength(2); + + await userEvent.click(screen.getByRole('checkbox', { name: /show deleted observations/i })); + + expect(obsRowsFor('Pulse')).toHaveLength(1); + expect(obsRowsFor('Pulse')[0]).toHaveTextContent('68'); + }); + + it('shows concept descriptions on request', async () => { + mockRestApi({ + obs: [ + { + ...mockObs[0], + concept: { + uuid: 'concept-worker', + display: 'Health care worker', + descriptions: [{ description: 'Whether the patient works in health care', locale: 'en' }], + }, + }, + ], + }); + renderEncounterAudit(); + await screen.findByText('CDI Klinik Ekstèn Jeneral'); + + expect(screen.queryByText(/whether the patient works in health care/i)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('checkbox', { name: /show concept descriptions/i })); + + expect(screen.getByText(/whether the patient works in health care/i)).toBeInTheDocument(); + }); + + it('warns that a deleted encounter is being audited', async () => { + mockRestApi({ + encounter: { + ...mockEncounter, + voided: true, + auditInfo: { + ...mockEncounter.auditInfo, + voidedBy: creator, + dateVoided: dayAfterEncounter, + voidReason: 'Entered on the wrong patient', + }, + }, + }); + renderEncounterAudit(); + + expect(await screen.findByText(/this encounter has been deleted/i)).toBeInTheDocument(); + expect(screen.getByText('Entered on the wrong patient')).toBeInTheDocument(); + }); + + it('walks back up to the patient the encounter belongs to', async () => { + const onBackToEncounters = renderEncounterAudit(); + await screen.findByText('CDI Klinik Ekstèn Jeneral'); + + await userEvent.click(screen.getByRole('button', { name: /back to encounters/i })); + + expect(onBackToEncounters).toHaveBeenCalledWith('patient-1'); + }); +}); diff --git a/packages/esm-audit-app/src/audit/encounter-audit.component.tsx b/packages/esm-audit-app/src/audit/encounter-audit.component.tsx new file mode 100644 index 0000000..a705606 --- /dev/null +++ b/packages/esm-audit-app/src/audit/encounter-audit.component.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + InlineNotification, + StructuredListBody, + StructuredListCell, + StructuredListRow, + StructuredListWrapper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + SkeletonText, + Tag, +} from '@carbon/react'; +import { ArrowLeftIcon, ConfigurableLink, ErrorState, useConfig } from '@openmrs/esm-framework'; +import { type Config } from '../config-schema'; +import { type AuditEncounter } from '../types'; +import { formatAuditDatetime, formatUserAndDate } from './audit-format'; +import { getPreferredIdentifier, useEncounterAudit } from './audit.resource'; +import ObsAuditTable from './obs-audit-table.component'; +import styles from './audit.scss'; + +interface EncounterAuditProps { + encounterUuid: string; + /** Called with the encounter's patient, so that a deep link can navigate back up the trail. */ + onBackToEncounters(patientUuid: string | undefined): void; +} + +interface SummaryRow { + label: string; + value: React.ReactNode; +} + +/** + * Step three of the audit trail: everything known about how one encounter came to be — who created + * it, who last changed it, whether it has been deleted, and the full history of its observations. + */ +export default function EncounterAudit({ encounterUuid, onBackToEncounters }: EncounterAuditProps) { + const { t } = useTranslation(); + const config = useConfig(); + const { encounter, obsTree, error, isLoading } = useEncounterAudit(encounterUuid); + + if (error) { + return ( +
+ onBackToEncounters(undefined)} /> + +
+ ); + } + + if (isLoading) { + return ( +
+ onBackToEncounters(undefined)} /> + +
+ ); + } + + if (!encounter) { + return ( +
+ onBackToEncounters(undefined)} /> +

{t('encounterNotFound', 'That encounter could not be found.')}

+
+ ); + } + + const patientUuid = encounter.patient?.uuid; + const summaryRows: Array = [ + { + label: t('patient', 'Patient'), + value: patientUuid ? ( + + {encounter.patient?.display} + + ) : ( + encounter.patient?.display + ), + }, + { label: t('identifier', 'Identifier'), value: getPreferredIdentifier(encounter.patient) }, + { label: t('encounterDate', 'Encounter date'), value: formatAuditDatetime(encounter.encounterDatetime) }, + { label: t('location', 'Location'), value: encounter.location?.display }, + { label: t('visit', 'Visit'), value: encounter.visit?.display }, + { label: t('encounterType', 'Encounter type'), value: encounter.encounterType?.display }, + { label: t('form', 'Form'), value: formatForm(encounter) }, + { + label: t('createdBy', 'Created by'), + value: formatUserAndDate(encounter.auditInfo?.creator, encounter.auditInfo?.dateCreated), + }, + ]; + + if (encounter.auditInfo?.changedBy) { + summaryRows.push({ + label: t('changedBy', 'Changed by'), + value: formatUserAndDate(encounter.auditInfo.changedBy, encounter.auditInfo.dateChanged), + }); + } + + if (encounter.voided) { + summaryRows.push({ + label: t('deletedBy', 'Deleted by'), + value: formatUserAndDate(encounter.auditInfo?.voidedBy, encounter.auditInfo?.dateVoided), + }); + if (encounter.auditInfo?.voidReason) { + summaryRows.push({ label: t('deletionReason', 'Reason for deletion'), value: encounter.auditInfo.voidReason }); + } + } + + const providers = (encounter.encounterProviders ?? []).filter((encounterProvider) => !encounterProvider.voided); + + return ( +
+ onBackToEncounters(patientUuid)} /> +
+ + {encounter.encounterType?.display ?? t('encounter', 'Encounter')} + {encounter.voided ? {t('deleted', 'Deleted')} : null} + + + {[encounter.patient?.display, formatAuditDatetime(encounter.encounterDatetime)].filter(Boolean).join(' · ')} + +
+ + {encounter.voided ? ( + + ) : null} + +

{t('encounterSummary', 'Encounter summary')}

+ + + {summaryRows.map((row) => ( + + + {row.label} + + {row.value} + + ))} + + + +

{t('providers', 'Providers')}

+ {providers.length === 0 ? ( +

{t('noProviders', 'No providers are recorded for this encounter.')}

+ ) : ( + + + + + {t('role', 'Role')} + {t('providerName', 'Provider name')} + {t('identifier', 'Identifier')} + + + + {providers.map((encounterProvider) => ( + + {encounterProvider.encounterRole?.display} + {encounterProvider.provider?.display} + {encounterProvider.provider?.identifier} + + ))} + +
+
+ )} + + +
+ ); +} + +function BackButton({ onClick }: { onClick(): void }) { + const { t } = useTranslation(); + return ( + + ); +} + +function formatForm(encounter: AuditEncounter): string | undefined { + if (!encounter.form?.display) { + return undefined; + } + return encounter.form.version ? `${encounter.form.display} v${encounter.form.version}` : encounter.form.display; +} diff --git a/packages/esm-audit-app/src/audit/encounter-filters.component.tsx b/packages/esm-audit-app/src/audit/encounter-filters.component.tsx new file mode 100644 index 0000000..d10893a --- /dev/null +++ b/packages/esm-audit-app/src/audit/encounter-filters.component.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ComboBox } from '@carbon/react'; +import { OpenmrsDateRangePicker } from '@openmrs/esm-framework'; +import { type AuditPatient, type OpenmrsResourceRef } from '../types'; +import { usePatientEncounterTypes } from './audit.resource'; +import { type EncounterFilters, fromDateKey, hasActiveFilters, toDateKey } from './encounter-filters'; +import styles from './audit.scss'; + +interface EncounterFiltersBarProps { + patient: AuditPatient | undefined; + includeDeleted: boolean; + filters: EncounterFilters; + onChange(filters: EncounterFilters): void; +} + +/** + * Narrows a patient's encounter list by encounter type and by the date the encounter happened. + * The types on offer are the ones this patient's encounters actually use. + */ +export default function EncounterFiltersBar({ patient, includeDeleted, filters, onChange }: EncounterFiltersBarProps) { + const { t } = useTranslation(); + const { encounterTypes, isLoading } = usePatientEncounterTypes(patient, includeDeleted); + + const today = useMemo(() => new Date(), []); + + /** + * Hiding deleted encounters again can take the chosen type out of the list. Keeping it on the + * list means the filter that is in force is still the one shown, and can still be changed. + */ + const items = useMemo(() => { + const selected = filters.encounterType; + if (selected && !encounterTypes.some((encounterType) => encounterType.uuid === selected.uuid)) { + return [...encounterTypes, selected]; + } + return encounterTypes; + }, [encounterTypes, filters.encounterType]); + + const dateRange = useMemo<[Date | null, Date | null]>( + () => [fromDateKey(filters.fromDate), fromDateKey(filters.toDate)], + [filters.fromDate, filters.toDate], + ); + + return ( +
+ encounterType?.display ?? ''} + onChange={({ selectedItem }: { selectedItem: OpenmrsResourceRef | null }) => + onChange({ ...filters, encounterType: selectedItem ?? undefined }) + } + placeholder={t('allEncounterTypes', 'All encounter types')} + selectedItem={filters.encounterType ?? null} + size="sm" + titleText={t('encounterType', 'Encounter type')} + /> + onChange({ ...filters, fromDate: toDateKey(from), toDate: toDateKey(to) })} + size="sm" + value={dateRange} + /> + {hasActiveFilters(filters) ? ( + + ) : null} +
+ ); +} diff --git a/packages/esm-audit-app/src/audit/encounter-filters.test.ts b/packages/esm-audit-app/src/audit/encounter-filters.test.ts new file mode 100644 index 0000000..c3a22cc --- /dev/null +++ b/packages/esm-audit-app/src/audit/encounter-filters.test.ts @@ -0,0 +1,119 @@ +import { type AuditEncounter } from '../types'; +import { + buildEncounterFilterQuery, + distinctEncounterTypes, + fromDateKey, + hasActiveFilters, + matchesEncounterFilters, + toDateKey, +} from './encounter-filters'; + +const consultationType = { uuid: 'type-consultation', display: 'Oncology Consultation' }; +const checkinType = { uuid: 'type-checkin', display: 'Inscription' }; + +const consultation: AuditEncounter = { + uuid: 'enc-1', + encounterDatetime: '2026-04-18T09:00:00.000+0000', + encounterType: consultationType, +}; + +describe('date keys', () => { + it('reads the calendar date a moment falls on', () => { + expect(toDateKey(new Date(2026, 3, 18, 9, 0))).toBe('2026-04-18'); + expect(toDateKey(new Date(2026, 0, 5))).toBe('2026-01-05'); + }); + + it('has nothing to say about a missing or unparseable date', () => { + expect(toDateKey(null)).toBeUndefined(); + expect(toDateKey(undefined)).toBeUndefined(); + expect(toDateKey(new Date('nonsense'))).toBeUndefined(); + }); + + it('round-trips back to local midnight, not to UTC midnight', () => { + const date = fromDateKey('2026-04-18'); + + expect(date?.getFullYear()).toBe(2026); + expect(date?.getMonth()).toBe(3); + expect(date?.getDate()).toBe(18); + expect(date?.getHours()).toBe(0); + expect(toDateKey(date)).toBe('2026-04-18'); + }); + + it('has nothing to round-trip when no date is set', () => { + expect(fromDateKey(undefined)).toBeNull(); + expect(fromDateKey('')).toBeNull(); + }); +}); + +describe('hasActiveFilters', () => { + it('knows when a filter is set', () => { + expect(hasActiveFilters({})).toBe(false); + expect(hasActiveFilters({ encounterType: consultationType })).toBe(true); + expect(hasActiveFilters({ fromDate: '2026-04-01' })).toBe(true); + expect(hasActiveFilters({ toDate: '2026-04-30' })).toBe(true); + }); +}); + +describe('buildEncounterFilterQuery', () => { + it('sends nothing when nothing is filtered', () => { + expect(buildEncounterFilterQuery({})).toBe(''); + }); + + it('asks the server for one encounter type', () => { + expect(buildEncounterFilterQuery({ encounterType: consultationType })).toBe('&encounterType=type-consultation'); + }); + + it('covers whole days at both ends of the range, in the server’s own time zone', () => { + expect(buildEncounterFilterQuery({ fromDate: '2026-04-01', toDate: '2026-04-30' })).toBe( + '&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59', + ); + }); +}); + +describe('matchesEncounterFilters', () => { + it('keeps everything when nothing is filtered', () => { + expect(matchesEncounterFilters(consultation, {})).toBe(true); + }); + + it('keeps only the chosen encounter type', () => { + expect(matchesEncounterFilters(consultation, { encounterType: consultationType })).toBe(true); + expect(matchesEncounterFilters(consultation, { encounterType: checkinType })).toBe(false); + }); + + it('treats both ends of the date range as inclusive', () => { + const day = toDateKey(new Date(consultation.encounterDatetime)); + + expect(matchesEncounterFilters(consultation, { fromDate: day, toDate: day })).toBe(true); + expect(matchesEncounterFilters(consultation, { fromDate: '2026-04-01', toDate: '2026-04-30' })).toBe(true); + expect(matchesEncounterFilters(consultation, { fromDate: '2026-04-19' })).toBe(false); + expect(matchesEncounterFilters(consultation, { toDate: '2026-04-17' })).toBe(false); + }); + + it('requires every filter to match', () => { + expect(matchesEncounterFilters(consultation, { encounterType: consultationType, fromDate: '2026-04-19' })).toBe( + false, + ); + }); + + it('excludes an encounter with no date once a date range is set', () => { + expect(matchesEncounterFilters({ uuid: 'enc-2' }, { fromDate: '2026-04-01' })).toBe(false); + expect(matchesEncounterFilters({ uuid: 'enc-2' }, {})).toBe(true); + }); +}); + +describe('distinctEncounterTypes', () => { + it('collects each type a patient has once, sorted by name', () => { + expect( + distinctEncounterTypes([ + { uuid: 'enc-1', encounterType: consultationType }, + { uuid: 'enc-2', encounterType: checkinType }, + { uuid: 'enc-3', encounterType: { ...consultationType } }, + ]), + ).toEqual([checkinType, consultationType]); + }); + + it('skips encounters with no type, and copes with none at all', () => { + expect(distinctEncounterTypes([{ uuid: 'enc-1' }])).toEqual([]); + expect(distinctEncounterTypes([])).toEqual([]); + }); +}); diff --git a/packages/esm-audit-app/src/audit/encounter-filters.ts b/packages/esm-audit-app/src/audit/encounter-filters.ts new file mode 100644 index 0000000..f04657a --- /dev/null +++ b/packages/esm-audit-app/src/audit/encounter-filters.ts @@ -0,0 +1,105 @@ +import { type AuditEncounter, type OpenmrsResourceRef } from '../types'; + +/** + * The filters an auditor can narrow a patient's encounter list by. Dates are held as calendar + * dates (`YYYY-MM-DD`) rather than instants, because that is what someone picking a date means, + * and both bounds are inclusive. + */ +export interface EncounterFilters { + /** + * The whole reference rather than just the uuid, so that the chosen type stays displayable even + * if it drops out of the available options — which happens when deleted encounters are hidden + * again and the type only occurred on those. + */ + encounterType?: OpenmrsResourceRef; + fromDate?: string; + toDate?: string; +} + +/** + * The encounter types a patient actually has, which is what their encounter list is worth + * filtering by — a dropdown of every type in the system would mostly offer types that return + * nothing for this patient. + */ +export function distinctEncounterTypes(encounters: Array): Array { + const byUuid = new Map(); + for (const encounter of encounters) { + if (encounter.encounterType?.uuid) { + byUuid.set(encounter.encounterType.uuid, encounter.encounterType); + } + } + return Array.from(byUuid.values()).sort((a, b) => (a.display ?? '').localeCompare(b.display ?? '')); +} + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +/** The calendar date a `Date` falls on, in the browser's time zone. */ +export function toDateKey(date: Date | null | undefined): string | undefined { + if (!date || Number.isNaN(date.getTime())) { + return undefined; + } + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** Local midnight on a calendar date, for handing a stored filter back to the date picker. */ +export function fromDateKey(key: string | undefined): Date | null { + if (!key) { + return null; + } + const [year, month, day] = key.split('-').map(Number); + if (!year || !month || !day) { + return null; + } + return new Date(year, month - 1, day); +} + +export function hasActiveFilters(filters: EncounterFilters): boolean { + return Boolean(filters.encounterType || filters.fromDate || filters.toDate); +} + +/** + * The query parameters the encounter-by-patient search understands. The dates are sent without a + * time zone so the server reads them as its own wall clock — the facility's day, which is what the + * auditor picking a date means — and `todate` covers the whole of its day, since the API compares + * it against `encounterDatetime` with `<=`. + */ +export function buildEncounterFilterQuery(filters: EncounterFilters): string { + let query = ''; + if (filters.encounterType) { + query += `&encounterType=${filters.encounterType.uuid}`; + } + if (filters.fromDate) { + query += `&fromdate=${filters.fromDate}T00:00:00`; + } + if (filters.toDate) { + query += `&todate=${filters.toDate}T23:59:59`; + } + return query; +} + +/** + * The same filters applied on the client, for the free-text search used to list deleted + * encounters, which the REST API cannot filter server-side. + */ +export function matchesEncounterFilters(encounter: AuditEncounter, filters: EncounterFilters): boolean { + if (filters.encounterType && encounter.encounterType?.uuid !== filters.encounterType.uuid) { + return false; + } + + if (filters.fromDate || filters.toDate) { + const day = encounter.encounterDatetime ? toDateKey(new Date(encounter.encounterDatetime)) : undefined; + if (!day) { + return false; + } + if (filters.fromDate && day < filters.fromDate) { + return false; + } + if (filters.toDate && day > filters.toDate) { + return false; + } + } + + return true; +} diff --git a/packages/esm-audit-app/src/audit/obs-audit-table.component.tsx b/packages/esm-audit-app/src/audit/obs-audit-table.component.tsx new file mode 100644 index 0000000..850e02e --- /dev/null +++ b/packages/esm-audit-app/src/audit/obs-audit-table.component.tsx @@ -0,0 +1,181 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Checkbox, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Tag, +} from '@carbon/react'; +import { type AuditObs, type ObsChangeStatus, type ObsTreeNode } from '../types'; +import { formatUserAndDate } from './audit-format'; +import { flattenObsTree, formatObsValue, getConceptDescription, isVoidedStatus } from './obs-audit'; +import styles from './audit.scss'; + +interface ObsAuditTableProps { + obsTree: Array; +} + +/** One indent step per level of obs group nesting. */ +const indentPerLevel = 1.5; + +function collectObs(nodes: Array, into: Map): Map { + for (const node of nodes) { + into.set(node.obs.uuid, node.obs); + collectObs(node.children, into); + } + return into; +} + +/** + * The audit trail for one encounter's observations: what was entered, what was changed, what was + * deleted, and by whom. Deleted obs are struck through, as they were on the legacy admin page, and + * every obs carries the change status derived by `getObsChangeStatus`. + */ +export default function ObsAuditTable({ obsTree }: ObsAuditTableProps) { + const { t, i18n } = useTranslation(); + const [showDeleted, setShowDeleted] = useState(true); + const [showDescriptions, setShowDescriptions] = useState(false); + + /** The tag and the legend entry for each status that is worth calling out. */ + const statusMeta = useMemo( + () => ({ + edited: { + label: t('edited', 'Edited'), + type: 'blue' as const, + legend: t('editedLegend', 'the value was changed after it was first recorded'), + }, + addedAfterEncounter: { + label: t('addedLater', 'Added later'), + type: 'teal' as const, + legend: t('addedLaterLegend', 'recorded after the encounter itself was created'), + }, + deleted: { + label: t('deleted', 'Deleted'), + type: 'red' as const, + legend: t('deletedLegend', 'removed from the encounter'), + }, + supersededValue: { + label: t('previousValue', 'Previous value'), + type: 'gray' as const, + legend: t('previousValueLegend', 'the value that an edit replaced'), + }, + }), + [t], + ); + + const obsByUuid = useMemo(() => collectObs(obsTree, new Map()), [obsTree]); + const rows = useMemo(() => flattenObsTree(obsTree, showDeleted), [obsTree, showDeleted]); + const hasDeletedObs = useMemo(() => Array.from(obsByUuid.values()).some((obs) => obs.voided), [obsByUuid]); + + return ( + <> +

{t('observations', 'Observations')}

+
+ setShowDeleted(checked)} + /> + setShowDescriptions(checked)} + /> +
+
+ {Object.entries(statusMeta).map(([status, meta]) => ( + + {meta.label} + {meta.legend} + + ))} +
+ + {rows.length === 0 ? ( +

{t('noObservations', 'This encounter has no observations.')}

+ ) : ( + + + + + {t('questionConcept', 'Question concept')} + {t('value', 'Value')} + {t('change', 'Change')} + {t('recordedBy', 'Recorded by')} + {t('deletedBy', 'Deleted by')} + + + + {rows.map(({ obs, status, depth, children }) => { + const meta = status === 'unchanged' ? undefined : statusMeta[status]; + const previous = obs.previousVersion ? obsByUuid.get(obs.previousVersion.uuid) : undefined; + const description = showDescriptions ? getConceptDescription(obs, i18n?.language) : undefined; + const isGroup = children.length > 0; + const indent = { paddingInlineStart: `${depth * indentPerLevel}rem` }; + + return ( + + + + + {isGroup ? {obs.concept?.display} : obs.concept?.display} + + + + {isGroup ? null : formatObsValue(obs.value)} + {previous ? ( +
+ {t('wasPreviously', 'was {{value}}', { value: formatObsValue(previous.value) })} +
+ ) : null} + {obs.comment ?
{obs.comment}
: null} +
+ {meta ? {meta.label} : null} + +
{formatUserAndDate(obs.auditInfo?.creator, obs.auditInfo?.dateCreated)}
+ {obs.auditInfo?.changedBy ? ( +
+ {t('changedByUser', 'Changed by {{user}}', { + user: formatUserAndDate(obs.auditInfo.changedBy, obs.auditInfo.dateChanged), + })} +
+ ) : null} +
+ + {obs.voided ? ( + <> +
{formatUserAndDate(obs.auditInfo?.voidedBy, obs.auditInfo?.dateVoided)}
+ {obs.auditInfo?.voidReason ? ( +
{obs.auditInfo.voidReason}
+ ) : null} + + ) : null} +
+
+ {description ? ( + + +
+ {description} +
+
+
+ ) : null} +
+ ); + })} +
+
+
+ )} + + ); +} diff --git a/packages/esm-audit-app/src/audit/obs-audit.test.ts b/packages/esm-audit-app/src/audit/obs-audit.test.ts new file mode 100644 index 0000000..6315e65 --- /dev/null +++ b/packages/esm-audit-app/src/audit/obs-audit.test.ts @@ -0,0 +1,170 @@ +import { type AuditEncounter, type AuditObs } from '../types'; +import { + buildObsTree, + flattenObsTree, + formatObsValue, + getConceptDescription, + getObsChangeStatus, + isVoidedStatus, +} from './obs-audit'; + +const encounterCreated = '2026-09-01T11:09:00.000+0000'; +const laterThanEncounter = '2026-09-02T08:30:00.000+0000'; + +const encounter: AuditEncounter = { + uuid: 'enc-1', + auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: encounterCreated }, +}; + +function obs(overrides: Partial & { uuid: string }): AuditObs { + return { + concept: { uuid: `concept-${overrides.uuid}`, display: 'Pulse' }, + auditInfo: { creator: { uuid: 'user-1', display: 'Cos John' }, dateCreated: encounterCreated }, + ...overrides, + }; +} + +describe('getObsChangeStatus', () => { + it('reports an obs created with its encounter as unchanged', () => { + expect(getObsChangeStatus(obs({ uuid: 'a' }), encounter, new Set())).toBe('unchanged'); + }); + + it('reports an obs that replaced an earlier one as edited', () => { + const edited = obs({ uuid: 'a', previousVersion: { uuid: 'b' }, auditInfo: { dateCreated: laterThanEncounter } }); + expect(getObsChangeStatus(edited, encounter, new Set())).toBe('edited'); + }); + + it('reports an obs created after its encounter as added later', () => { + const added = obs({ uuid: 'a', auditInfo: { dateCreated: laterThanEncounter } }); + expect(getObsChangeStatus(added, encounter, new Set())).toBe('addedAfterEncounter'); + }); + + it('reports a voided obs as deleted', () => { + expect(getObsChangeStatus(obs({ uuid: 'a', voided: true }), encounter, new Set())).toBe('deleted'); + }); + + it('reports a voided obs that a later obs replaced as a superseded value', () => { + expect(getObsChangeStatus(obs({ uuid: 'a', voided: true }), encounter, new Set(['a']))).toBe('supersededValue'); + }); + + it('does not guess when either creation date is missing', () => { + expect(getObsChangeStatus(obs({ uuid: 'a', auditInfo: {} }), encounter, new Set())).toBe('unchanged'); + expect(getObsChangeStatus(obs({ uuid: 'a' }), undefined, new Set())).toBe('unchanged'); + }); + + it('knows which statuses mean the obs row is voided', () => { + expect(isVoidedStatus('deleted')).toBe(true); + expect(isVoidedStatus('supersededValue')).toBe(true); + expect(isVoidedStatus('edited')).toBe(false); + }); +}); + +describe('buildObsTree', () => { + it('nests obs group members under their group and tags each obs with its status', () => { + const tree = buildObsTree( + [ + obs({ uuid: 'member', obsGroup: { uuid: 'group' }, concept: { uuid: 'c-2', display: 'Sign/Symptom name' } }), + obs({ uuid: 'group', concept: { uuid: 'c-1', display: 'Sign/Symptom construct' } }), + ], + encounter, + ); + + expect(tree).toHaveLength(1); + expect(tree[0].obs.uuid).toBe('group'); + expect(tree[0].depth).toBe(0); + expect(tree[0].status).toBe('unchanged'); + expect(tree[0].children.map((child) => child.obs.uuid)).toEqual(['member']); + expect(tree[0].children[0].depth).toBe(1); + }); + + it('keeps an obs whose group is missing from the list at the top level', () => { + const tree = buildObsTree([obs({ uuid: 'orphan', obsGroup: { uuid: 'not-loaded' } })], encounter); + + expect(tree.map((node) => node.obs.uuid)).toEqual(['orphan']); + }); + + it('puts the versions of one value together, newest first', () => { + const tree = buildObsTree( + [ + obs({ + uuid: 'pulse-old', + value: 67, + voided: true, + formFieldPath: 'covid.pulse', + auditInfo: { dateCreated: encounterCreated }, + }), + obs({ uuid: 'temperature', value: 38, formFieldPath: 'covid.temperature' }), + obs({ + uuid: 'pulse-new', + value: 68, + previousVersion: { uuid: 'pulse-old' }, + formFieldPath: 'covid.pulse', + auditInfo: { dateCreated: laterThanEncounter }, + }), + ], + encounter, + ); + + expect(tree.map((node) => [node.obs.uuid, node.status])).toEqual([ + ['pulse-new', 'edited'], + ['pulse-old', 'supersededValue'], + ['temperature', 'unchanged'], + ]); + }); +}); + +describe('flattenObsTree', () => { + const tree = buildObsTree( + [ + obs({ uuid: 'group', voided: true, concept: { uuid: 'c-1', display: 'Group' } }), + obs({ uuid: 'member', voided: true, obsGroup: { uuid: 'group' } }), + obs({ uuid: 'kept' }), + ], + encounter, + ); + + it('returns every obs when deleted obs are shown', () => { + expect(flattenObsTree(tree, true).map((node) => node.obs.uuid)).toEqual(['group', 'member', 'kept']); + }); + + it('drops deleted obs, and anything nested inside them, when they are hidden', () => { + expect(flattenObsTree(tree, false).map((node) => node.obs.uuid)).toEqual(['kept']); + }); +}); + +describe('formatObsValue', () => { + it('displays coded values by their name and scalar values as they are', () => { + expect(formatObsValue({ uuid: 'concept-yes', display: 'Yes' })).toBe('Yes'); + expect(formatObsValue(68)).toBe('68'); + expect(formatObsValue('advil 100')).toBe('advil 100'); + }); + + it('displays nothing for an obs with no value, such as an obs group', () => { + expect(formatObsValue(null)).toBe(''); + expect(formatObsValue(undefined)).toBe(''); + }); +}); + +describe('getConceptDescription', () => { + const described = obs({ + uuid: 'a', + concept: { + uuid: 'c-1', + display: 'Pulse', + descriptions: [ + { description: 'Battements par minute', locale: 'fr' }, + { description: 'Beats per minute', locale: 'en' }, + ], + }, + }); + + it('prefers the description matching the given locale', () => { + expect(getConceptDescription(described, 'en_GB')).toBe('Beats per minute'); + expect(getConceptDescription(described, 'fr')).toBe('Battements par minute'); + }); + + it('falls back to the first description when nothing matches', () => { + expect(getConceptDescription(described, 'es')).toBe('Battements par minute'); + expect(getConceptDescription(obs({ uuid: 'a' }), 'en')).toBeUndefined(); + }); +}); diff --git a/packages/esm-audit-app/src/audit/obs-audit.ts b/packages/esm-audit-app/src/audit/obs-audit.ts new file mode 100644 index 0000000..2447f94 --- /dev/null +++ b/packages/esm-audit-app/src/audit/obs-audit.ts @@ -0,0 +1,156 @@ +import { type AuditEncounter, type AuditObs, type ObsChangeStatus, type ObsTreeNode, type ObsValue } from '../types'; + +/** Statuses that mean the obs row is voided in the database. */ +const voidedStatuses: Array = ['deleted', 'supersededValue']; + +export function isVoidedStatus(status: ObsChangeStatus): boolean { + return voidedStatuses.includes(status); +} + +function toTime(date: string | undefined): number | null { + if (!date) { + return null; + } + const time = new Date(date).getTime(); + return Number.isNaN(time) ? null : time; +} + +/** + * Works out how an obs came to look the way it does. This reproduces what the legacy + * `admin/encounters/encounter.form` page showed, using the data the REST API exposes: + * + * - an obs that points at a `previousVersion` replaced an earlier obs, so its value was edited; + * - an obs created later than its encounter was added after the encounter was first saved. + * OpenMRS stamps the same `dateCreated` on an encounter and on every obs saved with it, so an + * obs whose `dateCreated` differs from the encounter's was saved in a later transaction; + * - a voided obs was deleted, unless a surviving obs points back at it as its `previousVersion`, + * in which case it is the value that was edited away. + * + * @param obs the obs to classify + * @param encounter the encounter the obs belongs to, for its creation date + * @param supersededUuids uuids that some other obs in the encounter names as its `previousVersion` + */ +export function getObsChangeStatus( + obs: AuditObs, + encounter: AuditEncounter | undefined, + supersededUuids: Set, +): ObsChangeStatus { + if (obs.voided) { + return supersededUuids.has(obs.uuid) ? 'supersededValue' : 'deleted'; + } + + if (obs.previousVersion) { + return 'edited'; + } + + const obsCreated = toTime(obs.auditInfo?.dateCreated); + const encounterCreated = toTime(encounter?.auditInfo?.dateCreated); + if (obsCreated !== null && encounterCreated !== null && obsCreated !== encounterCreated) { + return 'addedAfterEncounter'; + } + + return 'unchanged'; +} + +/** + * Sorts obs so that every version of a value sits together, newest first. Obs recorded through a + * form share a `formFieldPath`, which also approximates the order of the fields on that form; obs + * recorded some other way fall back to the concept name. + */ +function compareObs(a: AuditObs, b: AuditObs): number { + const groupA = a.formFieldPath ?? a.concept?.display ?? ''; + const groupB = b.formFieldPath ?? b.concept?.display ?? ''; + if (groupA !== groupB) { + return groupA.localeCompare(groupB); + } + + const createdA = toTime(a.auditInfo?.dateCreated) ?? 0; + const createdB = toTime(b.auditInfo?.dateCreated) ?? 0; + if (createdA !== createdB) { + return createdB - createdA; + } + + return a.uuid.localeCompare(b.uuid); +} + +/** + * Turns the flat list of obs the REST API returns for an encounter — which includes obs group + * members and voided obs — into the group hierarchy, tagging each obs with its change status. + * + * An obs whose group is not in the list (which the API should not return, but which would + * otherwise silently drop the obs) is treated as a root so that it stays visible. + */ +export function buildObsTree(obsList: Array, encounter?: AuditEncounter): Array { + const supersededUuids = new Set( + obsList.map((obs) => obs.previousVersion?.uuid).filter((uuid): uuid is string => Boolean(uuid)), + ); + + const nodes = new Map( + obsList.map((obs) => [ + obs.uuid, + { obs, status: getObsChangeStatus(obs, encounter, supersededUuids), depth: 0, children: [] }, + ]), + ); + + const roots: Array = []; + for (const node of nodes.values()) { + const parent = node.obs.obsGroup?.uuid ? nodes.get(node.obs.obsGroup.uuid) : undefined; + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + + const sortAndSetDepth = (siblings: Array, depth: number) => { + siblings.sort((a, b) => compareObs(a.obs, b.obs)); + for (const sibling of siblings) { + sibling.depth = depth; + sortAndSetDepth(sibling.children, depth + 1); + } + }; + sortAndSetDepth(roots, 0); + + return roots; +} + +/** + * Flattens the obs tree into the rows to render, dropping voided obs (along with anything nested + * inside them) unless deleted obs are being shown. + */ +export function flattenObsTree(nodes: Array, showDeleted: boolean): Array { + return nodes.flatMap((node) => { + if (!showDeleted && isVoidedStatus(node.status)) { + return []; + } + return [node, ...flattenObsTree(node.children, showDeleted)]; + }); +} + +/** + * Renders an obs value for display. Coded obs (and the drug and location values OpenMRS stores + * alongside them) arrive as references rather than as scalars. + */ +export function formatObsValue(value: ObsValue | undefined): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'object') { + return value.display ?? ''; + } + if (typeof value === 'boolean') { + return String(value); + } + return String(value); +} + +/** Picks the concept description matching the given locale, falling back to whatever is there. */ +export function getConceptDescription(obs: AuditObs, locale: string | undefined): string | undefined { + const descriptions = obs.concept?.descriptions; + if (!descriptions?.length) { + return undefined; + } + const language = locale?.split(/[-_]/)[0]; + const match = descriptions.find((description) => description.locale?.split(/[-_]/)[0] === language); + return (match ?? descriptions[0]).description; +} diff --git a/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx b/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx new file mode 100644 index 0000000..42cb0a9 --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SWRConfig } from 'swr'; +import { openmrsFetch, useConfig } from '@openmrs/esm-framework'; +import { type AuditEncounter, type AuditObs, type AuditPatient } from '../types'; +import PatientActivity from './patient-activity.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockUseConfig = jest.mocked(useConfig); + +const clerk = { uuid: 'user-clerk', display: 'Cos John' }; +const nurse = { uuid: 'user-nurse', display: 'Louidor Jean paul' }; +const created = '2026-09-01T11:09:00.000+0000'; +const dayAfter = '2026-09-02T08:30:00.000+0000'; + +const patient: AuditPatient = { + uuid: 'patient-1', + display: 'Y2AHXV - Dave TestPatient', + identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }], +}; + +const encounters: Array = [ + { + uuid: 'enc-1', + encounterDatetime: created, + encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' }, + auditInfo: { creator: clerk, dateCreated: created }, + }, +]; + +const obs: Array = [ + { + uuid: 'obs-pulse-new', + concept: { uuid: 'concept-pulse', display: 'Pulse' }, + previousVersion: { uuid: 'obs-pulse-old' }, + auditInfo: { creator: nurse, dateCreated: dayAfter }, + }, + { + uuid: 'obs-pulse-old', + concept: { uuid: 'concept-pulse', display: 'Pulse' }, + voided: true, + auditInfo: { creator: clerk, dateCreated: created, voidedBy: nurse, dateVoided: dayAfter }, + }, +]; + +function mockRestApi({ encounterList = encounters }: { encounterList?: Array } = {}) { + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/obs?encounter=')) { + return Promise.resolve({ data: { results: obs } }) as ReturnType; + } + return Promise.resolve({ data: { results: encounterList } }) as ReturnType; + }); +} + +function renderPatientActivity() { + const onSelectEncounter = jest.fn(); + render( + new Map() }}> + + , + ); + return onSelectEncounter; +} + +function rowFor(name: string) { + return screen.getAllByRole('row').find((row) => row.textContent?.includes(name)); +} + +/** The activity log's rows, which are the ones naming an action. */ +function logRows() { + return screen + .getAllByRole('row') + .filter((row) => + /Created encounter|Edited observation|Recorded observation|Deleted observation/.test(row.textContent ?? ''), + ); +} + +describe('', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ activityScanLimit: 50 }); + mockRestApi(); + }); + + it('says who touched the record and what each of them did', async () => { + renderPatientActivity(); + + expect(await screen.findByText('Who touched this record')).toBeInTheDocument(); + + const clerkRow = rowFor('Cos John'); + expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('1'); + expect(within(clerkRow).getAllByRole('cell')[2]).toHaveTextContent('1'); + + const nurseRow = rowFor('Louidor Jean paul'); + expect(within(nurseRow).getAllByRole('cell')[3]).toHaveTextContent('1'); + }); + + it('lists the individual events, most recent first', async () => { + renderPatientActivity(); + await screen.findByText('Activity log'); + + const rows = logRows(); + expect(rows[0]).toHaveTextContent('Edited observation'); + expect(rows[0]).toHaveTextContent('Louidor Jean paul'); + expect(rows[0]).toHaveTextContent('Pulse'); + expect(rows[0]).toHaveTextContent('COVID-19 Admission'); + }); + + it('narrows the activity log to one user, and back again', async () => { + renderPatientActivity(); + await screen.findByText('Activity log'); + expect(logRows()).toHaveLength(3); + + await userEvent.click(screen.getByRole('button', { name: 'Louidor Jean paul' })); + + const nurseOnly = logRows(); + expect(nurseOnly).toHaveLength(1); + expect(nurseOnly[0]).toHaveTextContent('Edited observation'); + expect(screen.getByRole('button', { name: 'Louidor Jean paul' })).toHaveAttribute('aria-pressed', 'true'); + + await userEvent.click(screen.getByRole('button', { name: /show everyone/i })); + + expect(logRows()).toHaveLength(3); + expect(screen.getByRole('button', { name: 'Louidor Jean paul' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('clicking the selected user again shows everyone', async () => { + renderPatientActivity(); + await screen.findByText('Activity log'); + + await userEvent.click(screen.getByRole('button', { name: 'Cos John' })); + expect(logRows()).toHaveLength(2); + + await userEvent.click(screen.getByRole('button', { name: 'Cos John' })); + expect(logRows()).toHaveLength(3); + }); + + it('drills into the encounter behind an event', async () => { + const onSelectEncounter = renderPatientActivity(); + await screen.findByText('Activity log'); + + await userEvent.click(screen.getAllByRole('button', { name: /COVID-19 Admission/ })[0]); + + expect(onSelectEncounter).toHaveBeenCalledWith('enc-1'); + }); + + it('says so when it could only read part of the record', async () => { + mockUseConfig.mockReturnValue({ activityScanLimit: 1 }); + mockRestApi({ + encounterList: [ + encounters[0], + { ...encounters[0], uuid: 'enc-2', auditInfo: { creator: clerk, dateCreated: created } }, + ], + }); + renderPatientActivity(); + + expect(await screen.findByText(/showing activity from the 1 most recent of 2 encounters/i)).toBeInTheDocument(); + }); + + it('says when there is nothing to show', async () => { + mockRestApi({ encounterList: [] }); + renderPatientActivity(); + + expect(await screen.findByText(/no recorded activity/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-audit-app/src/audit/patient-activity.component.tsx b/packages/esm-audit-app/src/audit/patient-activity.component.tsx new file mode 100644 index 0000000..604c35c --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-activity.component.tsx @@ -0,0 +1,213 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DismissibleTag, + InlineNotification, + Pagination, + SkeletonText, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Tag, +} from '@carbon/react'; +import { ErrorState, useConfig } from '@openmrs/esm-framework'; +import { type Config } from '../config-schema'; +import { type AuditPatient } from '../types'; +import { formatAuditDatetime } from './audit-format'; +import { usePatientActivity } from './audit.resource'; +import { type EncounterFilters } from './encounter-filters'; +import { type AuditAction, eventUserUuid } from './patient-activity'; +import styles from './audit.scss'; + +interface PatientActivityProps { + patient: AuditPatient | undefined; + includeDeleted: boolean; + filters: EncounterFilters; + onSelectEncounter(encounterUuid: string): void; +} + +const eventsPerPage = 20; + +/** + * Who touched this patient's record: one row per user with what they did, and the individual + * events behind those counts. + */ +export default function PatientActivity({ patient, includeDeleted, filters, onSelectEncounter }: PatientActivityProps) { + const { t } = useTranslation(); + const config = useConfig(); + const [selectedUserUuid, setSelectedUserUuid] = useState(null); + const [page, setPage] = useState(1); + const { events, userActivity, scannedCount, matchedCount, error, isLoading } = usePatientActivity( + patient, + includeDeleted, + filters, + config.activityScanLimit ?? 50, + ); + + const selectedUser = useMemo( + () => userActivity.find((activity) => activity.userUuid === selectedUserUuid), + [selectedUserUuid, userActivity], + ); + + const shownEvents = useMemo( + () => (selectedUserUuid ? events.filter((auditEvent) => eventUserUuid(auditEvent) === selectedUserUuid) : events), + [events, selectedUserUuid], + ); + + useEffect(() => { + setPage(1); + }, [selectedUserUuid]); + + /** Clamped rather than reset, so a revalidation that shortens the log cannot blank the page. */ + const totalPages = Math.max(1, Math.ceil(shownEvents.length / eventsPerPage)); + const currentPage = Math.min(page, totalPages); + const pagedEvents = shownEvents.slice((currentPage - 1) * eventsPerPage, currentPage * eventsPerPage); + + const actionLabels = useMemo>( + () => ({ + encounterCreated: { label: t('encounterCreatedAction', 'Created encounter'), type: 'teal' }, + encounterChanged: { label: t('encounterChangedAction', 'Changed encounter'), type: 'blue' }, + encounterDeleted: { label: t('encounterDeletedAction', 'Deleted encounter'), type: 'red' }, + obsRecorded: { label: t('obsRecordedAction', 'Recorded observation'), type: 'gray' }, + obsEdited: { label: t('obsEditedAction', 'Edited observation'), type: 'blue' }, + obsDeleted: { label: t('obsDeletedAction', 'Deleted observation'), type: 'red' }, + }), + [t], + ); + + if (error) { + return ; + } + + if (isLoading) { + return ; + } + + if (events.length === 0) { + return

{t('noActivity', 'There is no recorded activity to show.')}

; + } + + return ( + <> + {matchedCount > scannedCount ? ( + + ) : null} + +

{t('whoTouchedThisRecord', 'Who touched this record')}

+ + + + + {t('user', 'User')} + {t('encountersCreated', 'Encounters created')} + {t('obsRecordedCount', 'Observations recorded')} + {t('obsEditedCount', 'Observations edited')} + {t('obsDeletedCount', 'Observations deleted')} + {t('firstActivity', 'First activity')} + {t('lastActivity', 'Last activity')} + + + + {userActivity.map((activity) => { + const isSelected = activity.userUuid === selectedUserUuid; + return ( + + + + + {activity.counts.encounterCreated} + {activity.counts.obsRecorded} + {activity.counts.obsEdited} + {activity.counts.obsDeleted} + {formatAuditDatetime(activity.firstActivity)} + {formatAuditDatetime(activity.lastActivity)} + + ); + })} + +
+
+ +
+

{t('activityLog', 'Activity log')}

+ {selectedUserUuid ? ( + setSelectedUserUuid(null)} + text={selectedUser?.userDisplay || t('unknownUser', 'Unknown user')} + title={t('showEveryone', 'Show everyone')} + type="blue" + /> + ) : null} +
+ + + + + {t('when', 'When')} + {t('user', 'User')} + {t('action', 'Action')} + {t('detail', 'Detail')} + {t('encounter', 'Encounter')} + + + + {pagedEvents.map((auditEvent) => ( + + {formatAuditDatetime(auditEvent.timestamp)} + {auditEvent.user?.display || t('unknownUser', 'Unknown user')} + + {actionLabels[auditEvent.action].label} + + {auditEvent.concept} + + + + + ))} + +
+
+ setPage(nextPage)} + page={currentPage} + pageSize={eventsPerPage} + pageSizes={[eventsPerPage]} + size="sm" + totalItems={shownEvents.length} + /> + + ); +} diff --git a/packages/esm-audit-app/src/audit/patient-activity.test.ts b/packages/esm-audit-app/src/audit/patient-activity.test.ts new file mode 100644 index 0000000..0f0eee5 --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-activity.test.ts @@ -0,0 +1,155 @@ +import { type AuditEncounter, type AuditObs } from '../types'; +import { buildAuditEvents, eventUserUuid, summariseByUser, unknownUserUuid } from './patient-activity'; + +const clerk = { uuid: 'user-clerk', display: 'Cos John' }; +const nurse = { uuid: 'user-nurse', display: 'Louidor Jean paul' }; + +const encounterCreated = '2026-09-01T11:09:00.000+0000'; +const dayAfter = '2026-09-02T08:30:00.000+0000'; + +const encounter: AuditEncounter = { + uuid: 'enc-1', + encounterDatetime: encounterCreated, + encounterType: { uuid: 'type-1', display: 'COVID-19 Admission' }, + auditInfo: { creator: clerk, dateCreated: encounterCreated }, +}; + +function obs(overrides: Partial & { uuid: string }): AuditObs { + return { + concept: { uuid: 'concept-pulse', display: 'Pulse' }, + auditInfo: { creator: clerk, dateCreated: encounterCreated }, + ...overrides, + }; +} + +describe('buildAuditEvents', () => { + it('records who created an encounter', () => { + expect(buildAuditEvents([encounter], {})).toEqual([ + expect.objectContaining({ action: 'encounterCreated', user: clerk, timestamp: encounterCreated }), + ]); + }); + + it('records an encounter that was changed and one that was deleted', () => { + const changed: AuditEncounter = { + ...encounter, + voided: true, + auditInfo: { + creator: clerk, + dateCreated: encounterCreated, + changedBy: nurse, + dateChanged: dayAfter, + voidedBy: nurse, + dateVoided: dayAfter, + }, + }; + + expect(buildAuditEvents([changed], {}).map((auditEvent) => auditEvent.action)).toEqual([ + 'encounterChanged', + 'encounterDeleted', + 'encounterCreated', + ]); + }); + + it('counts an edit once, as an edit, rather than as a deletion and a recording', () => { + const events = buildAuditEvents([encounter], { + 'enc-1': [ + obs({ + uuid: 'obs-new', + previousVersion: { uuid: 'obs-old' }, + auditInfo: { creator: nurse, dateCreated: dayAfter }, + }), + obs({ + uuid: 'obs-old', + voided: true, + auditInfo: { creator: clerk, dateCreated: encounterCreated, voidedBy: nurse, dateVoided: dayAfter }, + }), + ], + }); + + expect(events.map((auditEvent) => [auditEvent.action, auditEvent.user?.uuid])).toEqual([ + ['obsEdited', 'user-nurse'], + ['encounterCreated', 'user-clerk'], + ['obsRecorded', 'user-clerk'], + ]); + }); + + it('records an outright deletion alongside the original recording', () => { + const events = buildAuditEvents([encounter], { + 'enc-1': [ + obs({ + uuid: 'obs-gone', + voided: true, + auditInfo: { creator: clerk, dateCreated: encounterCreated, voidedBy: nurse, dateVoided: dayAfter }, + }), + ], + }); + + expect(events.map((auditEvent) => [auditEvent.action, auditEvent.user?.uuid])).toEqual([ + ['obsDeleted', 'user-nurse'], + ['encounterCreated', 'user-clerk'], + ['obsRecorded', 'user-clerk'], + ]); + expect(events[0].concept).toBe('Pulse'); + }); + + it('leaves out events the api gave no timestamp for', () => { + expect(buildAuditEvents([{ uuid: 'enc-2' }], {})).toEqual([]); + }); + + it('puts the most recent event first', () => { + const events = buildAuditEvents([encounter], { + 'enc-1': [obs({ uuid: 'obs-later', auditInfo: { creator: nurse, dateCreated: dayAfter } })], + }); + + expect(events[0].timestamp).toBe(dayAfter); + }); +}); + +describe('eventUserUuid', () => { + it('keys an event by its user, or by the unknown user when the api named none', () => { + const [withUser] = buildAuditEvents([encounter], {}); + const [withoutUser] = buildAuditEvents([{ uuid: 'enc-2', auditInfo: { dateCreated: dayAfter } }], {}); + + expect(eventUserUuid(withUser)).toBe(clerk.uuid); + expect(eventUserUuid(withoutUser)).toBe(unknownUserUuid); + }); +}); + +describe('summariseByUser', () => { + const events = buildAuditEvents([encounter], { + 'enc-1': [ + obs({ uuid: 'obs-1' }), + obs({ uuid: 'obs-2' }), + obs({ + uuid: 'obs-new', + previousVersion: { uuid: 'obs-2' }, + auditInfo: { creator: nurse, dateCreated: dayAfter }, + }), + ], + }); + + it('counts what each user did, busiest first', () => { + const summary = summariseByUser(events); + + expect(summary.map((activity) => activity.userDisplay)).toEqual(['Cos John', 'Louidor Jean paul']); + expect(summary[0].counts).toMatchObject({ encounterCreated: 1, obsRecorded: 2, obsEdited: 0 }); + expect(summary[0].totalEvents).toBe(3); + expect(summary[1].counts).toMatchObject({ obsEdited: 1 }); + }); + + it('brackets each user’s activity with when they first and last touched the record', () => { + const summary = summariseByUser(events); + + expect(summary[0].firstActivity).toBe(encounterCreated); + expect(summary[0].lastActivity).toBe(encounterCreated); + expect(summary[1].firstActivity).toBe(dayAfter); + }); + + it('gathers events with no named user rather than dropping them', () => { + const summary = summariseByUser(buildAuditEvents([{ uuid: 'enc-3', auditInfo: { dateCreated: dayAfter } }], {})); + + expect(summary).toHaveLength(1); + expect(summary[0].userUuid).toBe(unknownUserUuid); + expect(summary[0].userDisplay).toBe(''); + }); +}); diff --git a/packages/esm-audit-app/src/audit/patient-activity.ts b/packages/esm-audit-app/src/audit/patient-activity.ts new file mode 100644 index 0000000..6642fda --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-activity.ts @@ -0,0 +1,156 @@ +import { type AuditEncounter, type AuditObs, type OpenmrsResourceRef } from '../types'; + +/** What someone did to a patient's record. */ +export type AuditAction = + | 'encounterCreated' + | 'encounterChanged' + | 'encounterDeleted' + | 'obsRecorded' + | 'obsEdited' + | 'obsDeleted'; + +export interface AuditEvent { + /** Stable across renders: the row it came from plus what happened to it. */ + key: string; + action: AuditAction; + /** When it happened, as the REST API reported it. */ + timestamp: string; + user: OpenmrsResourceRef | undefined; + encounter: AuditEncounter; + /** The concept whose value was touched, for the observation actions. */ + concept?: string; +} + +/** One user's footprint on the part of the record that was read. */ +export interface UserActivity { + userUuid: string; + userDisplay: string; + counts: Record; + firstActivity: string; + lastActivity: string; + totalEvents: number; +} + +/** The obs of one encounter, keyed by the encounter's uuid. */ +export type ObsByEncounter = Record>; + +/** Stands in for the user on events whose actor the API did not report. */ +export const unknownUserUuid = 'unknown'; + +/** + * The key an event is grouped and filtered by. `summariseByUser` and the activity log both use it, + * so a row's counts and the events behind it can never disagree about who did what. + */ +export function eventUserUuid(auditEvent: AuditEvent): string { + return auditEvent.user?.uuid ?? unknownUserUuid; +} + +function event( + action: AuditAction, + timestamp: string | undefined, + user: OpenmrsResourceRef | undefined, + encounter: AuditEncounter, + keySuffix: string, + concept?: string, +): AuditEvent | null { + return timestamp ? { key: `${keySuffix}:${action}`, action, timestamp, user, encounter, concept } : null; +} + +/** + * Turns encounters and their observations into the list of things people did to the record. + * + * Every obs row was created by someone at some point, so each yields one creation event — an edit + * if it replaced an earlier obs, a plain recording otherwise. A voided obs yields a deletion event + * too, unless a later obs replaced it: that voiding is the other half of the successor's edit, and + * counting it again would make one correction look like two acts. + */ +export function buildAuditEvents(encounters: Array, obsByEncounter: ObsByEncounter): Array { + const events: Array = []; + + for (const encounter of encounters) { + const audit = encounter.auditInfo; + events.push( + event('encounterCreated', audit?.dateCreated, audit?.creator, encounter, encounter.uuid), + event('encounterChanged', audit?.dateChanged, audit?.changedBy, encounter, encounter.uuid), + encounter.voided + ? event('encounterDeleted', audit?.dateVoided, audit?.voidedBy, encounter, encounter.uuid) + : null, + ); + + const obsList = obsByEncounter[encounter.uuid] ?? []; + const supersededUuids = new Set( + obsList.map((obs) => obs.previousVersion?.uuid).filter((uuid): uuid is string => Boolean(uuid)), + ); + + for (const obs of obsList) { + const obsAudit = obs.auditInfo; + const concept = obs.concept?.display; + events.push( + event( + obs.previousVersion ? 'obsEdited' : 'obsRecorded', + obsAudit?.dateCreated, + obsAudit?.creator, + encounter, + obs.uuid, + concept, + ), + obs.voided && !supersededUuids.has(obs.uuid) + ? event('obsDeleted', obsAudit?.dateVoided, obsAudit?.voidedBy, encounter, obs.uuid, concept) + : null, + ); + } + } + + return events + .filter((auditEvent): auditEvent is AuditEvent => auditEvent !== null) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)); +} + +function emptyCounts(): Record { + return { + encounterCreated: 0, + encounterChanged: 0, + encounterDeleted: 0, + obsRecorded: 0, + obsEdited: 0, + obsDeleted: 0, + }; +} + +/** + * Who touched the record, and how much: one row per user, busiest first. Events whose user the + * API did not report are gathered under a single unknown user rather than dropped, so the counts + * still add up to what was read. + */ +export function summariseByUser(events: Array): Array { + const byUser = new Map(); + + for (const auditEvent of events) { + const userUuid = eventUserUuid(auditEvent); + let activity = byUser.get(userUuid); + if (!activity) { + activity = { + userUuid, + userDisplay: auditEvent.user?.display ?? '', + counts: emptyCounts(), + firstActivity: auditEvent.timestamp, + lastActivity: auditEvent.timestamp, + totalEvents: 0, + }; + byUser.set(userUuid, activity); + } + + activity.counts[auditEvent.action] += 1; + activity.totalEvents += 1; + if (auditEvent.timestamp < activity.firstActivity) { + activity.firstActivity = auditEvent.timestamp; + } + if (auditEvent.timestamp > activity.lastActivity) { + activity.lastActivity = auditEvent.timestamp; + } + } + + return Array.from(byUser.values()).sort( + (a, b) => b.totalEvents - a.totalEvents || a.userDisplay.localeCompare(b.userDisplay), + ); +} diff --git a/packages/esm-audit-app/src/audit/patient-encounters.component.tsx b/packages/esm-audit-app/src/audit/patient-encounters.component.tsx new file mode 100644 index 0000000..07b78f0 --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-encounters.component.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DataTableSkeleton, + Pagination, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, + Tag, +} from '@carbon/react'; +import { ErrorState, useConfig } from '@openmrs/esm-framework'; +import { type Config } from '../config-schema'; +import { type AuditEncounter, type AuditPatient } from '../types'; +import { formatAuditDatetime } from './audit-format'; +import { usePatientEncounters } from './audit.resource'; +import { type EncounterFilters, hasActiveFilters } from './encounter-filters'; +import styles from './audit.scss'; + +interface PatientEncountersProps { + patient: AuditPatient | undefined; + includeDeleted: boolean; + filters: EncounterFilters; + isLoadingPatient: boolean; + onSelectEncounter(encounterUuid: string): void; +} + +function providerNames(encounter: AuditEncounter): string { + return (encounter.encounterProviders ?? []) + .filter((encounterProvider) => !encounterProvider.voided) + .map((encounterProvider) => encounterProvider.provider?.display) + .filter(Boolean) + .join(', '); +} + +/** + * Every encounter recorded for the patient, most recent first, as the way into one encounter's + * audit trail. Deleted encounters are hidden by default, as they were on the legacy admin page. + */ +export default function PatientEncounters({ + patient, + includeDeleted, + filters, + isLoadingPatient, + onSelectEncounter, +}: PatientEncountersProps) { + const { t } = useTranslation(); + const config = useConfig(); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10); + const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]); + + const { encounters, totalCount, error, isLoading } = usePatientEncounters( + patient, + includeDeleted, + filters, + page, + pageSize, + ); + + useEffect(() => { + setPage(1); + }, [filters, includeDeleted, patient?.uuid]); + + if (error) { + return ; + } + + if (isLoadingPatient || isLoading) { + return ; + } + + if (encounters.length === 0) { + return ( +

+ {hasActiveFilters(filters) + ? t('noEncountersMatchFilters', 'No encounters match these filters.') + : t('noEncountersFound', 'This patient has no encounters to audit.')} +

+ ); + } + + return ( + <> + + + + + {t('encounterDate', 'Encounter date')} + {t('encounterType', 'Encounter type')} + {t('form', 'Form')} + {t('provider', 'Provider')} + {t('location', 'Location')} + {t('status', 'Status')} + + + + {encounters.map((encounter) => ( + onSelectEncounter(encounter.uuid)}> + + + + {encounter.encounterType?.display} + {encounter.form?.display} + {providerNames(encounter)} + {encounter.location?.display} + {encounter.voided ? {t('deleted', 'Deleted')} : null} + + ))} + +
+
+ { + setPage(nextPage); + setPageSize(nextPageSize); + }} + page={page} + pageSize={pageSize} + pageSizes={pageSizes} + size="sm" + totalItems={totalCount} + /> + + ); +} diff --git a/packages/esm-audit-app/src/audit/patient-record.component.test.tsx b/packages/esm-audit-app/src/audit/patient-record.component.test.tsx new file mode 100644 index 0000000..a7793e7 --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-record.component.test.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SWRConfig } from 'swr'; +import { openmrsFetch, useConfig } from '@openmrs/esm-framework'; +import { type AuditEncounter, type AuditPatient } from '../types'; +import PatientRecord from './patient-record.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockUseConfig = jest.mocked(useConfig); + +const mockPatient: AuditPatient = { + uuid: 'patient-1', + display: 'Y2AHXV - Dave TestPatient', + identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }], + person: { display: 'Dave TestPatient', gender: 'M', age: 42, birthdate: '1984-03-14T00:00:00.000+0000' }, +}; + +const consultation: AuditEncounter = { + uuid: 'enc-1', + encounterDatetime: '2026-04-18T09:00:00.000+0000', + voided: false, + encounterType: { uuid: 'type-1', display: 'Oncology Consultation' }, + form: { uuid: 'form-1', display: 'Oncology Consult Note' }, + location: { uuid: 'loc-1', display: 'Klinik Ekstèn' }, + encounterProviders: [{ uuid: 'ep-1', provider: { uuid: 'prov-1', display: 'Louidor Jean paul' } }], +}; + +const deletedCheckin: AuditEncounter = { + uuid: 'enc-2', + encounterDatetime: '2026-04-11T08:00:00.000+0000', + voided: true, + encounterType: { uuid: 'type-2', display: 'Inscription' }, + form: { uuid: 'form-2', display: 'LiveCheckin' }, + location: { uuid: 'loc-2', display: 'CDI Klinik Ekstèn Jeneral' }, + patient: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient' }, +}; + +/** The free-text encounter search matches on identifier, so it can return other patients' rows. */ +const otherPatientsEncounter: AuditEncounter = { + uuid: 'enc-3', + encounterDatetime: '2026-05-01T08:00:00.000+0000', + voided: false, + encounterType: { uuid: 'type-3', display: 'Consultation' }, + patient: { uuid: 'patient-2', display: 'Y2AHXV2 - Someone Else' }, +}; + +/** + * The encounter list is the only read that asks for a total count; the unfiltered scan behind the + * encounter-type dropdown and the activity view reads the same endpoint without one. + */ +function mockRestApi({ pagedEncounters = [consultation] }: { pagedEncounters?: Array } = {}) { + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/patient/patient-1')) { + return Promise.resolve({ data: mockPatient }) as ReturnType; + } + if (url.includes('/obs?encounter=')) { + return Promise.resolve({ data: { results: [] } }) as ReturnType; + } + if (url.includes('/encounter?q=')) { + return Promise.resolve({ + data: { + results: [{ ...consultation, patient: deletedCheckin.patient }, deletedCheckin, otherPatientsEncounter], + }, + }) as ReturnType; + } + if (url.includes('totalCount=true')) { + return Promise.resolve({ + data: { results: pagedEncounters, totalCount: pagedEncounters.length }, + }) as ReturnType; + } + return Promise.resolve({ data: { results: [consultation, deletedCheckin] } }) as ReturnType; + }); +} + +function renderPatientRecord() { + const onSelectEncounter = jest.fn(); + const onBackToSearch = jest.fn(); + const onSelectView = jest.fn(); + render( + new Map() }}> + + , + ); + return { onSelectEncounter, onBackToSearch, onSelectView }; +} + +describe('', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ + activityScanLimit: 50, + patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart', + }); + mockRestApi(); + }); + + it("lists the patient's encounters, without the deleted ones", async () => { + renderPatientRecord(); + + expect(await screen.findByText('Dave TestPatient')).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Oncology Consultation' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Louidor Jean paul' })).toBeInTheDocument(); + expect(screen.queryByText('LiveCheckin')).not.toBeInTheDocument(); + expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/encounter?patient=patient-1')); + }); + + it('includes deleted encounters on request, and only this patient’s', async () => { + renderPatientRecord(); + await screen.findByRole('cell', { name: 'Oncology Consultation' }); + + await userEvent.click(screen.getByRole('checkbox', { name: /include deleted encounters/i })); + + expect(await screen.findByRole('cell', { name: 'LiveCheckin' })).toBeInTheDocument(); + expect(screen.queryByRole('cell', { name: 'Consultation' })).not.toBeInTheDocument(); + expect(mockOpenmrsFetch).toHaveBeenCalledWith( + expect.stringContaining('/ws/rest/v1/encounter?q=Y2AHXV&includeAll=true'), + ); + + const deletedRow = screen.getAllByRole('row').find((row) => row.textContent?.includes('LiveCheckin')); + expect(deletedRow).toHaveTextContent('Deleted'); + }); + + it('drills down into the encounter that is clicked', async () => { + const { onSelectEncounter } = renderPatientRecord(); + await screen.findByRole('cell', { name: 'Oncology Consultation' }); + + await userEvent.click(screen.getAllByRole('button', { name: /2026/ })[0]); + + expect(onSelectEncounter).toHaveBeenCalledWith('enc-1'); + }); + + it("offers only the encounter types this patient's encounters use", async () => { + renderPatientRecord(); + await screen.findByRole('cell', { name: 'Oncology Consultation' }); + + await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i })); + + const options = within(screen.getByRole('listbox')).getAllByRole('option'); + expect(options.map((option) => option.textContent)).toEqual(['Inscription', 'Oncology Consultation']); + }); + + it('asks the server for one encounter type when one is chosen', async () => { + renderPatientRecord(); + await screen.findByRole('cell', { name: 'Oncology Consultation' }); + + await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i })); + await userEvent.click(await screen.findByRole('option', { name: 'Inscription' })); + + await waitFor(() => + expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('&encounterType=type-2')), + ); + }); + + it('offers to clear the filters once one is set, and says when nothing matches', async () => { + renderPatientRecord(); + await screen.findByRole('cell', { name: 'Oncology Consultation' }); + expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument(); + + mockRestApi({ pagedEncounters: [] }); + + await userEvent.click(screen.getByRole('combobox', { name: /encounter type/i })); + await userEvent.click(await screen.findByRole('option', { name: 'Inscription' })); + + expect(await screen.findByText(/no encounters match these filters/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /clear filters/i })); + + expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument(); + }); + + it('goes back to the patient search', async () => { + const { onBackToSearch } = renderPatientRecord(); + + await userEvent.click(screen.getByRole('button', { name: /back to patient search/i })); + + expect(onBackToSearch).toHaveBeenCalled(); + }); + + it('switches to the record activity view', async () => { + const { onSelectView } = renderPatientRecord(); + await screen.findByRole('cell', { name: 'Oncology Consultation' }); + + await userEvent.click(screen.getByRole('tab', { name: /record activity/i })); + + expect(onSelectView).toHaveBeenCalledWith('activity'); + }); +}); diff --git a/packages/esm-audit-app/src/audit/patient-record.component.tsx b/packages/esm-audit-app/src/audit/patient-record.component.tsx new file mode 100644 index 0000000..8522890 --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-record.component.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Checkbox, InlineNotification, Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react'; +import { ArrowLeftIcon, ErrorState } from '@openmrs/esm-framework'; +import { canListDeletedEncounters, getPreferredIdentifier, useAuditPatient } from './audit.resource'; +import { type EncounterFilters } from './encounter-filters'; +import EncounterFiltersBar from './encounter-filters.component'; +import PatientActivity from './patient-activity.component'; +import PatientEncounters from './patient-encounters.component'; +import styles from './audit.scss'; + +export type PatientRecordView = 'encounters' | 'activity'; + +export const patientRecordViews: Array = ['encounters', 'activity']; + +interface PatientRecordProps { + patientUuid: string; + view: PatientRecordView; + onSelectView(view: PatientRecordView): void; + onSelectEncounter(encounterUuid: string): void; + onBackToSearch(): void; +} + +/** + * Step two of the audit trail. The patient's record can be read two ways — as the list of + * encounters to drill into, or as the record of who has touched it — and both are narrowed by the + * same filters, which is why they share this component's state rather than holding their own. + */ +export default function PatientRecord({ + patientUuid, + view, + onSelectView, + onSelectEncounter, + onBackToSearch, +}: PatientRecordProps) { + const { t } = useTranslation(); + const [includeDeleted, setIncludeDeleted] = useState(false); + const [filters, setFilters] = useState({}); + + const { patient, error: patientError, isLoading: isLoadingPatient } = useAuditPatient(patientUuid); + const cannotIncludeDeleted = includeDeleted && Boolean(patient) && !canListDeletedEncounters(patient); + + return ( +
+ +
+ {patient?.person?.display ?? patient?.display ?? ''} + + {[getPreferredIdentifier(patient), patient?.person?.gender, patient?.person?.age] + .filter((part) => part !== undefined && part !== '') + .join(' · ')} + +
+ + + +
+ setIncludeDeleted(checked)} + /> +
+ + {cannotIncludeDeleted ? ( + + ) : null} + + {patientError ? ( + + ) : ( + onSelectView(patientRecordViews[selectedIndex])} + selectedIndex={Math.max(0, patientRecordViews.indexOf(view))}> + + {t('encounters', 'Encounters')} + {t('recordActivity', 'Record activity')} + + + + + + + + + + + )} +
+ ); +} diff --git a/packages/esm-audit-app/src/audit/patient-search.component.test.tsx b/packages/esm-audit-app/src/audit/patient-search.component.test.tsx new file mode 100644 index 0000000..c0c570f --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-search.component.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SWRConfig } from 'swr'; +import { openmrsFetch } from '@openmrs/esm-framework'; +import PatientSearch from './patient-search.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); + +const mockPatients = [ + { + uuid: 'patient-1', + display: 'Y2AHXV - Dave TestPatient', + identifiers: [{ uuid: 'id-1', identifier: 'Y2AHXV', preferred: true }], + person: { display: 'Dave TestPatient', gender: 'M', age: 42, birthdate: '1984-03-14T00:00:00.000+0000' }, + }, +]; + +function renderPatientSearch() { + const onSelectPatient = jest.fn(); + render( + new Map() }}> + + , + ); + return onSelectPatient; +} + +describe('', () => { + beforeEach(() => { + mockOpenmrsFetch.mockResolvedValue({ + data: { results: mockPatients, totalCount: 1 }, + } as unknown as ReturnType); + }); + + it('asks for a search term before searching', () => { + renderPatientSearch(); + + expect(screen.getByText(/enter a patient name or identifier/i)).toBeInTheDocument(); + expect(mockOpenmrsFetch).not.toHaveBeenCalled(); + }); + + it('searches patients by name or identifier and lists what it finds', async () => { + renderPatientSearch(); + + await userEvent.type(screen.getByRole('searchbox'), 'Y2AHXV'); + + expect(await screen.findByRole('button', { name: 'Dave TestPatient' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Y2AHXV' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: '42' })).toBeInTheDocument(); + expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/ws/rest/v1/patient?q=Y2AHXV')); + }); + + it('drills down into the patient that is clicked', async () => { + const onSelectPatient = renderPatientSearch(); + + await userEvent.type(screen.getByRole('searchbox'), 'Y2AHXV'); + await userEvent.click(await screen.findByRole('button', { name: 'Dave TestPatient' })); + + expect(onSelectPatient).toHaveBeenCalledWith('patient-1'); + }); + + it('says so when nothing matches', async () => { + mockOpenmrsFetch.mockResolvedValue({ data: { results: [], totalCount: 0 } } as unknown as ReturnType< + typeof openmrsFetch + >); + renderPatientSearch(); + + await userEvent.type(screen.getByRole('searchbox'), 'nobody'); + + expect(await screen.findByText(/no patients match "nobody"/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-audit-app/src/audit/patient-search.component.tsx b/packages/esm-audit-app/src/audit/patient-search.component.tsx new file mode 100644 index 0000000..24c218f --- /dev/null +++ b/packages/esm-audit-app/src/audit/patient-search.component.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DataTableSkeleton, + Pagination, + Search, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '@carbon/react'; +import { ErrorState, useConfig, useDebounce } from '@openmrs/esm-framework'; +import { type Config } from '../config-schema'; +import { formatAuditDate } from './audit-format'; +import { getPreferredIdentifier, usePatientSearch } from './audit.resource'; +import styles from './audit.scss'; + +interface PatientSearchProps { + onSelectPatient(patientUuid: string): void; +} + +/** + * Step one of the audit trail: find the patient whose records are being audited, by name or by + * identifier. This mirrors the legacy admin page, which searched encounters by patient name or + * identifier, except that the patient is chosen explicitly before their encounters are listed. + */ +export default function PatientSearch({ onSelectPatient }: PatientSearchProps) { + const { t } = useTranslation(); + const config = useConfig(); + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(config.patientSearchPageSize ?? 10); + const debouncedSearchTerm = useDebounce(searchTerm.trim(), 300); + const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]); + const { patients, totalCount, error, isLoading } = usePatientSearch(debouncedSearchTerm, page, pageSize); + + useEffect(() => { + setPage(1); + }, [debouncedSearchTerm]); + + return ( +
+ setSearchTerm(event.target.value)} + size="lg" + value={searchTerm} + /> + {!debouncedSearchTerm ? ( +

+ {t('searchToBegin', 'Enter a patient name or identifier to begin auditing their records.')} +

+ ) : error ? ( + + ) : isLoading ? ( + + ) : patients.length === 0 ? ( +

+ {t('noPatientsFound', 'No patients match "{{searchTerm}}".', { searchTerm: debouncedSearchTerm })} +

+ ) : ( + <> + + + + + {t('patientName', 'Patient name')} + {t('identifier', 'Identifier')} + {t('gender', 'Gender')} + {t('age', 'Age')} + {t('birthdate', 'Date of birth')} + + + + {patients.map((patient) => ( + onSelectPatient(patient.uuid)}> + + + + {getPreferredIdentifier(patient)} + {patient.person?.gender} + {patient.person?.age} + {formatAuditDate(patient.person?.birthdate)} + + ))} + +
+
+ { + setPage(nextPage); + setPageSize(nextPageSize); + }} + page={page} + pageSize={pageSize} + pageSizes={pageSizes} + size="sm" + totalItems={totalCount} + /> + + )} +
+ ); +} diff --git a/packages/esm-audit-app/src/config-schema.ts b/packages/esm-audit-app/src/config-schema.ts new file mode 100644 index 0000000..dd83942 --- /dev/null +++ b/packages/esm-audit-app/src/config-schema.ts @@ -0,0 +1,45 @@ +/** + * This is the config schema. In OpenMRS Microfrontends, all config parameters + * are optional, so every element must have a reasonable default. See the + * configuration system documentation: + * https://openmrs.github.io/openmrs-esm-core/#/main/config + */ +import { Type, validators } from '@openmrs/esm-framework'; + +export const configSchema = { + patientSearchPageSize: { + _type: Type.Number, + _description: 'How many patients to show per page of patient search results.', + _default: 10, + _validators: [validators.inRange(1, 100)], + }, + encountersPageSize: { + _type: Type.Number, + _description: "How many encounters to show per page of a patient's encounter list.", + _default: 10, + _validators: [validators.inRange(1, 100)], + }, + activityScanLimit: { + _type: Type.Number, + _description: + "How many of a patient's most recent encounters the record activity view reads observations " + + 'from. Each encounter costs one request, so this bounds how much work a scan can do.', + _default: 50, + _validators: [validators.inRange(1, 200)], + }, + patientChartUrl: { + _type: Type.String, + _description: + 'The URL of the patient chart to link to from the audit trail. May contain the ' + + 'template variables ${openmrsSpaBase}, ${openmrsBase} and ${patientUuid}.', + _default: '${openmrsSpaBase}/patient/${patientUuid}/chart', + _validators: [validators.isUrlWithTemplateParameters(['patientUuid'])], + }, +}; + +export interface Config { + activityScanLimit: number; + patientSearchPageSize: number; + encountersPageSize: number; + patientChartUrl: string; +} diff --git a/packages/esm-audit-app/src/dashboard-link.component.tsx b/packages/esm-audit-app/src/dashboard-link.component.tsx new file mode 100644 index 0000000..cd132c4 --- /dev/null +++ b/packages/esm-audit-app/src/dashboard-link.component.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react'; +import { BrowserRouter, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ConfigurableLink } from '@openmrs/esm-framework'; +import { dashboardMeta } from './dashboard.meta'; + +function AuditLink() { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const isActive = useMemo( + () => + pathname + .split('/') + .map((segment) => decodeURIComponent(segment)) + .includes(dashboardMeta.name), + [pathname], + ); + + return ( + + {t('audit', dashboardMeta.title)} + + ); +} + +/** + * Extensions are mounted as their own React root, so they do not inherit the + * home app's router context and need their own to read the current location. + */ +export default function DashboardLink() { + return ( + + + + ); +} diff --git a/packages/esm-audit-app/src/dashboard.meta.ts b/packages/esm-audit-app/src/dashboard.meta.ts new file mode 100644 index 0000000..8c00e16 --- /dev/null +++ b/packages/esm-audit-app/src/dashboard.meta.ts @@ -0,0 +1,11 @@ +/** + * The home app renders one dashboard per extension assigned to the + * `homepage-dashboard-slot`, keyed off the `name` in the extension's meta. + * That name is also the last segment of the dashboard's URL, so this dashboard + * lives at `/openmrs/spa/home/audit`. + */ +export const dashboardMeta = { + name: 'audit', + slot: 'audit-dashboard-slot', + title: 'Audit', +}; diff --git a/packages/esm-audit-app/src/declarations.d.tsx b/packages/esm-audit-app/src/declarations.d.tsx new file mode 100644 index 0000000..450e9d9 --- /dev/null +++ b/packages/esm-audit-app/src/declarations.d.tsx @@ -0,0 +1,3 @@ +declare module '@carbon/react'; +declare module '*.css'; +declare module '*.scss'; diff --git a/packages/esm-audit-app/src/index.ts b/packages/esm-audit-app/src/index.ts new file mode 100644 index 0000000..7efeffe --- /dev/null +++ b/packages/esm-audit-app/src/index.ts @@ -0,0 +1,33 @@ +/** + * This is the entrypoint file of the application. It communicates the + * important features of this microfrontend to the app shell. It + * connects the app shell to the React application(s) that make up this + * microfrontend. + */ + +import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle, registerBreadcrumbs } from '@openmrs/esm-framework'; +import { configSchema } from './config-schema'; +import { dashboardMeta } from './dashboard.meta'; +import DashboardLink from './dashboard-link.component'; + +const moduleName = '@pih/esm-audit-app'; +const options = { + featureName: 'audit', + moduleName, +}; + +export const importTranslation = require.context('../translations', false, /.json$/, 'lazy'); + +export const root = getAsyncLifecycle(() => import('./root.component'), options); + +export const auditDashboardLink = getSyncLifecycle(DashboardLink, options); + +export function startupApp() { + defineConfigSchema(moduleName, configSchema); + registerBreadcrumbs([ + { + path: `${window.spaBase}/home/${dashboardMeta.name}`, + title: dashboardMeta.title, + }, + ]); +} diff --git a/packages/esm-audit-app/src/root.component.test.tsx b/packages/esm-audit-app/src/root.component.test.tsx new file mode 100644 index 0000000..8f5f216 --- /dev/null +++ b/packages/esm-audit-app/src/root.component.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import { openmrsFetch, useConfig } from '@openmrs/esm-framework'; +import Root from './root.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockUseConfig = jest.mocked(useConfig); + +function renderRoot(url = '/') { + window.history.pushState({}, '', url); + render( + new Map() }}> + + , + ); +} + +describe('', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart' }); + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/patient/patient-1')) { + return Promise.resolve({ + data: { uuid: 'patient-1', display: 'Y2AHXV - Dave TestPatient', person: { display: 'Dave TestPatient' } }, + }) as ReturnType; + } + if (url.includes('/encounter/enc-1')) { + return Promise.resolve({ + data: { uuid: 'enc-1', encounterType: { uuid: 'type-1', display: 'Oncology Consultation' } }, + }) as ReturnType; + } + return Promise.resolve({ data: { results: [] } }) as ReturnType; + }); + }); + + it('starts at the patient search', () => { + renderRoot(); + + expect(screen.getByText('Audit trail')).toBeInTheDocument(); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + expect(screen.getByText(/enter a patient name or identifier/i)).toBeInTheDocument(); + }); + + it("shows a patient's encounters when the url names a patient", async () => { + renderRoot('/?patient=patient-1'); + + expect(await screen.findByText('Dave TestPatient')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /include deleted encounters/i })).toBeInTheDocument(); + }); + + it('shows the encounter audit trail when the url names an encounter', async () => { + renderRoot('/?patient=patient-1&encounter=enc-1'); + + expect(await screen.findByText('Encounter summary')).toBeInTheDocument(); + expect(screen.getByText('Observations')).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-audit-app/src/root.component.tsx b/packages/esm-audit-app/src/root.component.tsx new file mode 100644 index 0000000..f8fedf0 --- /dev/null +++ b/packages/esm-audit-app/src/root.component.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import Audit from './audit/audit.component'; + +/** + * Extensions are mounted as their own React root, so this dashboard does not inherit the home + * app's router context and needs its own in order to keep the audit trail's position — the + * selected patient and encounter — in the URL's query string. + */ +export default function Root() { + return ( + + + + ); +} diff --git a/packages/esm-audit-app/src/routes.json b/packages/esm-audit-app/src/routes.json new file mode 100644 index 0000000..956d73f --- /dev/null +++ b/packages/esm-audit-app/src/routes.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.openmrs.org/routes.schema.json", + "extensions": [ + { + "name": "audit-dashboard-link", + "slot": "homepage-dashboard-slot", + "component": "auditDashboardLink", + "meta": { + "name": "audit", + "slot": "audit-dashboard-slot", + "title": "Audit" + } + }, + { + "name": "audit-dashboard", + "slot": "audit-dashboard-slot", + "component": "root" + } + ] +} diff --git a/packages/esm-audit-app/src/types.ts b/packages/esm-audit-app/src/types.ts new file mode 100644 index 0000000..ee27924 --- /dev/null +++ b/packages/esm-audit-app/src/types.ts @@ -0,0 +1,122 @@ +/** + * Types for the encounter audit trail. These mirror the shapes returned by the + * OpenMRS REST API for the custom representations requested in + * `audit/audit.resource.ts` — keep the two in sync. + */ + +/** The `{uuid, display}` shape the REST API uses for a reference to another resource. */ +export interface OpenmrsResourceRef { + uuid: string; + display?: string; +} + +/** + * The REST API's `auditInfo` property. `changedBy`/`dateChanged` are only set once a + * row has been edited in place, and the `voided*` fields are only present on voided rows. + */ +export interface AuditInfo { + creator?: OpenmrsResourceRef; + dateCreated?: string; + changedBy?: OpenmrsResourceRef; + dateChanged?: string; + voidedBy?: OpenmrsResourceRef; + dateVoided?: string; + voidReason?: string; +} + +export interface PagedResponse { + results: Array; + links?: Array<{ rel: 'prev' | 'next'; uri: string }>; + totalCount?: number; +} + +export interface PatientIdentifier { + identifier: string; + preferred?: boolean; + identifierType?: OpenmrsResourceRef; +} + +export interface AuditPatient { + uuid: string; + display: string; + identifiers?: Array; + person?: { + display?: string; + gender?: string; + age?: number; + birthdate?: string; + dead?: boolean; + }; +} + +export interface EncounterProvider { + uuid: string; + voided?: boolean; + provider?: OpenmrsResourceRef & { identifier?: string }; + encounterRole?: OpenmrsResourceRef; +} + +export interface AuditEncounter { + uuid: string; + display?: string; + encounterDatetime?: string; + voided?: boolean; + patient?: AuditPatient; + location?: OpenmrsResourceRef; + form?: OpenmrsResourceRef & { version?: string }; + encounterType?: OpenmrsResourceRef; + visit?: OpenmrsResourceRef & { startDatetime?: string; visitType?: OpenmrsResourceRef }; + encounterProviders?: Array; + auditInfo?: AuditInfo; +} + +export interface ConceptDescription { + description?: string; + locale?: string; +} + +/** + * An obs value is a plain string or number for free-text, numeric and datetime obs, and a + * reference for coded obs (and for the drug/location values OpenMRS stores as coded-ish obs). + */ +export type ObsValue = string | number | boolean | OpenmrsResourceRef | null; + +export interface AuditObs { + uuid: string; + display?: string; + obsDatetime?: string; + voided?: boolean; + value?: ObsValue; + comment?: string; + formFieldPath?: string; + concept?: OpenmrsResourceRef & { descriptions?: Array }; + /** Set when this obs is a member of an obs group. */ + obsGroup?: OpenmrsResourceRef; + /** Set when this obs replaced an earlier one, i.e. when the value was edited. */ + previousVersion?: OpenmrsResourceRef; + auditInfo?: AuditInfo; +} + +/** + * How an obs came to look the way it does, relative to the encounter it belongs to. Derived + * rather than stored — see `getObsChangeStatus`. + */ +export type ObsChangeStatus = + /** Entered when the encounter was first created and never touched since. */ + | 'unchanged' + /** Replaces an earlier obs: someone changed the value after the fact. */ + | 'edited' + /** Added to the encounter after the encounter itself was created. */ + | 'addedAfterEncounter' + /** Voided outright — the value was removed from the encounter. */ + | 'deleted' + /** Voided because a newer obs replaced it, so it holds a value that used to be current. */ + | 'supersededValue'; + +/** An obs plus the group nesting and change status the audit table renders. */ +export interface ObsTreeNode { + obs: AuditObs; + status: ObsChangeStatus; + depth: number; + children: Array; +} diff --git a/packages/esm-audit-app/translations/en.json b/packages/esm-audit-app/translations/en.json new file mode 100644 index 0000000..df35a0d --- /dev/null +++ b/packages/esm-audit-app/translations/en.json @@ -0,0 +1,90 @@ +{ + "action": "Action", + "activityLog": "Activity log", + "activityScanCapped": "Showing activity from the {{scannedCount}} most recent of {{matchedCount}} encounters. Narrow by encounter type or date to look further back.", + "addedLater": "Added later", + "addedLaterLegend": "recorded after the encounter itself was created", + "age": "Age", + "allEncounterTypes": "All encounter types", + "audit": "Audit", + "auditTrail": "Audit trail", + "auditViews": "Audit views", + "backToEncounters": "Back to encounters", + "backToPatientSearch": "Back to patient search", + "birthdate": "Date of birth", + "cannotIncludeDeletedEncounters": "Deleted encounters can only be listed for a patient who has an identifier.", + "change": "Change", + "changedBy": "Changed by", + "changedByUser": "Changed by {{user}}", + "clearFilters": "Clear filters", + "createdBy": "Created by", + "deleted": "Deleted", + "deletedBy": "Deleted by", + "deletedEncountersUnavailable": "Deleted encounters unavailable", + "deletedLegend": "removed from the encounter", + "deletionReason": "Reason for deletion", + "detail": "Detail", + "edited": "Edited", + "editedLegend": "the value was changed after it was first recorded", + "encounter": "Encounter", + "encounterAudit": "Encounter audit", + "encounterChangedAction": "Changed encounter", + "encounterCreatedAction": "Created encounter", + "encounterDate": "Encounter date", + "encounterDateRange": "Encounter date range", + "encounterDeleted": "Deleted encounter", + "encounterDeletedAction": "Deleted encounter", + "encounterDeletedSubtitle": "This encounter has been deleted. Its observations are shown as they were left.", + "encounterNotFound": "That encounter could not be found.", + "encounters": "Encounters", + "encountersCreated": "Encounters created", + "encounterSummary": "Encounter summary", + "encounterType": "Encounter type", + "firstActivity": "First activity", + "form": "Form", + "gender": "Gender", + "identifier": "Identifier", + "includeDeletedEncounters": "Include deleted encounters", + "lastActivity": "Last activity", + "location": "Location", + "noActivity": "There is no recorded activity to show.", + "noEncountersFound": "This patient has no encounters to audit.", + "noEncountersMatchFilters": "No encounters match these filters.", + "noObservations": "This encounter has no observations.", + "noPatientsFound": "No patients match \"{{searchTerm}}\".", + "noProviders": "No providers are recorded for this encounter.", + "obsDeletedAction": "Deleted observation", + "obsDeletedCount": "Observations deleted", + "obsEditedAction": "Edited observation", + "obsEditedCount": "Observations edited", + "observations": "Observations", + "obsRecordedAction": "Recorded observation", + "obsRecordedCount": "Observations recorded", + "partialScan": "Part of the record", + "patient": "Patient", + "patientName": "Patient name", + "patientSearchResults": "Patient search results", + "previousValue": "Previous value", + "previousValueLegend": "the value that an edit replaced", + "provider": "Provider", + "providerName": "Provider name", + "providers": "Providers", + "questionConcept": "Question concept", + "recordActivity": "Record activity", + "recordedBy": "Recorded by", + "role": "Role", + "searchForPatient": "Search for a patient by name or identifier", + "searchToBegin": "Enter a patient name or identifier to begin auditing their records.", + "showConceptDescriptions": "Show concept descriptions", + "showDeletedObs": "Show deleted observations", + "showEveryone": "Show everyone", + "showOnlyThisUser": "Show only this user's activity", + "status": "Status", + "unknownUser": "Unknown user", + "user": "User", + "value": "Value", + "visit": "Visit", + "wasPreviously": "was {{value}}", + "when": "When", + "whoTouchedThisRecord": "Who touched this record" +} diff --git a/packages/esm-audit-app/translations/es.json b/packages/esm-audit-app/translations/es.json new file mode 100644 index 0000000..9cf5240 --- /dev/null +++ b/packages/esm-audit-app/translations/es.json @@ -0,0 +1,90 @@ +{ + "action": "Acción", + "activityLog": "Registro de actividad", + "activityScanCapped": "Mostrando la actividad de los {{scannedCount}} encuentros más recientes de {{matchedCount}}. Filtre por tipo de encuentro o por fecha para llegar más atrás.", + "addedLater": "Añadido posteriormente", + "addedLaterLegend": "registrado después de crearse el encuentro", + "age": "Edad", + "allEncounterTypes": "Todos los tipos de encuentro", + "audit": "Auditoría", + "auditTrail": "Pista de auditoría", + "auditViews": "Vistas de auditoría", + "backToEncounters": "Volver a los encuentros", + "backToPatientSearch": "Volver a la búsqueda de pacientes", + "birthdate": "Fecha de nacimiento", + "cannotIncludeDeletedEncounters": "Los encuentros eliminados solo pueden listarse para un paciente que tenga un identificador.", + "change": "Cambio", + "changedBy": "Modificado por", + "changedByUser": "Modificado por {{user}}", + "clearFilters": "Borrar los filtros", + "createdBy": "Creado por", + "deleted": "Eliminado", + "deletedBy": "Eliminado por", + "deletedEncountersUnavailable": "Encuentros eliminados no disponibles", + "deletedLegend": "retirado del encuentro", + "deletionReason": "Motivo de la eliminación", + "detail": "Detalle", + "edited": "Editado", + "editedLegend": "el valor se cambió después de registrarse por primera vez", + "encounter": "Encuentro", + "encounterAudit": "Auditoría del encuentro", + "encounterChangedAction": "Encuentro modificado", + "encounterCreatedAction": "Encuentro creado", + "encounterDate": "Fecha del encuentro", + "encounterDateRange": "Rango de fechas de los encuentros", + "encounterDeleted": "Encuentro eliminado", + "encounterDeletedAction": "Encuentro eliminado", + "encounterDeletedSubtitle": "Este encuentro ha sido eliminado. Sus observaciones se muestran tal como quedaron.", + "encounterNotFound": "No se ha encontrado ese encuentro.", + "encounters": "Encuentros", + "encountersCreated": "Encuentros creados", + "encounterSummary": "Resumen del encuentro", + "encounterType": "Tipo de encuentro", + "firstActivity": "Primera actividad", + "form": "Formulario", + "gender": "Género", + "identifier": "Identificador", + "includeDeletedEncounters": "Incluir encuentros eliminados", + "lastActivity": "Última actividad", + "location": "Localidad", + "noActivity": "No hay actividad registrada que mostrar.", + "noEncountersFound": "Este paciente no tiene encuentros que auditar.", + "noEncountersMatchFilters": "Ningún encuentro coincide con estos filtros.", + "noObservations": "Este encuentro no tiene observaciones.", + "noPatientsFound": "Ningún paciente coincide con «{{searchTerm}}».", + "noProviders": "No hay proveedores registrados para este encuentro.", + "obsDeletedAction": "Observación eliminada", + "obsDeletedCount": "Observaciones eliminadas", + "obsEditedAction": "Observación editada", + "obsEditedCount": "Observaciones editadas", + "observations": "Observaciones", + "obsRecordedAction": "Observación registrada", + "obsRecordedCount": "Observaciones registradas", + "partialScan": "Parte del historial", + "patient": "Paciente", + "patientName": "Nombre del paciente", + "patientSearchResults": "Resultados de la búsqueda de pacientes", + "previousValue": "Valor anterior", + "previousValueLegend": "el valor sustituido por una edición", + "provider": "Proveedor", + "providerName": "Nombre del proveedor", + "providers": "Proveedores", + "questionConcept": "Concepto de la pregunta", + "recordActivity": "Actividad del historial", + "recordedBy": "Registrado por", + "role": "Rol", + "searchForPatient": "Buscar un paciente por nombre o identificador", + "searchToBegin": "Introduzca el nombre o el identificador de un paciente para empezar a auditar su historial.", + "showConceptDescriptions": "Mostrar las descripciones de los conceptos", + "showDeletedObs": "Mostrar las observaciones eliminadas", + "showEveryone": "Mostrar a todos", + "showOnlyThisUser": "Mostrar solo la actividad de este usuario", + "status": "Estado", + "unknownUser": "Usuario desconocido", + "user": "Usuario", + "value": "Valor", + "visit": "Visita", + "wasPreviously": "era {{value}}", + "when": "Cuándo", + "whoTouchedThisRecord": "Quién ha tocado este historial" +} diff --git a/packages/esm-audit-app/translations/fr.json b/packages/esm-audit-app/translations/fr.json new file mode 100644 index 0000000..567e0a1 --- /dev/null +++ b/packages/esm-audit-app/translations/fr.json @@ -0,0 +1,90 @@ +{ + "action": "Action", + "activityLog": "Journal d'activité", + "activityScanCapped": "Affichage de l'activité des {{scannedCount}} rencontres les plus récentes sur {{matchedCount}}. Filtrez par type de rencontre ou par date pour remonter plus loin.", + "addedLater": "Ajouté ultérieurement", + "addedLaterLegend": "enregistré après la création de la rencontre", + "age": "Âge", + "allEncounterTypes": "Tous les types de rencontre", + "audit": "Audit", + "auditTrail": "Piste d'audit", + "auditViews": "Vues d'audit", + "backToEncounters": "Retour aux rencontres", + "backToPatientSearch": "Retour à la recherche de patient", + "birthdate": "Date de naissance", + "cannotIncludeDeletedEncounters": "Les rencontres supprimées ne peuvent être listées que pour un patient possédant un numéro d'identification.", + "change": "Modification", + "changedBy": "Modifié par", + "changedByUser": "Modifié par {{user}}", + "clearFilters": "Effacer les filtres", + "createdBy": "Créé par", + "deleted": "Supprimé", + "deletedBy": "Supprimé par", + "deletedEncountersUnavailable": "Rencontres supprimées indisponibles", + "deletedLegend": "retiré de la rencontre", + "deletionReason": "Motif de la suppression", + "detail": "Détail", + "edited": "Modifié", + "editedLegend": "la valeur a été modifiée après son premier enregistrement", + "encounter": "Rencontre", + "encounterAudit": "Audit de la rencontre", + "encounterChangedAction": "Rencontre modifiée", + "encounterCreatedAction": "Rencontre créée", + "encounterDate": "Date de rencontre", + "encounterDateRange": "Période des rencontres", + "encounterDeleted": "Rencontre supprimée", + "encounterDeletedAction": "Rencontre supprimée", + "encounterDeletedSubtitle": "Cette rencontre a été supprimée. Ses observations sont affichées telles qu'elles ont été laissées.", + "encounterNotFound": "Cette rencontre est introuvable.", + "encounters": "Rencontres", + "encountersCreated": "Rencontres créées", + "encounterSummary": "Résumé de la rencontre", + "encounterType": "Type de rencontre", + "firstActivity": "Première activité", + "form": "Fiche", + "gender": "Sexe", + "identifier": "Numéro d'identification", + "includeDeletedEncounters": "Inclure les rencontres supprimées", + "lastActivity": "Dernière activité", + "location": "Lieu", + "noActivity": "Aucune activité enregistrée à afficher.", + "noEncountersFound": "Ce patient n'a aucune rencontre à auditer.", + "noEncountersMatchFilters": "Aucune rencontre ne correspond à ces filtres.", + "noObservations": "Cette rencontre n'a aucune observation.", + "noPatientsFound": "Aucun patient ne correspond à « {{searchTerm}} ».", + "noProviders": "Aucun prestataire de service n'est enregistré pour cette rencontre.", + "obsDeletedAction": "Observation supprimée", + "obsDeletedCount": "Observations supprimées", + "obsEditedAction": "Observation modifiée", + "obsEditedCount": "Observations modifiées", + "observations": "Observations", + "obsRecordedAction": "Observation enregistrée", + "obsRecordedCount": "Observations enregistrées", + "partialScan": "Une partie du dossier", + "patient": "Patient", + "patientName": "Nom du patient", + "patientSearchResults": "Résultats de la recherche de patients", + "previousValue": "Valeur précédente", + "previousValueLegend": "la valeur remplacée par une modification", + "provider": "Prestataire de service", + "providerName": "Nom du prestataire de service", + "providers": "Prestataires de service", + "questionConcept": "Concept de la question", + "recordActivity": "Activité du dossier", + "recordedBy": "Enregistré par", + "role": "Rôle", + "searchForPatient": "Rechercher un patient par nom ou numéro d'identification", + "searchToBegin": "Saisissez le nom ou le numéro d'identification d'un patient pour commencer l'audit de son dossier.", + "showConceptDescriptions": "Afficher les descriptions des concepts", + "showDeletedObs": "Afficher les observations supprimées", + "showEveryone": "Afficher tout le monde", + "showOnlyThisUser": "Afficher uniquement l'activité de cet utilisateur", + "status": "Statut", + "unknownUser": "Utilisateur inconnu", + "user": "Utilisateur", + "value": "Valeur", + "visit": "Visite", + "wasPreviously": "était {{value}}", + "when": "Quand", + "whoTouchedThisRecord": "Qui a touché à ce dossier" +} diff --git a/packages/esm-audit-app/tsconfig.json b/packages/esm-audit-app/tsconfig.json new file mode 100644 index 0000000..54ce28c --- /dev/null +++ b/packages/esm-audit-app/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.tsx"] +} diff --git a/tools/setup-tests.ts b/tools/setup-tests.ts index a49fca6..f49da13 100644 --- a/tools/setup-tests.ts +++ b/tools/setup-tests.ts @@ -12,10 +12,17 @@ window.spaBase = '/spa'; window.getOpenmrsSpaBase = () => '/openmrs/spa/'; window.HTMLElement.prototype.scrollIntoView = jest.fn(); -window.matchMedia = jest.fn().mockImplementation(() => { +// Carbon components subscribe with addEventListener; the deprecated addListener pair is kept for +// anything still using it. +window.matchMedia = jest.fn().mockImplementation((query: string) => { return { matches: false, + media: query, + onchange: null, addListener: () => {}, removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, }; }); diff --git a/yarn.lock b/yarn.lock index d723338..3040006 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2958,6 +2958,85 @@ __metadata: languageName: node linkType: hard +"@module-federation/bridge-react-webpack-plugin@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/bridge-react-webpack-plugin@npm:2.8.1" + dependencies: + "@module-federation/sdk": "npm:2.8.1" + checksum: 10/67df65b8b9a0f011423b2bac0faab32c03b1607acf40a923315299e1ad663645475194a4fdcaa6f1d5718b64a0ea8849915d7d0aff65a6ae0182971ab9e2c9be + languageName: node + linkType: hard + +"@module-federation/cli@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/cli@npm:2.8.1" + dependencies: + "@module-federation/dts-plugin": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + commander: "npm:11.1.0" + jiti: "npm:2.4.2" + bin: + mf: bin/mf.js + checksum: 10/696437e41c240dbb045171842363779bf7dbd8a560005402d6958006ad1fd25934f3391dcc53a3c14469beaff1932cd16058f239f336d16549bf8ea8d53d3a33 + languageName: node + linkType: hard + +"@module-federation/dts-plugin@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/dts-plugin@npm:2.8.1" + dependencies: + "@module-federation/error-codes": "npm:2.8.1" + "@module-federation/managers": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + "@module-federation/third-party-dts-extractor": "npm:2.8.1" + adm-zip: "npm:0.6.0" + isomorphic-ws: "npm:5.0.0" + undici: "npm:7.28.0" + ws: "npm:8.21.0" + peerDependencies: + typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vue-tsc: ">=1.0.24" + peerDependenciesMeta: + vue-tsc: + optional: true + checksum: 10/51b87277e87725b9ec1e8aa41b810b19675e86eb8b8b3f8f4da068d644a18b808e0630aa67e867d1164884cd93c8e2f9314001c5b46223665fd496382bfdbd39 + languageName: node + linkType: hard + +"@module-federation/enhanced@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/enhanced@npm:2.8.1" + dependencies: + "@module-federation/bridge-react-webpack-plugin": "npm:2.8.1" + "@module-federation/cli": "npm:2.8.1" + "@module-federation/dts-plugin": "npm:2.8.1" + "@module-federation/error-codes": "npm:2.8.1" + "@module-federation/inject-external-runtime-core-plugin": "npm:2.8.1" + "@module-federation/managers": "npm:2.8.1" + "@module-federation/manifest": "npm:2.8.1" + "@module-federation/rspack": "npm:2.8.1" + "@module-federation/runtime-tools": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + "@module-federation/webpack-bundler-runtime": "npm:2.8.1" + schema-utils: "npm:4.3.0" + tapable: "npm:2.3.0" + peerDependencies: + typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vue-tsc: ">=1.0.24" + webpack: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + vue-tsc: + optional: true + webpack: + optional: true + bin: + mf: bin/mf.js + checksum: 10/d1f86641b321e5c309166b347c724748b1dcfaa6420a12ae66089d48579ac312dec4fc61d6e9da14d80c342f218983aff694a75fc869050816cb78ced3043511 + languageName: node + linkType: hard + "@module-federation/error-codes@npm:0.22.0": version: 0.22.0 resolution: "@module-federation/error-codes@npm:0.22.0" @@ -2965,6 +3044,66 @@ __metadata: languageName: node linkType: hard +"@module-federation/error-codes@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/error-codes@npm:2.8.1" + checksum: 10/3608349037c567477693b1cce501f0186f606937a49cc75a190b87953f74e6116bec18e0be576c6f759b9533c10a79de639a6635c21a1827876e915389b9bf17 + languageName: node + linkType: hard + +"@module-federation/inject-external-runtime-core-plugin@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/inject-external-runtime-core-plugin@npm:2.8.1" + peerDependencies: + "@module-federation/runtime-tools": 2.8.1 + checksum: 10/eaa3b38207074644fdce467df406a6a19be598468f4980c89281c9f2cd269809a4a7ec1b14ab044d9d5c051d6f1c9d98b77718f3a5002f09f7cab416a83bfcec + languageName: node + linkType: hard + +"@module-federation/managers@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/managers@npm:2.8.1" + dependencies: + "@module-federation/sdk": "npm:2.8.1" + checksum: 10/3a6d00607c1b70c81c7e7d84574b670548594cd7df345db7077d80805e0a12a8ce1b54ddb2dc0285ac14da9b6a8b42279dfbbfaafcbbc02ca317f60d3c8bba28 + languageName: node + linkType: hard + +"@module-federation/manifest@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/manifest@npm:2.8.1" + dependencies: + "@module-federation/dts-plugin": "npm:2.8.1" + "@module-federation/managers": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + checksum: 10/4797f1c5b6f5ebb91d198b79d45910ebb211867bd993407b366d237cd0bd29426f2cf24b589bec66799629aa0c947722afe6dbe35030cd7c57f9d17e8278ec62 + languageName: node + linkType: hard + +"@module-federation/rspack@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/rspack@npm:2.8.1" + dependencies: + "@module-federation/bridge-react-webpack-plugin": "npm:2.8.1" + "@module-federation/dts-plugin": "npm:2.8.1" + "@module-federation/inject-external-runtime-core-plugin": "npm:2.8.1" + "@module-federation/managers": "npm:2.8.1" + "@module-federation/manifest": "npm:2.8.1" + "@module-federation/runtime-tools": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + peerDependencies: + "@rspack/core": ^0.7.0 || ^1.0.0 || ^2.0.0-0 + typescript: ^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vue-tsc: ">=1.0.24" + peerDependenciesMeta: + typescript: + optional: true + vue-tsc: + optional: true + checksum: 10/17468d735d2813e080ce16a5e6c7407b0ec7592452d275a7aae9437799254895deccdf7f84f0cc7908eefaf0313962c43ed8d20110e8833590319072e5216a12 + languageName: node + linkType: hard + "@module-federation/runtime-core@npm:0.22.0": version: 0.22.0 resolution: "@module-federation/runtime-core@npm:0.22.0" @@ -2975,6 +3114,16 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime-core@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/runtime-core@npm:2.8.1" + dependencies: + "@module-federation/error-codes": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + checksum: 10/3da5cf2a2f89cd091d422e55b9e94bb7447de848cd8b0b29aa4fa9a217ec5439d6915058070ff3b0d33ab7d580411672556a549016404b0c54a1abd33965305d + languageName: node + linkType: hard + "@module-federation/runtime-tools@npm:0.22.0": version: 0.22.0 resolution: "@module-federation/runtime-tools@npm:0.22.0" @@ -2985,6 +3134,16 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime-tools@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/runtime-tools@npm:2.8.1" + dependencies: + "@module-federation/runtime": "npm:2.8.1" + "@module-federation/webpack-bundler-runtime": "npm:2.8.1" + checksum: 10/5e0cc4fa41098364fead905daab38b36b79611eb575fe747a1629728fe934523e78ee75421858bace1e56c0c917637f81aaf3b5f7ffcf24ebbf88b60b068cdda + languageName: node + linkType: hard + "@module-federation/runtime@npm:0.22.0": version: 0.22.0 resolution: "@module-federation/runtime@npm:0.22.0" @@ -2996,6 +3155,17 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/runtime@npm:2.8.1" + dependencies: + "@module-federation/error-codes": "npm:2.8.1" + "@module-federation/runtime-core": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + checksum: 10/801db9408871c65cb3c48add5615a0bc4eba413b765019f9d70994d3d8ec93e21b2800c666bf6675ae5775eb68e7796e6d4adf5cb7caae282253b9631e839471 + languageName: node + linkType: hard + "@module-federation/sdk@npm:0.22.0": version: 0.22.0 resolution: "@module-federation/sdk@npm:0.22.0" @@ -3003,6 +3173,20 @@ __metadata: languageName: node linkType: hard +"@module-federation/sdk@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/sdk@npm:2.8.1" + checksum: 10/181d3f80ea5aebf5d99224531e56e0434ff3fc69548c96f17dc2dadcaad9c2c3143fea023c8427f09e8a4fc9c928f8e406e8c8b6fb922883ec4a929d9e6a9146 + languageName: node + linkType: hard + +"@module-federation/third-party-dts-extractor@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/third-party-dts-extractor@npm:2.8.1" + checksum: 10/387d524b42573d11fd93a5686a07977001e45f94c53e9bc4426da9195179752123a02526ca47d13ba1467db93c1ba98df55e80a9358ded22b5c4a25c321c53e5 + languageName: node + linkType: hard + "@module-federation/webpack-bundler-runtime@npm:0.22.0": version: 0.22.0 resolution: "@module-federation/webpack-bundler-runtime@npm:0.22.0" @@ -3013,6 +3197,17 @@ __metadata: languageName: node linkType: hard +"@module-federation/webpack-bundler-runtime@npm:2.8.1": + version: 2.8.1 + resolution: "@module-federation/webpack-bundler-runtime@npm:2.8.1" + dependencies: + "@module-federation/error-codes": "npm:2.8.1" + "@module-federation/runtime": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + checksum: 10/33ffd6e412aba1854e016b6efcad67d5af7b4ccb61749fdd2b19d1b19c679808f16e17d892ae5a0e6c79277b6a72678c85782d79e612e68e09a7049aa0261069 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:1.0.7": version: 1.0.7 resolution: "@napi-rs/wasm-runtime@npm:1.0.7" @@ -3181,28 +3376,31 @@ __metadata: languageName: node linkType: hard -"@openmrs/esm-api@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-api@npm:9.0.3-pre.4537" +"@openmrs/esm-api@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-api@npm:10.0.1-pre.5263" dependencies: lodash-es: "npm:^4.17.21" peerDependencies: - "@openmrs/esm-config": 9.x - "@openmrs/esm-error-handling": 9.x - "@openmrs/esm-globals": 9.x - "@openmrs/esm-navigation": 9.x - checksum: 10/d84d36c273e02c9859a023e84bde34ecb6958717db05c790b3d87b9fc3cf6bc1c5cd6cca6bbc6f3c7781bb9944a5dd417bf1118537c4301f9ccc3413fcc75f35 + "@openmrs/esm-config": ^10.0.1-pre.5263 + "@openmrs/esm-error-handling": ^10.0.1-pre.5263 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-navigation": ^10.0.1-pre.5263 + checksum: 10/c7c9b80e29f42a2ec91184b2324c852830d54573c5fd1e10f02fb96e98605ee0383a786fc3b599651ae5a50c0e955873fe09bf4032a0da5f104f503fd8da7327 languageName: node linkType: hard -"@openmrs/esm-app-shell@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-app-shell@npm:9.0.3-pre.4537" +"@openmrs/esm-app-shell@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-app-shell@npm:10.0.1-pre.5263" dependencies: "@carbon/react": "npm:^1.92.1" "@internationalized/date": "npm:^3.8.0" - "@openmrs/esm-framework": "npm:9.0.3-pre.4537" - "@openmrs/esm-styleguide": "npm:9.0.3-pre.4537" + "@module-federation/enhanced": "npm:2.8.1" + "@module-federation/error-codes": "npm:2.8.1" + "@module-federation/sdk": "npm:2.8.1" + "@openmrs/esm-framework": "npm:10.0.1-pre.5263" + "@openmrs/esm-styleguide": "npm:10.0.1-pre.5263" "@rspack/cli": "npm:1.7.9" "@rspack/core": "npm:1.7.9" dayjs: "npm:^1.11.13" @@ -3222,70 +3420,70 @@ __metadata: swc-loader: "npm:0.2.7" swr: "npm:2.2.5" webpack-pwa-manifest: "npm:4.3.0" - checksum: 10/f93dd64dd401cff771559d56549bbd2e18a56804783e79c2a292ddd6534ae162b0c952b360a88fcd8ecd70e2f11051118626c7d86eda894782f9de67e72367b2 + checksum: 10/e8cf048a0c3a9cc28a437ea4fb5652169f5797a0bb9236b182202eefad4bf61e463e0e6d9baf10a4927ed2c973b8123076acb0d1c97a474ebbb16eb38250a0fb languageName: node linkType: hard -"@openmrs/esm-config@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-config@npm:9.0.3-pre.4537" +"@openmrs/esm-config@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-config@npm:10.0.1-pre.5263" dependencies: lodash-es: "npm:^4.17.21" peerDependencies: - "@openmrs/esm-globals": 9.x - "@openmrs/esm-state": 9.x - "@openmrs/esm-utils": 9.x + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 + "@openmrs/esm-utils": ^10.0.1-pre.5263 single-spa: 6.x - checksum: 10/eedc931b31681166c10dc6c7e302bc68479e5cbfd6e0e7e4a996c160b15105e35f085313a6cd25907710a67a2b4d667af7fb211ed52184e05b4287fa2da72c27 + checksum: 10/374263344c587dede41e2feeea77e1e4e569242a0f48424a772b747a9a88e5d50413058f6ec822643ed201a4e5d39db4357137f3a46568c08c217c45f7bdaf9e languageName: node linkType: hard -"@openmrs/esm-context@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-context@npm:9.0.3-pre.4537" +"@openmrs/esm-context@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-context@npm:10.0.1-pre.5263" peerDependencies: - "@openmrs/esm-globals": 9.x - "@openmrs/esm-state": 9.x - checksum: 10/2a25282a4696ccc4d688126031cc444d4e5b9957214e903f2ea63188158effa5a75420334f7b215b13d518914ce54d4d28e0351d7d6b18f022f75f4a5d6c14f3 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 + checksum: 10/d616ccdf9db643f2f06cbbf4c0e1b0ba61f0b2237619a8e3accc660c6bf7c0fcc2d94d134aebe98e5da62101d2c26109ac06ff7d4e4dfc1e114a23f6f396ae96 languageName: node linkType: hard -"@openmrs/esm-dynamic-loading@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-dynamic-loading@npm:9.0.3-pre.4537" +"@openmrs/esm-dynamic-loading@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-dynamic-loading@npm:10.0.1-pre.5263" peerDependencies: - "@openmrs/esm-globals": 9.x - "@openmrs/esm-translations": 9.x - checksum: 10/d39d7bff6ed4cc7c3def2a5d7dc43f8d4b3b8b5d2d118811398868de583c37652f82ac404f5c76543248da9754bdacfab5dec60a55d24443e1a8bfa6454f3ed1 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-translations": ^10.0.1-pre.5263 + checksum: 10/f971781033a9b636e1f498c9fffe18027ba28270db1d8c53d4f8ecd85c7eaedbeb68af333011a84da298996ed40029a6e676c54b5da5cc72f470f37fbd69de1d languageName: node linkType: hard -"@openmrs/esm-emr-api@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-emr-api@npm:9.0.3-pre.4537" +"@openmrs/esm-emr-api@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-emr-api@npm:10.0.1-pre.5263" dependencies: "@types/fhir": "npm:0.0.31" lodash-es: "npm:^4.17.21" peerDependencies: - "@openmrs/esm-api": 9.x - "@openmrs/esm-offline": 9.x - "@openmrs/esm-state": 9.x - checksum: 10/4d8c5583f4d03a6bd9ed0e9a24e5bb2368a5ccd8444cd7c33eafee10675f15cfa936f8ca23385d117017718fd5045b837a44390c80884b9e0548d27a1c978f5b + "@openmrs/esm-api": ^10.0.1-pre.5263 + "@openmrs/esm-offline": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 + checksum: 10/fd371b2cffa9a9e692944966796775a4cff876e2511196c505911b8ec0ef8e40c468da5df10986d5382e1dbb561ca6f928614419db7b90f5407dfb577c611ac5 languageName: node linkType: hard -"@openmrs/esm-error-handling@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-error-handling@npm:9.0.3-pre.4537" +"@openmrs/esm-error-handling@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-error-handling@npm:10.0.1-pre.5263" peerDependencies: - "@openmrs/esm-globals": 9.x - checksum: 10/efb2119b575e2ab43992672b8ca53989468991564c09fd026b2866b8a4b36a4d0eb3072a6361e2ed6cb787da37a8f2e47c4043af1897688d83fb868faf580bf3 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + checksum: 10/c69c7f4e74c2bb7254bac649593b459b35ef1495a6f79b05a84ef85ed7b163846e941dae42f0af1b9c9c693942370f0ce1fbd82bc7810b66f9607e0c8a772dda languageName: node linkType: hard -"@openmrs/esm-expression-evaluator@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-expression-evaluator@npm:9.0.3-pre.4537" +"@openmrs/esm-expression-evaluator@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-expression-evaluator@npm:10.0.1-pre.5263" dependencies: "@jsep-plugin/arrow": "npm:^1.0.6" "@jsep-plugin/new": "npm:^1.0.4" @@ -3294,60 +3492,60 @@ __metadata: "@jsep-plugin/template": "npm:^1.0.5" "@jsep-plugin/ternary": "npm:^1.1.4" jsep: "npm:^1.4.0" - checksum: 10/226b5e7d1206df5dda6fd5b220972a5683f2289f9fe0589b2763195c37e253c60e9913785c5bc89e78a6ad2c4d14a8acc14619bb3b833698281a200664091929 + checksum: 10/0a1ed07461120b6ff92eb61ae4a9df1f4c756c4a2b826c690902327da932d2d28c99ba98a4764c5627d9fb6d9db0525876a1b6fee70b14f9ac9e9ed479c380e3 languageName: node linkType: hard -"@openmrs/esm-extensions@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-extensions@npm:9.0.3-pre.4537" +"@openmrs/esm-extensions@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-extensions@npm:10.0.1-pre.5263" dependencies: lodash-es: "npm:^4.17.21" peerDependencies: - "@openmrs/esm-api": 9.x - "@openmrs/esm-config": 9.x - "@openmrs/esm-expression-evaluator": 9.x - "@openmrs/esm-feature-flags": 9.x - "@openmrs/esm-state": 9.x - "@openmrs/esm-utils": 9.x + "@openmrs/esm-api": ^10.0.1-pre.5263 + "@openmrs/esm-config": ^10.0.1-pre.5263 + "@openmrs/esm-expression-evaluator": ^10.0.1-pre.5263 + "@openmrs/esm-feature-flags": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 + "@openmrs/esm-utils": ^10.0.1-pre.5263 single-spa: 6.x - checksum: 10/42a3e036a88aff85b12a28505fd14492b258be0d07064ed402ad1110a14968128f1756800348912e1c959e690a3d56236afbd43eb3c3041ca9a6aea0ef608a72 + checksum: 10/b511d73ae6aa74e17369b5bc76be848c587a6661987b67f14911db5f4d4e4585e2a384e54212397940941e0f72a0d7cfa75cdc2cd0f4288c09efa59dfd1550f5 languageName: node linkType: hard -"@openmrs/esm-feature-flags@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-feature-flags@npm:9.0.3-pre.4537" +"@openmrs/esm-feature-flags@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-feature-flags@npm:10.0.1-pre.5263" peerDependencies: - "@openmrs/esm-globals": 9.x - "@openmrs/esm-state": 9.x + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 single-spa: 6.x - checksum: 10/4926a621d49439a272e95b6a65638cb0ac507ddd27a4c5d20d8916b7767292c73ac55ded404899df1c8f487871977b4b935453aed56969614acf68a53d2b69ba - languageName: node - linkType: hard - -"@openmrs/esm-framework@npm:9.0.3-pre.4537, @openmrs/esm-framework@npm:next": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-framework@npm:9.0.3-pre.4537" - dependencies: - "@openmrs/esm-api": "npm:9.0.3-pre.4537" - "@openmrs/esm-config": "npm:9.0.3-pre.4537" - "@openmrs/esm-context": "npm:9.0.3-pre.4537" - "@openmrs/esm-dynamic-loading": "npm:9.0.3-pre.4537" - "@openmrs/esm-emr-api": "npm:9.0.3-pre.4537" - "@openmrs/esm-error-handling": "npm:9.0.3-pre.4537" - "@openmrs/esm-expression-evaluator": "npm:9.0.3-pre.4537" - "@openmrs/esm-extensions": "npm:9.0.3-pre.4537" - "@openmrs/esm-feature-flags": "npm:9.0.3-pre.4537" - "@openmrs/esm-globals": "npm:9.0.3-pre.4537" - "@openmrs/esm-navigation": "npm:9.0.3-pre.4537" - "@openmrs/esm-offline": "npm:9.0.3-pre.4537" - "@openmrs/esm-react-utils": "npm:9.0.3-pre.4537" - "@openmrs/esm-routes": "npm:9.0.3-pre.4537" - "@openmrs/esm-state": "npm:9.0.3-pre.4537" - "@openmrs/esm-styleguide": "npm:9.0.3-pre.4537" - "@openmrs/esm-translations": "npm:9.0.3-pre.4537" - "@openmrs/esm-utils": "npm:9.0.3-pre.4537" + checksum: 10/1c98a4c2e0651cd7c93ac955925455c6c809bc045d79172592ca89ff94c28462e1ed6768b6c3c28740a38e35c0de54e5f268388550e70f3df2e827b6cc906bf8 + languageName: node + linkType: hard + +"@openmrs/esm-framework@npm:10.0.1-pre.5263, @openmrs/esm-framework@npm:next": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-framework@npm:10.0.1-pre.5263" + dependencies: + "@openmrs/esm-api": "npm:10.0.1-pre.5263" + "@openmrs/esm-config": "npm:10.0.1-pre.5263" + "@openmrs/esm-context": "npm:10.0.1-pre.5263" + "@openmrs/esm-dynamic-loading": "npm:10.0.1-pre.5263" + "@openmrs/esm-emr-api": "npm:10.0.1-pre.5263" + "@openmrs/esm-error-handling": "npm:10.0.1-pre.5263" + "@openmrs/esm-expression-evaluator": "npm:10.0.1-pre.5263" + "@openmrs/esm-extensions": "npm:10.0.1-pre.5263" + "@openmrs/esm-feature-flags": "npm:10.0.1-pre.5263" + "@openmrs/esm-globals": "npm:10.0.1-pre.5263" + "@openmrs/esm-navigation": "npm:10.0.1-pre.5263" + "@openmrs/esm-offline": "npm:10.0.1-pre.5263" + "@openmrs/esm-react-utils": "npm:10.0.1-pre.5263" + "@openmrs/esm-routes": "npm:10.0.1-pre.5263" + "@openmrs/esm-state": "npm:10.0.1-pre.5263" + "@openmrs/esm-styleguide": "npm:10.0.1-pre.5263" + "@openmrs/esm-translations": "npm:10.0.1-pre.5263" + "@openmrs/esm-utils": "npm:10.0.1-pre.5263" peerDependencies: dayjs: 1.x i18next: 25.x @@ -3357,67 +3555,67 @@ __metadata: rxjs: 6.x single-spa: 6.x swr: 2.x - checksum: 10/79903a3f0a3b0df8af6fc07faa7f182894c65a2e5e566b894d74a34aa4b60c6e121eea303bf5190fb03b2b422a0cbe4c84a9f08b908392b51f35e33f1ab47eb2 + checksum: 10/5e5cfcc0ff6736749a423de0e342b070d44352f51b3ede595b9b18e84f5e05ce2c878a1e11aff6796fab87655efccccc53e4c193a3ed5093598ecf084070a8af languageName: node linkType: hard -"@openmrs/esm-globals@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-globals@npm:9.0.3-pre.4537" +"@openmrs/esm-globals@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-globals@npm:10.0.1-pre.5263" dependencies: "@types/fhir": "npm:0.0.31" peerDependencies: single-spa: 6.x - checksum: 10/9f70a194a2c7943e1848e5d2e62e72d353f54f78b3bb6971f07ff12f677ae989c24c6f853a75151b172f1626d483a24e87adc76b51d6278141125833d8c47e91 + checksum: 10/088fef4af28e88a783645ba2eb15525039b4dd13b4d77d014440707036659fd2884354d51177db70c39c3f6dc30129dca5478d0aace1d9ada688c122a17f873b languageName: node linkType: hard -"@openmrs/esm-navigation@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-navigation@npm:9.0.3-pre.4537" +"@openmrs/esm-navigation@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-navigation@npm:10.0.1-pre.5263" dependencies: path-to-regexp: "npm:^8.3.0" peerDependencies: - "@openmrs/esm-state": 9.x - checksum: 10/f0ca576ff0be1c8474cdff18eeac5d74aaa7dcfe3f91e97a279d6ea7cf7c303534fd6301e25ccde42a68dd822839feab8acb137b2970d07bfd40d58acedcbaf1 + "@openmrs/esm-state": ^10.0.1-pre.5263 + checksum: 10/24b86caa0e0edc614330dcaf51bad9acac9f532d34733879a8fdf36c5d45fcd15fb5dbd4cf22f285d7c6e2926d01bba2463c2024c33487ebb78105ce8c82ad8d languageName: node linkType: hard -"@openmrs/esm-offline@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-offline@npm:9.0.3-pre.4537" +"@openmrs/esm-offline@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-offline@npm:10.0.1-pre.5263" dependencies: dexie: "npm:^3.0.3" lodash-es: "npm:^4.17.21" uuid: "npm:^9.0.1" workbox-window: "npm:^6.1.5" peerDependencies: - "@openmrs/esm-api": 9.x - "@openmrs/esm-globals": 9.x - "@openmrs/esm-state": 9.x + "@openmrs/esm-api": ^10.0.1-pre.5263 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 rxjs: 6.x - checksum: 10/601b9db0bcc69ba9ffa55647d07ad7e1eeeb946118a6b75bdbcd8d7bbd0cf0937d8d92f2c0a4ee527d0fd8c3ef0fd102150b8a62656eb46f742819310c3b05ad + checksum: 10/28369e7d3387fc7ba537ee5273b7032e19e234804d69f95d94526abccaf022179881aa51f09daa0b71625e1287592ea4685697a041cd0f828415f03c4aa92467 languageName: node linkType: hard -"@openmrs/esm-react-utils@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-react-utils@npm:9.0.3-pre.4537" +"@openmrs/esm-react-utils@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-react-utils@npm:10.0.1-pre.5263" dependencies: lodash-es: "npm:^4.17.21" single-spa-react: "npm:^6.0.2" peerDependencies: - "@openmrs/esm-api": 9.x - "@openmrs/esm-config": 9.x - "@openmrs/esm-context": 9.x - "@openmrs/esm-emr-api": 9.x - "@openmrs/esm-error-handling": 9.x - "@openmrs/esm-extensions": 9.x - "@openmrs/esm-feature-flags": 9.x - "@openmrs/esm-globals": 9.x - "@openmrs/esm-navigation": 9.x - "@openmrs/esm-state": 9.x - "@openmrs/esm-utils": 9.x + "@openmrs/esm-api": ^10.0.1-pre.5263 + "@openmrs/esm-config": ^10.0.1-pre.5263 + "@openmrs/esm-context": ^10.0.1-pre.5263 + "@openmrs/esm-emr-api": ^10.0.1-pre.5263 + "@openmrs/esm-error-handling": ^10.0.1-pre.5263 + "@openmrs/esm-extensions": ^10.0.1-pre.5263 + "@openmrs/esm-feature-flags": ^10.0.1-pre.5263 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-navigation": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 + "@openmrs/esm-utils": ^10.0.1-pre.5263 dayjs: 1.x i18next: 25.x react: 18.x @@ -3425,40 +3623,40 @@ __metadata: react-i18next: 16.x rxjs: 6.x swr: 2.x - checksum: 10/68bdfa11970d63d207ee128b6ca86722c8c662406f8d062cbd1113b26adfff191caad8992a8435b8aa9dccf3d6338014b213cc23423755d5500659b42474cbc6 + checksum: 10/f8abb61d771b2f6cc1c769ed6b0f12dda45b398549c04c1aa60b1cd4e91de86f47bc709b36ee8b6b2495669b5e6239476463cf5d3e9526aa056176db641882d6 languageName: node linkType: hard -"@openmrs/esm-routes@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-routes@npm:9.0.3-pre.4537" +"@openmrs/esm-routes@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-routes@npm:10.0.1-pre.5263" peerDependencies: - "@openmrs/esm-config": 9.x - "@openmrs/esm-dynamic-loading": 9.x - "@openmrs/esm-extensions": 9.x - "@openmrs/esm-feature-flags": 9.x - "@openmrs/esm-globals": 9.x - "@openmrs/esm-utils": 9.x + "@openmrs/esm-config": ^10.0.1-pre.5263 + "@openmrs/esm-dynamic-loading": ^10.0.1-pre.5263 + "@openmrs/esm-extensions": ^10.0.1-pre.5263 + "@openmrs/esm-feature-flags": ^10.0.1-pre.5263 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-utils": ^10.0.1-pre.5263 single-spa: 6.x - checksum: 10/8184f80c120e45a45fc8a8ae5949d112e3f7a184004aa85362134e9560ecaa6c00a377e4749d0ac60fd9109bcc77432fe7d41156e202601ec46ab92b7f57b657 + checksum: 10/94506908d3f842433218f7cf8d937f88b963232de8ece575f59efaa6e5884081b305f45d1028c2a4de44d719287c0cf028650dd99a0bce6b2a6524ab8b8f9989 languageName: node linkType: hard -"@openmrs/esm-state@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-state@npm:9.0.3-pre.4537" +"@openmrs/esm-state@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-state@npm:10.0.1-pre.5263" dependencies: zustand: "npm:^4.5.5" peerDependencies: - "@openmrs/esm-globals": 9.x - "@openmrs/esm-utils": 9.x - checksum: 10/a375af05990e86786c96022c19bf023585093d2302156c09aeff9f4c5f175fe8db9ccd3501851480fa19910cec5147ba53a9466de63c31166f75e3be5b0aa20b + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-utils": ^10.0.1-pre.5263 + checksum: 10/46ff12464dc5afa1eb1a7b80a123e3fd434d25c7039d151768679a14fe4a8cc850d81ab31cc4a820f6b543171cb3ad0a0b632ee10fd2dc1a8c921219148ee5a8 languageName: node linkType: hard -"@openmrs/esm-styleguide@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-styleguide@npm:9.0.3-pre.4537" +"@openmrs/esm-styleguide@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-styleguide@npm:10.0.1-pre.5263" dependencies: "@carbon/charts": "npm:^1.27.0" "@carbon/react": "npm:^1.92.1" @@ -3469,18 +3667,18 @@ __metadata: lodash-es: "npm:^4.17.21" react-aria-components: "npm:^1.7.1" peerDependencies: - "@openmrs/esm-api": 9.x - "@openmrs/esm-config": 9.x - "@openmrs/esm-emr-api": 9.x - "@openmrs/esm-error-handling": 9.x - "@openmrs/esm-extensions": 9.x - "@openmrs/esm-globals": 9.x - "@openmrs/esm-navigation": 9.x - "@openmrs/esm-react-utils": 9.x - "@openmrs/esm-routes": 9.x - "@openmrs/esm-state": 9.x - "@openmrs/esm-translations": 9.x - "@openmrs/esm-utils": 9.x + "@openmrs/esm-api": ^10.0.1-pre.5263 + "@openmrs/esm-config": ^10.0.1-pre.5263 + "@openmrs/esm-emr-api": ^10.0.1-pre.5263 + "@openmrs/esm-error-handling": ^10.0.1-pre.5263 + "@openmrs/esm-extensions": ^10.0.1-pre.5263 + "@openmrs/esm-globals": ^10.0.1-pre.5263 + "@openmrs/esm-navigation": ^10.0.1-pre.5263 + "@openmrs/esm-react-utils": ^10.0.1-pre.5263 + "@openmrs/esm-routes": ^10.0.1-pre.5263 + "@openmrs/esm-state": ^10.0.1-pre.5263 + "@openmrs/esm-translations": ^10.0.1-pre.5263 + "@openmrs/esm-utils": ^10.0.1-pre.5263 dayjs: 1.x i18next: 25.x react: 18.x @@ -3488,24 +3686,24 @@ __metadata: react-i18next: 16.x rxjs: 6.x swr: 2.x - checksum: 10/74414fc8261c4712144d34d73473b619f77571c1788de0333727e150a5df6fbc368ae14fb518b2460ce00e250a5bb5d3005b297f919c6f6b17e1e377a2e19719 + checksum: 10/a7cb071064b0af63aa0f3953665fed1b5aac933cbb2390c1c0ede19548ae268fa985ee983628eafb482137ab3a91e43dee68f86468e725989a0619eee3b59cd3 languageName: node linkType: hard -"@openmrs/esm-translations@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-translations@npm:9.0.3-pre.4537" +"@openmrs/esm-translations@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-translations@npm:10.0.1-pre.5263" dependencies: i18next: "npm:^25.5.3" peerDependencies: i18next: 25.x - checksum: 10/5c656b25541be1a609b4ce88e411b3f87dbd79047ab8911918c3d7531c662d2f5f0b99f0ed490dacbc0543a5fd74b72a2f6018872ba2fff5e45b0537cbda819c + checksum: 10/7b9a912bee54e2d186a14a0504afc27c58bf79f51356e377a9e3318e7ea0d9674b7ae4b0c13083693b1c225838cce81f04e69a40070b9413191b483771aca2f8 languageName: node linkType: hard -"@openmrs/esm-utils@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/esm-utils@npm:9.0.3-pre.4537" +"@openmrs/esm-utils@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/esm-utils@npm:10.0.1-pre.5263" dependencies: "@formatjs/intl-durationformat": "npm:^0.7.3" "@internationalized/date": "npm:^3.8.0" @@ -3513,18 +3711,19 @@ __metadata: lodash-es: "npm:^4.17.21" semver: "npm:^7.7.3" peerDependencies: - "@openmrs/esm-globals": 9.x + "@openmrs/esm-globals": ^10.0.1-pre.5263 dayjs: 1.x i18next: 25.x rxjs: 6.x - checksum: 10/7d8aeea1b790693b94e3cea553129d5d845c3ec08d668a98ed9ccc2a36cebff8a630388ea6d3c759dba37e5f58816a4c7244dd9788bfb5cb754082fe6eb20ad3 + checksum: 10/f3d386c5f49fe2dc7175b14726467f6062c2172bc555f7baaf131f044cb47f91c8b2fb695d6bd6514bf81913d784825132c8dcaa72c0e4670f3a6d97f34ca2e0 languageName: node linkType: hard -"@openmrs/rspack-config@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/rspack-config@npm:9.0.3-pre.4537" +"@openmrs/rspack-config@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/rspack-config@npm:10.0.1-pre.5263" dependencies: + "@module-federation/enhanced": "npm:2.8.1" "@rspack/cli": "npm:1.7.9" "@rspack/core": "npm:1.7.9" "@swc/core": "npm:1.15.21" @@ -3534,19 +3733,21 @@ __metadata: lodash: "npm:4.17.21" sass-embedded: "npm:1.89.2" sass-loader: "npm:16.0.5" + semver: "npm:^7.7.3" style-loader: "npm:3.3.4" swc-loader: "npm:0.2.7" ts-checker-rspack-plugin: "npm:1.1.4" webpack-bundle-analyzer: "npm:4.10.2" webpack-stats-plugin: "npm:1.1.3" - checksum: 10/66aca1a0049ef232985923ccc00d8c25fca6f848a5b63a9813c9c09096d5fb86d4c9f88a60dc0c667a0388dbcaa06d631e36176dd3a38017e087764472973f43 + checksum: 10/3bef1d5f3e224a3e544bf16c272c42ef7f8d1c639d14e0f8f8057502176e00c79f6457fa327f2f52e7e034301608eda6523f8f494ed7f554950375b2714bd01a languageName: node linkType: hard -"@openmrs/webpack-config@npm:9.0.3-pre.4537": - version: 9.0.3-pre.4537 - resolution: "@openmrs/webpack-config@npm:9.0.3-pre.4537" +"@openmrs/webpack-config@npm:10.0.1-pre.5263": + version: 10.0.1-pre.5263 + resolution: "@openmrs/webpack-config@npm:10.0.1-pre.5263" dependencies: + "@module-federation/enhanced": "npm:2.8.1" "@swc/core": "npm:1.15.21" clean-webpack-plugin: "npm:4.0.0" copy-webpack-plugin: "npm:11.0.0" @@ -3555,13 +3756,14 @@ __metadata: lodash: "npm:4.17.21" sass-embedded: "npm:1.89.2" sass-loader: "npm:16.0.5" + semver: "npm:^7.7.3" style-loader: "npm:3.3.4" swc-loader: "npm:0.2.7" webpack: "npm:5.105.3" webpack-bundle-analyzer: "npm:4.10.2" webpack-cli: "npm:6.0.1" webpack-stats-plugin: "npm:1.1.3" - checksum: 10/226878dc2eee47a915f006a2ebd8a326d785b2ae11493c770748da12a752b9c21ba7b06e975f1650d6c67c43a29c43bdead9b3f7e1c369e07d3ae5505f320a80 + checksum: 10/9b2863783460f6ff55a9355c7dda7cb0e4958c31763206886bd1dfc8369e6e8494744ed76c0c5b418e7577a3a3b3e196e73e37f3ff5a909b86ff23dad6b02e5d languageName: node linkType: hard @@ -3710,6 +3912,22 @@ __metadata: languageName: node linkType: hard +"@pih/esm-audit-app@workspace:packages/esm-audit-app": + version: 0.0.0-use.local + resolution: "@pih/esm-audit-app@workspace:packages/esm-audit-app" + dependencies: + "@babel/runtime": "npm:^7.24.0" + openmrs: "npm:^10.0.1-pre.5263" + peerDependencies: + "@openmrs/esm-framework": 8.x + i18next: ^19.0.0 + react: 18.x + react-i18next: 16.x + react-router-dom: ^6.x + swr: 2.x + languageName: unknown + linkType: soft + "@pih/esm-commons-app@workspace:packages/esm-commons-app": version: 0.0.0-use.local resolution: "@pih/esm-commons-app@workspace:packages/esm-commons-app" @@ -7856,6 +8074,13 @@ __metadata: languageName: node linkType: hard +"adm-zip@npm:0.6.0": + version: 0.6.0 + resolution: "adm-zip@npm:0.6.0" + checksum: 10/6a49ab39055d8f716958d3a2055491ab86fe3359c4afeed9b14bbce78fb847bcd3cbaa0ff4921aaa19824aaa29d06691def1f53263db5224d06c068f922c5ba2 + languageName: node + linkType: hard + "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -9192,6 +9417,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:11.1.0": + version: 11.1.0 + resolution: "commander@npm:11.1.0" + checksum: 10/66bd2d8a0547f6cb1d34022efb25f348e433b0e04ad76a65279b1b09da108f59a4d3001ca539c60a7a46ea38bcf399fc17d91adad76a8cf43845d8dcbaf5cda1 + languageName: node + linkType: hard + "commander@npm:2, commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -13443,6 +13675,15 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: 10/e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -14076,6 +14317,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:2.4.2": + version: 2.4.2 + resolution: "jiti@npm:2.4.2" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10/e2b07eb2e3fbb245e29ad288dddecab31804967fc84d5e01d39858997d2743b5e248946defcecf99272275a00284ecaf7ec88b8c841331324f0c946d8274414b + languageName: node + linkType: hard + "jpeg-js@npm:^0.4.2": version: 0.4.4 resolution: "jpeg-js@npm:0.4.4" @@ -15575,14 +15825,14 @@ __metadata: languageName: node linkType: hard -"openmrs@npm:next": - version: 9.0.3-pre.4537 - resolution: "openmrs@npm:9.0.3-pre.4537" +"openmrs@npm:^10.0.1-pre.5263, openmrs@npm:next": + version: 10.0.1-pre.5263 + resolution: "openmrs@npm:10.0.1-pre.5263" dependencies: "@inquirer/prompts": "npm:8.3.2" - "@openmrs/esm-app-shell": "npm:9.0.3-pre.4537" - "@openmrs/rspack-config": "npm:9.0.3-pre.4537" - "@openmrs/webpack-config": "npm:9.0.3-pre.4537" + "@openmrs/esm-app-shell": "npm:10.0.1-pre.5263" + "@openmrs/rspack-config": "npm:10.0.1-pre.5263" + "@openmrs/webpack-config": "npm:10.0.1-pre.5263" "@pnpm/npm-conf": "npm:^3.0.2" "@rspack/cli": "npm:1.7.9" "@rspack/core": "npm:1.7.9" @@ -15621,7 +15871,7 @@ __metadata: openmrs: ./dist/cli.js rspack: ./bin/rspack.cjs webpack: ./bin/webpack.cjs - checksum: 10/e5dd60c4e67428e25c0aafef89de1c3d6d7d5f995cb05e1844385d46e5909d61b70655910d5bf9ec05c1315d3811f0903dfc433f32a6a4815ea3b64c29294363 + checksum: 10/19a4dbd8226c224349ba0e5334df9af4ef31b406a383770d35ada87bcf1946a2789440e41ff3f71bb1d8baa1af2fcf985c941b5ca82c5ac6c6cfd37130e2673f languageName: node linkType: hard @@ -17624,6 +17874,18 @@ __metadata: languageName: node linkType: hard +"schema-utils@npm:4.3.0": + version: 4.3.0 + resolution: "schema-utils@npm:4.3.0" + dependencies: + "@types/json-schema": "npm:^7.0.9" + ajv: "npm:^8.9.0" + ajv-formats: "npm:^2.1.1" + ajv-keywords: "npm:^5.1.0" + checksum: 10/86c5a7c72a275c56f140bc3cdd832d56efb11428c88ad588127db12cb9b2c83ccaa9540e115d7baa9c6175b5e360094457e29c44e6fb76787c9498c2eb6df5d6 + languageName: node + linkType: hard + "schema-utils@npm:^3.0.0": version: 3.3.0 resolution: "schema-utils@npm:3.3.0" @@ -18564,6 +18826,13 @@ __metadata: languageName: node linkType: hard +"tapable@npm:2.3.0, tapable@npm:^2.3.0": + version: 2.3.0 + resolution: "tapable@npm:2.3.0" + checksum: 10/496a841039960533bb6e44816a01fffc2a1eb428bb2051ecab9e87adf07f19e1f937566cbbbb09dceff31163c0ffd81baafcad84db900b601f0155dd0b37e9f2 + languageName: node + linkType: hard + "tapable@npm:^1.0.0": version: 1.1.3 resolution: "tapable@npm:1.1.3" @@ -18578,13 +18847,6 @@ __metadata: languageName: node linkType: hard -"tapable@npm:^2.3.0": - version: 2.3.0 - resolution: "tapable@npm:2.3.0" - checksum: 10/496a841039960533bb6e44816a01fffc2a1eb428bb2051ecab9e87adf07f19e1f937566cbbbb09dceff31163c0ffd81baafcad84db900b601f0155dd0b37e9f2 - languageName: node - linkType: hard - "tar@npm:7.5.13, tar@npm:^7.4.3, tar@npm:^7.5.4": version: 7.5.13 resolution: "tar@npm:7.5.13" @@ -19184,6 +19446,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:7.28.0": + version: 7.28.0 + resolution: "undici@npm:7.28.0" + checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d + languageName: node + linkType: hard + "undici@npm:^7.12.0": version: 7.16.0 resolution: "undici@npm:7.16.0" @@ -20045,6 +20314,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.21.0": + version: 8.21.0 + resolution: "ws@npm:8.21.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/088411956432c8f876158409d5a285cb9ad1382f593391f51d3a599bd0a5b277f876609ebd00fc3596321c4a4c9064d6fffe1ebad960e8ea7fd9ae25324f35c2 + languageName: node + linkType: hard + "ws@npm:^7.3.1": version: 7.5.9 resolution: "ws@npm:7.5.9" From 99baa64ca38f53e25fbc3bf05ab9dd9b8fdcf15d Mon Sep 17 00:00:00 2001 From: Cosmin Date: Wed, 2 Sep 2026 09:53:59 -0400 Subject: [PATCH 2/3] UHM-9498: modify verbiage --- packages/esm-audit-app/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/esm-audit-app/translations/en.json b/packages/esm-audit-app/translations/en.json index df35a0d..e866e72 100644 --- a/packages/esm-audit-app/translations/en.json +++ b/packages/esm-audit-app/translations/en.json @@ -70,7 +70,7 @@ "providerName": "Provider name", "providers": "Providers", "questionConcept": "Question concept", - "recordActivity": "Record activity", + "recordActivity": "Patient activity", "recordedBy": "Recorded by", "role": "Role", "searchForPatient": "Search for a patient by name or identifier", @@ -86,5 +86,5 @@ "visit": "Visit", "wasPreviously": "was {{value}}", "when": "When", - "whoTouchedThisRecord": "Who touched this record" + "whoTouchedThisRecord": "Who modified this patient record" } From 8c8b13f5b43edaaf0aa721cff1d04d7ac9005640 Mon Sep 17 00:00:00 2001 From: Cosmin Date: Wed, 2 Sep 2026 19:48:39 -0400 Subject: [PATCH 3/3] UHM-9498: remove the scan limit and read all encounters and obs when viewing patient activity --- packages/esm-audit-app/README.md | 27 +-- .../src/audit/audit.resource.test.tsx | 37 +++- .../esm-audit-app/src/audit/audit.resource.ts | 176 ++++++++++++------ .../audit/patient-activity.component.test.tsx | 35 +++- .../src/audit/patient-activity.component.tsx | 46 ++--- .../audit/patient-encounters.component.tsx | 14 +- .../audit/patient-record.component.test.tsx | 3 +- .../src/audit/patient-record.component.tsx | 2 +- packages/esm-audit-app/src/config-schema.ts | 9 - packages/esm-audit-app/translations/en.json | 3 +- packages/esm-audit-app/translations/es.json | 7 +- packages/esm-audit-app/translations/fr.json | 7 +- 12 files changed, 223 insertions(+), 143 deletions(-) diff --git a/packages/esm-audit-app/README.md b/packages/esm-audit-app/README.md index 4d25bae..3f97c6d 100644 --- a/packages/esm-audit-app/README.md +++ b/packages/esm-audit-app/README.md @@ -15,8 +15,8 @@ up the drill-down. 1. **Patient search** — find the patient by name or identifier (`GET /ws/rest/v1/patient?q=…`). 2. **Encounter list** — every encounter recorded for that patient, most recent first - (`GET /ws/rest/v1/encounter?patient=…&order=desc`), narrowable by encounter type and by the date - the encounter happened. The type dropdown offers only the types this patient's own encounters + (`GET /ws/rest/v1/encounter?patient=…&order=desc`, paged by the server through + `useOpenmrsPagination`), narrowable by encounter type and by the date the encounter happened. The type dropdown offers only the types this patient's own encounters use, read from a scan of their encounters with a rep that carries nothing but the type, so it never offers a type that would return nothing. Deleted encounters are hidden by default; see the caveat below. @@ -56,8 +56,9 @@ The REST API's encounter-by-patient search always excludes voided encounters and `includeAll`. Listing deleted encounters therefore falls back to the free-text encounter search, which does honour it, using the patient's preferred identifier as the search phrase; because that search matches any patient whose name or identifier contains the phrase, the results are narrowed -back down to the patient in hand and paginated on the client. A patient with no identifier at all -cannot have their deleted encounters listed, and the page says so. +back down to the patient in hand and paginated on the client — the one list that has to be read in +full rather than a page at a time. A patient with no identifier at all cannot have their deleted +encounters listed, and the page says so. That search also takes no filter parameters, so the encounter type and date range are applied by the server on the normal path (`encounterType`, `fromdate` and `todate`, which the API compares @@ -71,15 +72,20 @@ come from once deleted encounters are shown — otherwise a type used only by a would be missing from the dropdown. Both hooks build the same url, so SWR serves the second one from cache rather than reading it twice. -### Caveat: how far the activity scan reaches +### Caveat: what the activity view costs Encounters carry their own `auditInfo`, so who created, changed or deleted each one comes free with -the encounter list. Observations do not: they have to be read per encounter, because the +the encounter list. + +Observations do not. Each encounter's observations need their own request, because the obs-by-encounter search is the only one that honours `includeAll` and so the only one that can see -deleted observations. That is one request per encounter, so the activity view reads only the -`activityScanLimit` most recent encounters — five requests in flight at a time — and says plainly -when it covered part of the record. Filtering by type or date is how an auditor reaches further -back. +deleted observations. The view reads every encounter the filters match, which makes it complete but +means **one request per encounter** — five in flight at a time, with the running count shown while +it works. On a long record over a slow link that is a real wait, and filtering by encounter type or +date is the way to keep it short. + +This is a deliberate choice to favour completeness over speed while the view is new; a cap is the +obvious lever if it proves too slow in the field. ## Enabling the app @@ -108,4 +114,3 @@ for information about how to provide configuration files. | `patientSearchPageSize` | `10` | Patients per page of search results. | | `encountersPageSize` | `10` | Encounters per page of a patient's encounter list. | | `patientChartUrl` | `${openmrsSpaBase}/patient/${patientUuid}/chart` | Where the patient's name in the encounter audit links to. | -| `activityScanLimit` | `50` | How many encounters the activity view reads observations from. | diff --git a/packages/esm-audit-app/src/audit/audit.resource.test.tsx b/packages/esm-audit-app/src/audit/audit.resource.test.tsx index f0a8ee0..d73b1bb 100644 --- a/packages/esm-audit-app/src/audit/audit.resource.test.tsx +++ b/packages/esm-audit-app/src/audit/audit.resource.test.tsx @@ -1,5 +1,5 @@ import React, { type PropsWithChildren } from 'react'; -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { SWRConfig } from 'swr'; import { openmrsFetch } from '@openmrs/esm-framework'; import { type AuditEncounter, type AuditPatient } from '../types'; @@ -37,7 +37,7 @@ const wrapper = ({ children }: PropsWithChildren) => ( ); function renderUsePatientEncounters(includeDeleted: boolean, filters: EncounterFilters) { - return renderHook(() => usePatientEncounters(patient, includeDeleted, filters, 1, 10), { wrapper }); + return renderHook(() => usePatientEncounters(patient, includeDeleted, filters, 10), { wrapper }); } describe('usePatientEncounters', () => { @@ -53,7 +53,7 @@ describe('usePatientEncounters', () => { it('reuses one read of the deleted-encounter search across both hooks', async () => { const { result } = renderHook( () => ({ - encounters: usePatientEncounters(patient, true, {}, 1, 10), + encounters: usePatientEncounters(patient, true, {}, 10), types: usePatientEncounterTypes(patient, true), }), { wrapper }, @@ -64,7 +64,12 @@ describe('usePatientEncounters', () => { expect(mockOpenmrsFetch.mock.calls.filter(([url]) => (url as string).includes('/encounter?q=')).length).toBe(1); }); - it('has the server apply the filters when deleted encounters are excluded', async () => { + /** `useOpenmrsPagination` rebuilds the url through `URL`, so its params come back encoded. */ + function lastRequestedUrl() { + return decodeURIComponent(mockOpenmrsFetch.mock.calls.at(-1)?.[0] as string); + } + + it('has the server apply the filters, and page, when deleted encounters are excluded', async () => { const { result } = renderUsePatientEncounters(false, { encounterType: consultationType, fromDate: '2026-04-01', @@ -72,11 +77,25 @@ describe('usePatientEncounters', () => { }); await waitFor(() => expect(result.current.encounters).toHaveLength(1)); - expect(mockOpenmrsFetch).toHaveBeenCalledWith( - expect.stringContaining( - '&encounterType=type-consultation&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59', - ), + expect(lastRequestedUrl()).toContain( + '&encounterType=type-consultation&fromdate=2026-04-01T00:00:00&todate=2026-04-30T23:59:59', + ); + expect(lastRequestedUrl()).toContain('&limit=10&startIndex=0&totalCount=true'); + }); + + it('asks the server for the next page rather than reading the whole record', async () => { + mockOpenmrsFetch.mockImplementation( + () => Promise.resolve({ data: { results: [april], totalCount: 30 } }) as ReturnType, ); + const { result } = renderUsePatientEncounters(false, {}); + + await waitFor(() => expect(result.current.totalCount).toBe(30)); + expect(result.current.currentPage).toBe(1); + + act(() => result.current.goTo(3)); + + await waitFor(() => expect(result.current.currentPage).toBe(3)); + expect(lastRequestedUrl()).toContain('&limit=10&startIndex=20&totalCount=true'); }); it('applies the filters on the client when deleted encounters are included', async () => { @@ -97,7 +116,7 @@ describe('usePatientEncounters', () => { it('cannot list deleted encounters for a patient with no identifier', async () => { const { result } = renderHook( - () => usePatientEncounters({ uuid: 'patient-2', display: 'No Identifier' }, true, {}, 1, 10), + () => usePatientEncounters({ uuid: 'patient-2', display: 'No Identifier' }, true, {}, 10), { wrapper }, ); diff --git a/packages/esm-audit-app/src/audit/audit.resource.ts b/packages/esm-audit-app/src/audit/audit.resource.ts index 4404b6b..2858355 100644 --- a/packages/esm-audit-app/src/audit/audit.resource.ts +++ b/packages/esm-audit-app/src/audit/audit.resource.ts @@ -1,6 +1,12 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import useSWR from 'swr'; -import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { + type FetchResponse, + openmrsFetch, + restBaseUrl, + useOpenmrsFetchAll, + useOpenmrsPagination, +} from '@openmrs/esm-framework'; import { type AuditEncounter, type AuditObs, type AuditPatient, type ObsTreeNode, type PagedResponse } from '../types'; import { buildEncounterFilterQuery, @@ -47,14 +53,26 @@ const noFilters: EncounterFilters = {}; /** Just enough of an obs to say who touched it and when. */ const activityObsRep = 'custom:(uuid,voided,concept:(uuid,display),previousVersion:(uuid),auditInfo)'; -/** The REST API caps `limit` server-side, so bulk reads are fetched a page at a time. */ +/** + * The framework's paging hooks default to their own binding of `openmrsFetch`; naming ours keeps + * every request this module makes going through the one function, which is also what lets tests + * stand in for it. + */ +const restFetchOptions = { fetcher: openmrsFetch }; + +/** + * The page size asked for on bulk reads. Without it the server falls back to + * `webservices.rest.maxResultsDefault`, which is 50 by default and means more round trips. + */ const bulkPageSize = 100; const maxBulkPages = 25; /** - * Reads every page of a paginated REST endpoint. The `next` link the API returns is built from the - * server's configured URI prefix, which is not always reachable from the browser, so this walks - * `startIndex` itself instead of following the link. + * Reads every page of a paginated REST endpoint imperatively. + * + * `useOpenmrsFetchAll` covers this wherever the url is known at render time. The activity scan is + * the exception: it reads the observations of many encounters, and a hook cannot be called once + * per encounter, so that one loop needs a plain function. */ async function fetchAllPages(url: string): Promise> { const results: Array = []; @@ -150,7 +168,8 @@ export function canListDeletedEncounters(patient: AuditPatient | undefined): boo function deletedEncountersUrl(patient: AuditPatient | undefined): string | null { const searchPhrase = encounterSearchPhrase(patient); return patient && searchPhrase - ? `${restBaseUrl}/encounter?q=${encodeURIComponent(searchPhrase)}&includeAll=true&v=${encounterListRep}` + ? `${restBaseUrl}/encounter?q=${encodeURIComponent(searchPhrase)}&includeAll=true&v=${encounterListRep}` + + `&limit=${bulkPageSize}` : null; } @@ -172,13 +191,13 @@ export function useAllPatientEncounters( const scanUrl = patient && !includeDeleted ? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` + - `${buildEncounterFilterQuery(filters)}&order=desc` + `${buildEncounterFilterQuery(filters)}&order=desc&limit=${bulkPageSize}` : null; - const scanResult = useSWR>(scanUrl, fetchAllPages); - - const bulkResult = useSWR>( - includeDeleted ? deletedEncountersUrl(patient) : null, - fetchAllPages, + // `useOpenmrsFetchAll` takes a null url to mean "do not fetch", though its type does not say so. + const scanResult = useOpenmrsFetchAll(scanUrl as string, restFetchOptions); + const bulkResult = useOpenmrsFetchAll( + (includeDeleted ? deletedEncountersUrl(patient) : null) as string, + restFetchOptions, ); const encounters = useMemo(() => { @@ -210,59 +229,94 @@ export function usePatientEncounterTypes(patient: AuditPatient | undefined, incl } /** - * Lists a patient's encounters, optionally narrowed by encounter type and date range. + * One page of a patient's encounters, optionally narrowed by encounter type and date range, with + * the page to show and the callback to change it — whichever of the two paths below is in use. + */ +interface PatientEncountersResult { + encounters: Array; + totalCount: number; + currentPage: number; + goTo(page: number): void; + error: unknown; + isLoading: boolean; + /** True when the patient has no identifier to run the deleted-encounter search with. */ + cannotIncludeDeleted: boolean; +} + +/** + * Lists a patient's encounters, a page at a time. + * + * Live encounters are paged by the server: `useOpenmrsPagination` owns the page number and asks + * for one page at a time, so the size of a patient's record costs nothing to display. * - * The API's encounter-by-patient search always excludes voided encounters, so showing deleted - * encounters means falling back to the free-text encounter search, which does honour - * `includeAll`, with the patient's identifier as the search phrase. That search matches any - * patient whose name or identifier contains the phrase, so the results are narrowed back down to - * the patient in hand, and paginated on the client. It also takes no filter parameters, so the - * filters are applied on the client for that path and by the server for the other. + * Deleted encounters cannot be paged that way. The encounter-by-patient search always excludes + * voided encounters, so showing them means falling back to the free-text encounter search, which + * does honour `includeAll` but matches any patient whose name or identifier contains the phrase + * and takes no filter parameters. Its results therefore have to be read in full, narrowed to the + * patient in hand, filtered, and paged here — the one case where the whole list is fetched. */ export function usePatientEncounters( patient: AuditPatient | undefined, includeDeleted: boolean, filters: EncounterFilters, - page: number, pageSize: number, -) { - const startIndex = (page - 1) * pageSize; - +): PatientEncountersResult { const pagedUrl = patient && !includeDeleted ? `${restBaseUrl}/encounter?patient=${patient.uuid}&v=${encounterListRep}` + - `${buildEncounterFilterQuery(filters)}&order=desc&startIndex=${startIndex}&limit=${pageSize}&totalCount=true` + `${buildEncounterFilterQuery(filters)}&order=desc` : null; - const pagedResult = useSWR>>(pagedUrl, openmrsFetch); + // `useOpenmrsPagination` appends limit, startIndex and totalCount itself, so the url omits them. + const pagedResult = useOpenmrsPagination(pagedUrl as string, pageSize, restFetchOptions); - const bulkResult = useSWR>( - includeDeleted ? deletedEncountersUrl(patient) : null, - fetchAllPages, + const bulkResult = useOpenmrsFetchAll( + (includeDeleted ? deletedEncountersUrl(patient) : null) as string, + restFetchOptions, ); + const [clientPage, setClientPage] = useState(1); - const allEncounters = useMemo(() => { + const deletedEncounters = useMemo(() => { if (!includeDeleted) { - return null; + return []; } return encountersForPatient(bulkResult.data, patient) .filter((encounter) => matchesEncounterFilters(encounter, filters)) .sort((a, b) => (b.encounterDatetime ?? '').localeCompare(a.encounterDatetime ?? '')); }, [bulkResult.data, filters, includeDeleted, patient]); + /** + * A filter, the deleted toggle or a different patient changes which encounters exist, so the + * paging starts over. `goTo` refuses a page it considers out of bounds, so it is only called + * when there is somewhere to go back from. + */ + useEffect(() => { + setClientPage(1); + if (pagedResult.currentPage !== 1) { + pagedResult.goTo(1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters, includeDeleted, patient?.uuid]); + if (includeDeleted) { + const totalPages = Math.max(1, Math.ceil(deletedEncounters.length / pageSize)); + const currentPage = Math.min(clientPage, totalPages); return { - encounters: (allEncounters ?? []).slice(startIndex, startIndex + pageSize), - totalCount: allEncounters?.length ?? 0, + encounters: deletedEncounters.slice((currentPage - 1) * pageSize, currentPage * pageSize), + totalCount: deletedEncounters.length, + currentPage, + goTo: setClientPage, error: bulkResult.error, isLoading: bulkResult.isLoading, - /** True when the patient has no identifier to run the deleted-encounter search with. */ cannotIncludeDeleted: Boolean(patient) && !canListDeletedEncounters(patient), }; } return { - encounters: pagedResult.data?.data?.results ?? [], - totalCount: pagedResult.data?.data?.totalCount ?? pagedResult.data?.data?.results?.length ?? 0, + encounters: pagedResult.data ?? [], + // The hook reports NaN until the first page has been read. + totalCount: Number.isNaN(pagedResult.totalCount) ? 0 : pagedResult.totalCount, + currentPage: pagedResult.currentPage, + goTo: pagedResult.goTo, error: pagedResult.error, isLoading: pagedResult.isLoading, cannotIncludeDeleted: false, @@ -282,8 +336,10 @@ export function useEncounterAudit(encounterUuid: string | null): { const encounterUrl = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}?v=${encounterDetailRep}` : null; const encounterResult = useSWR>(encounterUrl, openmrsFetch); - const obsUrl = encounterUuid ? `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${obsRep}` : null; - const obsResult = useSWR>(obsUrl, fetchAllPages); + const obsUrl = encounterUuid + ? `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${obsRep}&limit=${bulkPageSize}` + : null; + const obsResult = useOpenmrsFetchAll(obsUrl as string, restFetchOptions); const encounter = encounterResult.data?.data; const obsTree = useMemo(() => buildObsTree(obsResult.data ?? [], encounter), [obsResult.data, encounter]); @@ -300,17 +356,19 @@ export function useEncounterAudit(encounterUuid: string | null): { * Who touched this patient's record: every creation, edit and deletion across their encounters, * summarised per user and listed in order. * - * The encounter rows carry their own `auditInfo`, but the observations do not come with them — - * they have to be read per encounter, since that is the only obs search that honours - * `includeAll` and so the only one that can see deleted observations. That is one request per - * encounter, so the scan is capped at the `scanLimit` most recent encounters and reports what it - * covered; narrowing by type or date is how an auditor reaches further back. + * Encounter rows carry their own `auditInfo`, and every matching encounter has already been read, + * so who created, changed and deleted encounters comes free. + * + * Observations do not: each encounter's observations need their own request, because the + * obs-by-encounter search is the only one that honours `includeAll` and so the only one that can + * see deleted observations. Every matching encounter is read all the same, so the view is complete + * — which means a patient with a long record costs one request per encounter, a few at a time. + * `scanProgress` is reported so the caller can show how far it has got. */ export function usePatientActivity( patient: AuditPatient | undefined, includeDeleted: boolean, filters: EncounterFilters, - scanLimit: number, ) { const { encounters, @@ -318,31 +376,33 @@ export function usePatientActivity( isLoading: isLoadingEncounters, } = useAllPatientEncounters(patient, includeDeleted, filters); - const scannedEncounters = useMemo(() => encounters.slice(0, scanLimit), [encounters, scanLimit]); - const scannedUuids = useMemo(() => scannedEncounters.map((encounter) => encounter.uuid), [scannedEncounters]); + const [encountersRead, setEncountersRead] = useState(0); + const encounterUuids = useMemo(() => encounters.map((encounter) => encounter.uuid), [encounters]); const obsResult = useSWR( - scannedUuids.length ? ['audit-activity-obs', scannedUuids] : null, + encounterUuids.length ? ['audit-activity-obs', encounterUuids] : null, async () => { - const perEncounter = await mapWithConcurrency(scannedUuids, scanConcurrency, (encounterUuid) => - fetchAllPages(`${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${activityObsRep}`), - ); - return Object.fromEntries(scannedUuids.map((encounterUuid, index) => [encounterUuid, perEncounter[index]])); + setEncountersRead(0); + let read = 0; + const perEncounter = await mapWithConcurrency(encounterUuids, scanConcurrency, async (encounterUuid) => { + const obs = await fetchAllPages( + `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=${activityObsRep}`, + ); + setEncountersRead((read += 1)); + return obs; + }); + return Object.fromEntries(encounterUuids.map((encounterUuid, index) => [encounterUuid, perEncounter[index]])); }, ); - const events = useMemo( - () => buildAuditEvents(scannedEncounters, obsResult.data ?? {}), - [obsResult.data, scannedEncounters], - ); + const events = useMemo(() => buildAuditEvents(encounters, obsResult.data ?? {}), [encounters, obsResult.data]); const userActivity = useMemo(() => summariseByUser(events), [events]); return { events, userActivity, - /** How many encounters were read, and how many matched — they differ when the cap bites. */ - scannedCount: scannedEncounters.length, - matchedCount: encounters.length, + /** How far the observation reads have got, for a view that has to wait on a long record. */ + scanProgress: { read: encountersRead, total: encounterUuids.length }, error: encountersError ?? obsResult.error, isLoading: isLoadingEncounters || obsResult.isLoading, }; diff --git a/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx b/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx index 42cb0a9..9d235d3 100644 --- a/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx +++ b/packages/esm-audit-app/src/audit/patient-activity.component.test.tsx @@ -78,14 +78,13 @@ function logRows() { describe('', () => { beforeEach(() => { - mockUseConfig.mockReturnValue({ activityScanLimit: 50 }); mockRestApi(); }); it('says who touched the record and what each of them did', async () => { renderPatientActivity(); - expect(await screen.findByText('Who touched this record')).toBeInTheDocument(); + expect(await screen.findByText('Who modified this patient record')).toBeInTheDocument(); const clerkRow = rowFor('Cos John'); expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('1'); @@ -144,8 +143,7 @@ describe('', () => { expect(onSelectEncounter).toHaveBeenCalledWith('enc-1'); }); - it('says so when it could only read part of the record', async () => { - mockUseConfig.mockReturnValue({ activityScanLimit: 1 }); + it("reads every encounter's observations, however many there are", async () => { mockRestApi({ encounterList: [ encounters[0], @@ -154,7 +152,34 @@ describe('', () => { }); renderPatientActivity(); - expect(await screen.findByText(/showing activity from the 1 most recent of 2 encounters/i)).toBeInTheDocument(); + await screen.findByText('Activity log'); + + // Both encounters are created by the clerk and both contribute their observations. + const clerkRow = rowFor('Cos John'); + expect(within(clerkRow).getAllByRole('cell')[1]).toHaveTextContent('2'); + expect(within(clerkRow).getAllByRole('cell')[2]).toHaveTextContent('2'); + expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/obs?encounter=enc-1')); + expect(mockOpenmrsFetch).toHaveBeenCalledWith(expect.stringContaining('/obs?encounter=enc-2')); + }); + + it('says how far the observation reads have got while they run', async () => { + let releaseObs: (value: unknown) => void = () => {}; + const obsGate = new Promise((resolve) => { + releaseObs = resolve; + }); + mockOpenmrsFetch.mockImplementation((url: string) => { + if (url.includes('/obs?encounter=')) { + return obsGate.then(() => ({ data: { results: obs } })) as ReturnType; + } + return Promise.resolve({ data: { results: encounters } }) as ReturnType; + }); + renderPatientActivity(); + + expect(await screen.findByText(/reading observations from 0 of 1 encounters/i)).toBeInTheDocument(); + + releaseObs(null); + + expect(await screen.findByText('Activity log')).toBeInTheDocument(); }); it('says when there is nothing to show', async () => { diff --git a/packages/esm-audit-app/src/audit/patient-activity.component.tsx b/packages/esm-audit-app/src/audit/patient-activity.component.tsx index 604c35c..b3a731b 100644 --- a/packages/esm-audit-app/src/audit/patient-activity.component.tsx +++ b/packages/esm-audit-app/src/audit/patient-activity.component.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DismissibleTag, - InlineNotification, + InlineLoading, Pagination, SkeletonText, Table, @@ -14,8 +14,7 @@ import { TableRow, Tag, } from '@carbon/react'; -import { ErrorState, useConfig } from '@openmrs/esm-framework'; -import { type Config } from '../config-schema'; +import { ErrorState } from '@openmrs/esm-framework'; import { type AuditPatient } from '../types'; import { formatAuditDatetime } from './audit-format'; import { usePatientActivity } from './audit.resource'; @@ -38,15 +37,9 @@ const eventsPerPage = 20; */ export default function PatientActivity({ patient, includeDeleted, filters, onSelectEncounter }: PatientActivityProps) { const { t } = useTranslation(); - const config = useConfig(); const [selectedUserUuid, setSelectedUserUuid] = useState(null); const [page, setPage] = useState(1); - const { events, userActivity, scannedCount, matchedCount, error, isLoading } = usePatientActivity( - patient, - includeDeleted, - filters, - config.activityScanLimit ?? 50, - ); + const { events, userActivity, scanProgress, error, isLoading } = usePatientActivity(patient, includeDeleted, filters); const selectedUser = useMemo( () => userActivity.find((activity) => activity.userUuid === selectedUserUuid), @@ -80,11 +73,23 @@ export default function PatientActivity({ patient, includeDeleted, filters, onSe ); if (error) { - return ; + return ; } if (isLoading) { - return ; + return ( + <> + {scanProgress.total > 0 ? ( + + ) : null} + + + ); } if (events.length === 0) { @@ -93,22 +98,7 @@ export default function PatientActivity({ patient, includeDeleted, filters, onSe return ( <> - {matchedCount > scannedCount ? ( - - ) : null} - -

{t('whoTouchedThisRecord', 'Who touched this record')}

+

{t('whoTouchedThisRecord', 'Who modified this patient record')}

diff --git a/packages/esm-audit-app/src/audit/patient-encounters.component.tsx b/packages/esm-audit-app/src/audit/patient-encounters.component.tsx index 07b78f0..f5f0389 100644 --- a/packages/esm-audit-app/src/audit/patient-encounters.component.tsx +++ b/packages/esm-audit-app/src/audit/patient-encounters.component.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DataTableSkeleton, @@ -49,22 +49,16 @@ export default function PatientEncounters({ }: PatientEncountersProps) { const { t } = useTranslation(); const config = useConfig(); - const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(config.encountersPageSize ?? 10); const pageSizes = useMemo(() => Array.from(new Set([pageSize, 10, 20, 50])).sort((a, b) => a - b), [pageSize]); - const { encounters, totalCount, error, isLoading } = usePatientEncounters( + const { encounters, totalCount, currentPage, goTo, error, isLoading } = usePatientEncounters( patient, includeDeleted, filters, - page, pageSize, ); - useEffect(() => { - setPage(1); - }, [filters, includeDeleted, patient?.uuid]); - if (error) { return ; } @@ -126,10 +120,10 @@ export default function PatientEncounters({ { - setPage(nextPage); setPageSize(nextPageSize); + goTo(nextPage); }} - page={page} + page={currentPage} pageSize={pageSize} pageSizes={pageSizes} size="sm" diff --git a/packages/esm-audit-app/src/audit/patient-record.component.test.tsx b/packages/esm-audit-app/src/audit/patient-record.component.test.tsx index a7793e7..7f52df9 100644 --- a/packages/esm-audit-app/src/audit/patient-record.component.test.tsx +++ b/packages/esm-audit-app/src/audit/patient-record.component.test.tsx @@ -94,7 +94,6 @@ function renderPatientRecord() { describe('', () => { beforeEach(() => { mockUseConfig.mockReturnValue({ - activityScanLimit: 50, patientChartUrl: '${openmrsSpaBase}/patient/${patientUuid}/chart', }); mockRestApi(); @@ -187,7 +186,7 @@ describe('', () => { const { onSelectView } = renderPatientRecord(); await screen.findByRole('cell', { name: 'Oncology Consultation' }); - await userEvent.click(screen.getByRole('tab', { name: /record activity/i })); + await userEvent.click(screen.getByRole('tab', { name: /patient activity/i })); expect(onSelectView).toHaveBeenCalledWith('activity'); }); diff --git a/packages/esm-audit-app/src/audit/patient-record.component.tsx b/packages/esm-audit-app/src/audit/patient-record.component.tsx index 8522890..0dd3f7b 100644 --- a/packages/esm-audit-app/src/audit/patient-record.component.tsx +++ b/packages/esm-audit-app/src/audit/patient-record.component.tsx @@ -87,7 +87,7 @@ export default function PatientRecord({ selectedIndex={Math.max(0, patientRecordViews.indexOf(view))}> {t('encounters', 'Encounters')} - {t('recordActivity', 'Record activity')} + {t('recordActivity', 'Patient activity')} diff --git a/packages/esm-audit-app/src/config-schema.ts b/packages/esm-audit-app/src/config-schema.ts index dd83942..dcacddd 100644 --- a/packages/esm-audit-app/src/config-schema.ts +++ b/packages/esm-audit-app/src/config-schema.ts @@ -19,14 +19,6 @@ export const configSchema = { _default: 10, _validators: [validators.inRange(1, 100)], }, - activityScanLimit: { - _type: Type.Number, - _description: - "How many of a patient's most recent encounters the record activity view reads observations " + - 'from. Each encounter costs one request, so this bounds how much work a scan can do.', - _default: 50, - _validators: [validators.inRange(1, 200)], - }, patientChartUrl: { _type: Type.String, _description: @@ -38,7 +30,6 @@ export const configSchema = { }; export interface Config { - activityScanLimit: number; patientSearchPageSize: number; encountersPageSize: number; patientChartUrl: string; diff --git a/packages/esm-audit-app/translations/en.json b/packages/esm-audit-app/translations/en.json index e866e72..0ba0b02 100644 --- a/packages/esm-audit-app/translations/en.json +++ b/packages/esm-audit-app/translations/en.json @@ -1,7 +1,6 @@ { "action": "Action", "activityLog": "Activity log", - "activityScanCapped": "Showing activity from the {{scannedCount}} most recent of {{matchedCount}} encounters. Narrow by encounter type or date to look further back.", "addedLater": "Added later", "addedLaterLegend": "recorded after the encounter itself was created", "age": "Age", @@ -60,7 +59,6 @@ "observations": "Observations", "obsRecordedAction": "Recorded observation", "obsRecordedCount": "Observations recorded", - "partialScan": "Part of the record", "patient": "Patient", "patientName": "Patient name", "patientSearchResults": "Patient search results", @@ -70,6 +68,7 @@ "providerName": "Provider name", "providers": "Providers", "questionConcept": "Question concept", + "readingObservations": "Reading observations from {{read}} of {{total}} encounters…", "recordActivity": "Patient activity", "recordedBy": "Recorded by", "role": "Role", diff --git a/packages/esm-audit-app/translations/es.json b/packages/esm-audit-app/translations/es.json index 9cf5240..8e76db8 100644 --- a/packages/esm-audit-app/translations/es.json +++ b/packages/esm-audit-app/translations/es.json @@ -1,7 +1,6 @@ { "action": "Acción", "activityLog": "Registro de actividad", - "activityScanCapped": "Mostrando la actividad de los {{scannedCount}} encuentros más recientes de {{matchedCount}}. Filtre por tipo de encuentro o por fecha para llegar más atrás.", "addedLater": "Añadido posteriormente", "addedLaterLegend": "registrado después de crearse el encuentro", "age": "Edad", @@ -60,7 +59,6 @@ "observations": "Observaciones", "obsRecordedAction": "Observación registrada", "obsRecordedCount": "Observaciones registradas", - "partialScan": "Parte del historial", "patient": "Paciente", "patientName": "Nombre del paciente", "patientSearchResults": "Resultados de la búsqueda de pacientes", @@ -70,7 +68,8 @@ "providerName": "Nombre del proveedor", "providers": "Proveedores", "questionConcept": "Concepto de la pregunta", - "recordActivity": "Actividad del historial", + "readingObservations": "Leyendo las observaciones de {{read}} de {{total}} encuentros…", + "recordActivity": "Actividad del paciente", "recordedBy": "Registrado por", "role": "Rol", "searchForPatient": "Buscar un paciente por nombre o identificador", @@ -86,5 +85,5 @@ "visit": "Visita", "wasPreviously": "era {{value}}", "when": "Cuándo", - "whoTouchedThisRecord": "Quién ha tocado este historial" + "whoTouchedThisRecord": "Quién ha modificado este historial del paciente" } diff --git a/packages/esm-audit-app/translations/fr.json b/packages/esm-audit-app/translations/fr.json index 567e0a1..c5310fc 100644 --- a/packages/esm-audit-app/translations/fr.json +++ b/packages/esm-audit-app/translations/fr.json @@ -1,7 +1,6 @@ { "action": "Action", "activityLog": "Journal d'activité", - "activityScanCapped": "Affichage de l'activité des {{scannedCount}} rencontres les plus récentes sur {{matchedCount}}. Filtrez par type de rencontre ou par date pour remonter plus loin.", "addedLater": "Ajouté ultérieurement", "addedLaterLegend": "enregistré après la création de la rencontre", "age": "Âge", @@ -60,7 +59,6 @@ "observations": "Observations", "obsRecordedAction": "Observation enregistrée", "obsRecordedCount": "Observations enregistrées", - "partialScan": "Une partie du dossier", "patient": "Patient", "patientName": "Nom du patient", "patientSearchResults": "Résultats de la recherche de patients", @@ -70,7 +68,8 @@ "providerName": "Nom du prestataire de service", "providers": "Prestataires de service", "questionConcept": "Concept de la question", - "recordActivity": "Activité du dossier", + "readingObservations": "Lecture des observations de {{read}} rencontres sur {{total}}…", + "recordActivity": "Activité du patient", "recordedBy": "Enregistré par", "role": "Rôle", "searchForPatient": "Rechercher un patient par nom ou numéro d'identification", @@ -86,5 +85,5 @@ "visit": "Visite", "wasPreviously": "était {{value}}", "when": "Quand", - "whoTouchedThisRecord": "Qui a touché à ce dossier" + "whoTouchedThisRecord": "Qui a modifié ce dossier patient" }