diff --git a/e2e/specs/results-viewer.spec.ts b/e2e/specs/results-viewer.spec.ts index 839aa1f38a..cf69581e32 100644 --- a/e2e/specs/results-viewer.spec.ts +++ b/e2e/specs/results-viewer.spec.ts @@ -264,21 +264,23 @@ test('Record and edit test results', async ({ page }) => { await resultsViewerPage.goTo(patient.uuid); }); - await test.step('And I click on the `Panel` tab', async () => { - await page.getByRole('tab', { name: /panel/i }).click(); + await test.step('And I click on the `Individual tests` tab', async () => { + await page.getByRole('tab', { name: /individual tests/i }).click(); }); await test.step('Then I should see the newly entered test results reflect in the results viewer', async () => { - for (const { resultsPageReference, value } of completeBloodCountData) { - await test.step(resultsPageReference, async () => { - const row = page.locator(`tr:has-text("${resultsPageReference}")`); - const valueCell = row.locator('td:nth-child(2)'); - await expect(valueCell).toContainText(value); - }); - } + await test.step('Then I should see the newly entered test results reflect in the results viewer', async () => { + for (const { resultsPageReference, value } of completeBloodCountData) { + await test.step(resultsPageReference, async () => { + const row = page.locator(`tr:has-text("${resultsPageReference}"):has(td:has-text("${value}"))`).first(); + const valueCell = row.locator('td:nth-child(2)'); + await expect(valueCell).toContainText(value); + }); + } + }); for (const { resultsPageReference, value } of chemistryResultsData) { await test.step(resultsPageReference, async () => { - const row = page.locator(`tr:has-text("${resultsPageReference}")`); + const row = page.locator(`tr:has-text("${resultsPageReference}"):has(td:has-text("${value}"))`).first(); const valueCell = row.locator('td:nth-child(2)'); await expect(valueCell).toContainText(value); }); @@ -338,21 +340,21 @@ test('Record and edit test results', async ({ page }) => { await resultsViewerPage.goTo(patient.uuid); }); - await test.step('And I click on the `Panel` tab', async () => { - await page.getByRole('tab', { name: /panel/i }).click(); + await test.step('And I click on the `Individual tests` tab', async () => { + await page.getByRole('tab', { name: /individual tests/i }).click(); }); await test.step('Then I should see the updated results reflect in the results viewer', async () => { for (const { resultsPageReference, updatedValue } of completeBloodCountData) { await test.step(resultsPageReference, async () => { - const row = page.locator(`tr:has-text("${resultsPageReference}")`); + const row = page.locator(`tr:has-text("${resultsPageReference}"):has(td:has-text("${updatedValue}"))`).first(); const valueCell = row.locator('td:nth-child(2)'); await expect(valueCell).toContainText(updatedValue); }); } for (const { resultsPageReference, updatedValue } of chemistryResultsData) { await test.step(resultsPageReference, async () => { - const row = page.locator(`tr:has-text("${resultsPageReference}")`); + const row = page.locator(`tr:has-text("${resultsPageReference}"):has(td:has-text("${updatedValue}"))`).first(); const valueCell = row.locator('td:nth-child(2)'); await expect(valueCell).toContainText(updatedValue); }); diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx b/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx index add7e38d39..a6d9ef3eab 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx +++ b/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx @@ -59,7 +59,7 @@ import { useMutateAppointments } from '../hooks/useMutateAppointments'; import { useOfflineVisitType } from '../hooks/useOfflineVisitType'; import { useVisitAttributeTypes } from '../hooks/useVisitAttributeType'; import { useVisitQueueEntry } from '../queue-entry/queue.resource'; -import { useVisits } from '../visits-widget/visit.resource'; +import { useInfiniteVisits, useVisits } from '../visits-widget/visit.resource'; import BaseVisitType from './base-visit-type.component'; import LocationSelector from './location-selector.component'; import VisitAttributeTypeFields from './visit-attribute-type.component'; @@ -97,6 +97,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({ const { activePatientEnrollment, isLoading } = useActivePatientEnrollment(patientUuid); const { mutate: mutateCurrentVisit } = useVisit(patientUuid); const { mutateVisits } = useVisits(patientUuid); + const { mutateVisits: mutateInfiniteVisits } = useInfiniteVisits(patientUuid); const { mutateAppointments } = useMutateAppointments(); const allVisitTypes = useConditionalVisitTypes(); @@ -482,6 +483,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({ if (status === 201) { mutateCurrentVisit(); mutateVisits(); + mutateInfiniteVisits(); mutateQueueEntry(); showSnackbar({ kind: 'success', @@ -506,6 +508,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({ () => { mutateCurrentVisit(); mutateVisits(); + mutateInfiniteVisits(); mutateAppointments(); showSnackbar({ isLowContrast: true, @@ -537,6 +540,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({ if (!attributesResponses.includes(undefined)) { mutateCurrentVisit(); mutateVisits(); + mutateInfiniteVisits(); closeWorkspace({ ignoreChanges: true }); showSnackbar({ isLowContrast: true, @@ -623,6 +627,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({ mutateCurrentVisit, mutateQueueEntry, mutateVisits, + mutateInfiniteVisits, patientUuid, priority, queueLocation, diff --git a/packages/esm-patient-chart-app/src/visit/visits-widget/past-visits-components/visits-table/visits-table.component.tsx b/packages/esm-patient-chart-app/src/visit/visits-widget/past-visits-components/visits-table/visits-table.component.tsx index bfb97d1b47..140df9f298 100644 --- a/packages/esm-patient-chart-app/src/visit/visits-widget/past-visits-components/visits-table/visits-table.component.tsx +++ b/packages/esm-patient-chart-app/src/visit/visits-widget/past-visits-components/visits-table/visits-table.component.tsx @@ -37,8 +37,12 @@ import { useSession, userHasAccess, } from '@openmrs/esm-framework'; -import { EmptyState, PatientChartPagination, launchFormEntryOrHtmlForms } from '@openmrs/esm-patient-common-lib'; -import type { HtmlFormEntryForm } from '@openmrs/esm-patient-forms-app/src/config-schema'; +import { + type HtmlFormEntryForm, + EmptyState, + PatientChartPagination, + launchFormEntryOrHtmlForms, +} from '@openmrs/esm-patient-common-lib'; import { deleteEncounter } from './visits-table.resource'; import { type MappedEncounter } from '../../visit.resource'; import EncounterObservations from '../../encounter-observations'; diff --git a/packages/esm-patient-chart-app/src/visit/visits-widget/visit.resource.tsx b/packages/esm-patient-chart-app/src/visit/visits-widget/visit.resource.tsx index 38a80b5976..1b636fce50 100644 --- a/packages/esm-patient-chart-app/src/visit/visits-widget/visit.resource.tsx +++ b/packages/esm-patient-chart-app/src/visit/visits-widget/visit.resource.tsx @@ -38,7 +38,7 @@ export function useInfiniteVisits(patientUuid: string) { ); return { - visits: data ? [].concat(data?.flatMap((page) => page.data.results)) : null, + visits: data ? [].concat(data?.flatMap((page) => page?.data?.results)) : null, error, hasMore: data?.length ? !!data[data.length - 1].data?.links?.some((link) => link.rel === 'next') : false, isLoading, diff --git a/packages/esm-patient-common-lib/src/form-entry/form-entry.ts b/packages/esm-patient-common-lib/src/form-entry/form-entry.ts index 0c918c0c59..4344381ef2 100644 --- a/packages/esm-patient-common-lib/src/form-entry/form-entry.ts +++ b/packages/esm-patient-common-lib/src/form-entry/form-entry.ts @@ -1,4 +1,4 @@ -import { type HtmlFormEntryForm } from '@openmrs/esm-patient-forms-app/src/config-schema'; +import { type HtmlFormEntryForm } from '../types'; export interface FormEntryProps { encounterUuid?: string; diff --git a/packages/esm-patient-common-lib/src/orders/types.ts b/packages/esm-patient-common-lib/src/orders/types.ts index 96911d6ff2..81478cd166 100644 --- a/packages/esm-patient-common-lib/src/orders/types.ts +++ b/packages/esm-patient-common-lib/src/orders/types.ts @@ -39,6 +39,10 @@ export interface OrderBasketItem { }; extractedOrderError?: ExtractedOrderErrorObject; isOrderIncomplete?: boolean; + /** + * An optional identifier from the fulfiller (e.g., lab system) for the specimen or record associated with the order. + */ + accessionNumber?: string; } export interface OrderPost { @@ -68,7 +72,7 @@ export interface OrderPost { orderReasonNonCoded?: string; orderReason?: string; instructions?: string; - labReferenceNumber?: string; + accessionNumber?: string; } export interface PatientOrderFetchResponse { @@ -146,7 +150,6 @@ export interface Order { clinicalHistory: string; numberOfRepeats: string; type: string; - labReferenceNumber?: string; } export interface OrderTypeFetchResponse { @@ -204,7 +207,6 @@ export interface LabOrderBasketItem extends OrderBasketItem { label: string; conceptUuid: string; }; - labReferenceNumber?: string; urgency?: string; instructions?: string; previousOrder?: string; diff --git a/packages/esm-patient-common-lib/src/types/index.ts b/packages/esm-patient-common-lib/src/types/index.ts index 38c6a9ff36..7ef7db6a5a 100644 --- a/packages/esm-patient-common-lib/src/types/index.ts +++ b/packages/esm-patient-common-lib/src/types/index.ts @@ -1,5 +1,3 @@ -import { type OpenmrsResource } from '@openmrs/esm-framework'; - export * from './test-results'; export interface DashboardLinkConfig { @@ -49,3 +47,11 @@ export interface DisplayMetadata { links: Links; uuid: string; } + +export interface HtmlFormEntryForm { + formUuid: string; + formName: string; + formUiResource: string; + formUiPage: 'enterHtmlFormWithSimpleUi' | 'enterHtmlFormWithStandardUi'; + formEditUiPage: 'editHtmlFormWithSimpleUi' | 'editHtmlFormWithStandardUi'; +} diff --git a/packages/esm-patient-forms-app/src/config-schema.ts b/packages/esm-patient-forms-app/src/config-schema.ts index 655b82f343..00732e2ac0 100644 --- a/packages/esm-patient-forms-app/src/config-schema.ts +++ b/packages/esm-patient-forms-app/src/config-schema.ts @@ -1,4 +1,5 @@ import { validator, Type } from '@openmrs/esm-framework'; +import { type HtmlFormEntryForm } from '@openmrs/esm-patient-common-lib'; export const configSchema = { htmlFormEntryForms: { @@ -133,14 +134,6 @@ export const configSchema = { }, }; -export interface HtmlFormEntryForm { - formUuid: string; - formName: string; - formUiResource: string; - formUiPage: 'enterHtmlFormWithSimpleUi' | 'enterHtmlFormWithStandardUi'; - formEditUiPage: 'editHtmlFormWithSimpleUi' | 'editHtmlFormWithStandardUi'; -} - export interface FormsSection { name: string; forms: Array<string>; diff --git a/packages/esm-patient-forms-app/src/form-entry-interop.ts b/packages/esm-patient-forms-app/src/form-entry-interop.ts index c8a1d2cd2f..db65784042 100644 --- a/packages/esm-patient-forms-app/src/form-entry-interop.ts +++ b/packages/esm-patient-forms-app/src/form-entry-interop.ts @@ -1,7 +1,10 @@ import { navigate, type Visit } from '@openmrs/esm-framework'; -import { type HtmlFormEntryForm } from './config-schema'; -import isEmpty from 'lodash-es/isEmpty'; -import { launchPatientWorkspace, launchStartVisitPrompt } from '@openmrs/esm-patient-common-lib'; +import { + type HtmlFormEntryForm, + launchPatientWorkspace, + launchStartVisitPrompt, +} from '@openmrs/esm-patient-common-lib'; +import { isEmpty } from 'lodash-es'; export function launchFormEntryOrHtmlForms( currentVisit: Visit | undefined, diff --git a/packages/esm-patient-forms-app/src/offline-forms/offline-form-helpers.ts b/packages/esm-patient-forms-app/src/offline-forms/offline-form-helpers.ts index 463983bed2..935513869b 100644 --- a/packages/esm-patient-forms-app/src/offline-forms/offline-form-helpers.ts +++ b/packages/esm-patient-forms-app/src/offline-forms/offline-form-helpers.ts @@ -1,7 +1,7 @@ import useSWR from 'swr'; import { getDynamicOfflineDataEntries } from '@openmrs/esm-framework'; +import { type HtmlFormEntryForm } from '@openmrs/esm-patient-common-lib'; import { type Form, type FormEncounterResource } from '../types'; -import { type HtmlFormEntryForm } from '../config-schema'; /** * Returns whether the given form encounter is valid for offline mode and can be cached. diff --git a/packages/esm-patient-labs-app/src/config-schema.ts b/packages/esm-patient-labs-app/src/config-schema.ts index 445a49de7f..f851627b9d 100644 --- a/packages/esm-patient-labs-app/src/config-schema.ts +++ b/packages/esm-patient-labs-app/src/config-schema.ts @@ -32,12 +32,6 @@ export const configSchema = { }, ], }, - showPrintButton: { - _type: Type.Boolean, - _default: true, - _description: - 'Whether or not to display the print button in the Test Results dashboard. When set to `true`, a print button is shown alongside the panel and tree view content switcher. When clicked, a modal pops up showing a datatable with the available test results. Once the user selects an appropriate date range, they can click on the print button in the modal to print the data', - }, orders: { labOrderTypeUuid: { _type: Type.UUID, @@ -97,7 +91,6 @@ export interface OrderReason { } export interface ConfigObject { resultsViewerConcepts: Array<ObsTreeEntry>; - showPrintButton: boolean; orders: { labOrderTypeUuid: string; labOrderableConcepts: Array<string>; diff --git a/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.scss b/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.scss index 66ed160487..bf2fc693c8 100644 --- a/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.scss +++ b/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.scss @@ -28,8 +28,7 @@ button { display: flex; - padding-left: 0 !important; - margin: 0 layout.$spacing-05; + margin-left: -(layout.$spacing-03); svg { order: 1; diff --git a/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.test.tsx b/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.test.tsx index b4ba8c3da6..db9e2bd2e5 100644 --- a/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.test.tsx +++ b/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/add-lab-order.test.tsx @@ -139,7 +139,7 @@ describe('AddLabOrder', () => { display: 'CD4 COUNT', urgency: 'STAT', instructions: 'plz do it thx', - labReferenceNumber: 'lba-000124', + accessionNumber: 'lba-000124', testType: { label: 'CD4 COUNT', conceptUuid: 'test-lab-uuid-2' }, orderer: mockSessionDataResponse.data.currentProvider.uuid, }), diff --git a/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/lab-order-form.component.tsx b/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/lab-order-form.component.tsx index 5a50965d23..a1a057c5f2 100644 --- a/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/lab-order-form.component.tsx +++ b/packages/esm-patient-labs-app/src/lab-orders/add-lab-order/lab-order-form.component.tsx @@ -62,7 +62,7 @@ export function LabOrderForm({ urgency: z.string().refine((value) => value !== '', { message: translateFrom(moduleName, 'addLabOrderPriorityRequired', 'Priority is required'), }), - labReferenceNumber: z.string().optional(), + accessionNumber: z.string().optional(), testType: z.object( { label: z.string(), conceptUuid: z.string() }, { @@ -182,7 +182,7 @@ export function LabOrderForm({ <Column lg={16} md={8} sm={4}> <InputWrapper> <Controller - name="labReferenceNumber" + name="accessionNumber" control={control} render={({ field: { onChange, onBlur, value } }) => ( <TextInput @@ -193,8 +193,8 @@ export function LabOrderForm({ value={value} onChange={onChange} onBlur={onBlur} - invalid={errors.labReferenceNumber?.message} - invalidText={errors.labReferenceNumber?.message} + invalid={errors.accessionNumber?.message} + invalidText={errors.accessionNumber?.message} /> )} /> diff --git a/packages/esm-patient-labs-app/src/lab-orders/api.ts b/packages/esm-patient-labs-app/src/lab-orders/api.ts index 95e3996c93..391c6d9f36 100644 --- a/packages/esm-patient-labs-app/src/lab-orders/api.ts +++ b/packages/esm-patient-labs-app/src/lab-orders/api.ts @@ -92,6 +92,7 @@ export function prepLabOrderPostData( concept: order.testType.conceptUuid, instructions: order.instructions, orderReason: order.orderReason, + accessionNumber: order.accessionNumber, }; } else if (order.action === 'REVISE') { return { @@ -105,6 +106,7 @@ export function prepLabOrderPostData( instructions: order.instructions, orderReason: order.orderReason, previousOrder: order.previousOrder, + accessionNumber: order.accessionNumber, }; } else if (order.action === 'DISCONTINUE') { return { @@ -117,6 +119,7 @@ export function prepLabOrderPostData( concept: order.testType.conceptUuid, orderReason: order.orderReason, previousOrder: order.previousOrder, + accessionNumber: order.accessionNumber, }; } else { throw new Error(`Unknown order action: ${order.action}.`); diff --git a/packages/esm-patient-labs-app/src/test-results/filter/filter-set.component.tsx b/packages/esm-patient-labs-app/src/test-results/filter/filter-set.component.tsx index 04c7b91324..7da54802c5 100644 --- a/packages/esm-patient-labs-app/src/test-results/filter/filter-set.component.tsx +++ b/packages/esm-patient-labs-app/src/test-results/filter/filter-set.component.tsx @@ -1,7 +1,6 @@ import React, { useContext, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Accordion, AccordionItem, Button, Checkbox, Search } from '@carbon/react'; -import { TreeViewAlt, Close, Search as SearchIcon } from '@carbon/react/icons'; +import { Accordion, AccordionItem, Button, Checkbox } from '@carbon/react'; import { useConfig, useLayoutType } from '@openmrs/esm-framework'; import { type ConfigObject } from '../../config-schema'; import type { FilterNodeProps, FilterLeafProps } from './filter-types'; @@ -17,6 +16,10 @@ interface FilterSetProps { hideFilterSetHeader?: boolean; } +interface filterNodeParentProps extends Pick<FilterNodeProps, 'root'> { + itemNumber: number; +} + function filterTreeNode(inputValue, treeNode) { // If the tree node's display value contains the user input, or any of its children's display contains the user input, return true if ( @@ -31,15 +34,10 @@ function filterTreeNode(inputValue, treeNode) { return false; } -const FilterSet: React.FC<FilterSetProps> = ({ hideFilterSetHeader = false }) => { +const FilterSet: React.FC<FilterSetProps> = () => { const { roots } = useContext(FilterContext); - const config = useConfig<ConfigObject>(); - const tablet = useLayoutType() === 'tablet'; - const { t } = useTranslation(); - const { resetTree } = useContext(FilterContext); const [searchTerm, setSearchTerm] = useState(''); const [treeDataFiltered, setTreeDataFiltered] = useState(roots); - const [showSearchInput, setShowSearchInput] = useState(false); useEffect(() => { const filteredData = roots.filter((node) => filterTreeNode(searchTerm, node)); @@ -47,40 +45,12 @@ const FilterSet: React.FC<FilterSetProps> = ({ hideFilterSetHeader = false }) => }, [searchTerm, roots]); return ( - <div className={!tablet ? styles.stickyFilterSet : ''}> - {!hideFilterSetHeader && - (!showSearchInput ? ( - <div className={styles.filterSetHeader}> - <h4>{t('tree', 'Tree')}</h4> - <div className={styles.filterSetActions}> - <Button - kind="ghost" - size="sm" - onClick={resetTree} - renderIcon={(props) => <TreeViewAlt size={16} {...props} />} - > - {t('resetTreeText', 'Reset tree')} - </Button> - - <Button kind="ghost" size="sm" renderIcon={SearchIcon} onClick={() => setShowSearchInput(true)}> - {t('search', 'Search')} - </Button> - </div> - </div> - ) : ( - <div className={styles.filterTreeSearchHeader}> - <Search autoFocus size="sm" value={searchTerm} onChange={(evt) => setSearchTerm(evt.target.value)} /> - <Button kind="secondary" size="sm" onClick={() => {}}> - {t('search', 'Search')} - </Button> - <Button hasIconOnly renderIcon={Close} size="sm" kind="ghost" onClick={() => setShowSearchInput(false)} /> - </div> - ))} + <div> <div className={styles.filterSetContent}> {treeDataFiltered?.length > 0 ? ( treeDataFiltered?.map((root, index) => ( - <div className={styles.nestedAccordion} key={`filter-node-${index}`}> - <FilterNode root={root} level={0} open={config.resultsViewerConcepts[index].defaultOpen} /> + <div className={`${styles.nestedAccordion} ${styles.nestedAccordionTablet}`} key={`filter-node-${index}`}> + <FilterNodeParent root={root} itemNumber={index} /> </div> )) ) : ( @@ -91,11 +61,48 @@ const FilterSet: React.FC<FilterSetProps> = ({ hideFilterSetHeader = false }) => ); }; +const FilterNodeParent = ({ root, itemNumber }: filterNodeParentProps) => { + const config = useConfig<ConfigObject>(); + const { t } = useTranslation(); + const tablet = useLayoutType() === 'tablet'; + const [expandAll, setExpandAll] = useState<boolean | undefined>(undefined); + + if (!root.subSets) return; + + const filterParent = root.subSets.map((node) => { + return ( + <FilterNode + root={node} + level={0} + open={expandAll === undefined ? config.resultsViewerConcepts[itemNumber].defaultOpen : expandAll} + /> + ); + }); + + return ( + <div> + <div className={`${styles.treeNodeHeader} ${tablet ? styles.treeNodeHeaderTablet : ''}`}> + <h5>{t(root.display)}</h5> + <Button + className={styles.button} + kind="ghost" + size="sm" + onClick={() => setExpandAll((prevValue) => !prevValue)} + > + <span>{t(!expandAll ? `Expand all` : `Collapse all`)}</span> + </Button> + </div> + {filterParent} + </div> + ); +}; + const FilterNode = ({ root, level, open }: FilterNodeProps) => { const tablet = useLayoutType() === 'tablet'; const { checkboxes, parents, updateParent } = useContext(FilterContext); const indeterminate = isIndeterminate(parents[root.flatName], checkboxes); const allChildrenChecked = parents[root.flatName]?.every((kid) => checkboxes[kid]); + return ( <Accordion align="start" size={tablet ? 'md' : 'sm'}> <AccordionItem @@ -104,7 +111,7 @@ const FilterNode = ({ root, level, open }: FilterNodeProps) => { id={root?.flatName} checked={root.hasData && allChildrenChecked} indeterminate={indeterminate} - labelText={`${root?.display} (${parents?.[root?.flatName]?.length})`} + labelText={`${root?.display} (${parents?.[root?.flatName]?.length ?? 0})`} onChange={() => updateParent(root.flatName)} disabled={!root.hasData} /> diff --git a/packages/esm-patient-labs-app/src/test-results/filter/filter-set.scss b/packages/esm-patient-labs-app/src/test-results/filter/filter-set.scss index c2a8120531..106465cb95 100644 --- a/packages/esm-patient-labs-app/src/test-results/filter/filter-set.scss +++ b/packages/esm-patient-labs-app/src/test-results/filter/filter-set.scss @@ -1,61 +1,53 @@ @use '@carbon/layout'; -@use '@carbon/type'; -@use '@openmrs/esm-styleguide/src/vars' as *; +@use '@carbon/styles/scss/type'; +@use '@carbon/colors'; +@import '@openmrs/esm-styleguide/src/vars'; -.stickyFilterSet { - position: sticky; - top: 6.5rem; - overflow-y: hidden; +.filterSetContent { + max-height: calc(100vh - 9.5rem); + overflow-y: auto; } -.filterSetHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: layout.$spacing-03; +// background of filter, and spacing between containers +.nestedAccordion { background-color: $openmrs-background-grey; - position: sticky; - top: 0; - z-index: 1; + margin: layout.$spacing-02 0; + + @media (min-width: $breakpoint-small-desktop-min) { + margin: layout.$spacing-02 0; + } - h4 { - @include type.type-style('heading-compact-02'); - color: $text-02; + :global(.cds--accordion__item) { + border: none; } - .filterSetActions { - display: flex; - justify-content: flex-end; - align-items: center; + :global(.cds--accordion__item:last-child) { + border: none; } } -.filterTreeSearchHeader { +.treeNodeHeader { display: flex; + justify-content: space-between; + border-bottom: 1px solid colors.$gray-20; + padding: layout.$spacing-03 0; margin-bottom: layout.$spacing-03; - background-color: $openmrs-background-grey; - position: sticky; - top: 0; - z-index: 1; + align-items: center; } -.filterSetContent { - max-height: calc(100vh - 9.5rem); - overflow-y: auto; +.treeNodeHeaderTablet { + padding-left: layout.$spacing-05; + margin-bottom: 0; } -// background of filter, and spacing between containers -.nestedAccordion { - background-color: $openmrs-background-grey; - margin: layout.$spacing-02 0; - @media (min-width: $breakpoint-small-desktop-min) { - background-color: $ui-background; - } +.nestedAccordionTablet { + margin-bottom: layout.$spacing-05; } // our special accordion rules .nestedAccordion > :global(.cds--accordion--start) > :global(.cds--accordion__item--active) { border-left: 0.375rem solid var(--brand-01); + @media (max-width: $breakpoint-tablet-max) { margin: layout.$spacing-06 0; } @@ -75,6 +67,7 @@ .cds--accordion__item--active > .cds--accordion__content { display: block; + padding-right: layout.$spacing-05; } .cds--accordion__title { diff --git a/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.component.tsx b/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.component.tsx index f131a061c7..2d6fa7e298 100644 --- a/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.component.tsx +++ b/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.component.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { EmptyState } from '@openmrs/esm-patient-common-lib'; -import { ConfigurableLink, usePatient } from '@openmrs/esm-framework'; +import { ConfigurableLink, showModal, usePatient } from '@openmrs/esm-framework'; import { Grid, ShadowBox } from '../panel-timeline/helpers'; import { makeThrottled, testResultsBasePath } from '../helpers'; import type { @@ -30,6 +30,14 @@ const PanelNameCorner: React.FC<PanelNameCornerProps> = ({ showShadow, panelName const NewRowStartCell = ({ title, range, units, conceptUuid, shadow = false, isString = false }) => { const { patientUuid } = usePatient(); + const launchResultsDialog = (patientUuid: string, title: string, testUuid: string) => { + const dispose = showModal('timeline-results-modal', { + closeDeleteModal: () => dispose(), + patientUuid, + testUuid, + title, + }); + }; return ( <div @@ -38,16 +46,18 @@ const NewRowStartCell = ({ title, range, units, conceptUuid, shadow = false, isS boxShadow: shadow ? '8px 0 20px 0 rgba(0,0,0,0.15)' : undefined, }} > - {!isString ? ( - <ConfigurableLink - to={`${testResultsBasePath(`/patient/${patientUuid}/chart`)}/trendline/${conceptUuid}`} - className={styles.trendlineLink} - > - {title} - </ConfigurableLink> - ) : ( - <span className={styles.trendlineLink}>{title}</span> - )} + <span className={styles['trendline-link']}> + {!isString ? ( + <span + className={styles['trendline-link-view']} + onClick={() => launchResultsDialog(patientUuid, title, conceptUuid)} + > + {title} + </span> + ) : ( + <span className={styles.trendlineLink}>{title}</span> + )} + </span> <span className={styles.rangeUnits}> {range} {units} </span> @@ -292,18 +302,19 @@ export const GroupedTimeline = () => { ) : rowData?.filter((row: { flatName: string }) => parents[parent.flatName].includes(row.flatName)); - // show kid rows return ( - <TimelineDataGroup - parent={parent} - subRows={subRows} - key={index} - xScroll={xScroll} - setXScroll={setXScroll} - panelName={panelName} - setPanelName={setPanelName} - groupNumber={shownGroups} - /> + subRows?.length > 0 && ( + <TimelineDataGroup + parent={parent} + subRows={subRows} + key={index} + xScroll={xScroll} + setXScroll={setXScroll} + panelName={panelName} + setPanelName={setPanelName} + groupNumber={shownGroups} + /> + ) ); } else return null; })} diff --git a/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.scss b/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.scss index 0cb5191eb7..33ed799b42 100644 --- a/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.scss +++ b/packages/esm-patient-labs-app/src/test-results/grouped-timeline/grouped-timeline.scss @@ -98,6 +98,7 @@ align-self: flex-start; background-color: $ui-03; padding: 0.6rem layout.$spacing-05; + border-top: 1px solid colors.$gray-30; } .rowStartCell { @@ -237,3 +238,13 @@ grid-row: 2 / -1; grid-column: 1 / 2; } + +.trendline-link-view { + @include type.type-style('helper-text-01'); + color: colors.$blue-60; + cursor: pointer; +} + +.trendline-link-view:hover { + text-decoration: underline; +} diff --git a/packages/esm-patient-labs-app/src/test-results/individual-results-table/individual-results-table.component.tsx b/packages/esm-patient-labs-app/src/test-results/individual-results-table/individual-results-table.component.tsx new file mode 100644 index 0000000000..a38ca4ab89 --- /dev/null +++ b/packages/esm-patient-labs-app/src/test-results/individual-results-table/individual-results-table.component.tsx @@ -0,0 +1,167 @@ +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { + DataTableSkeleton, + Button, + DataTable, + TableContainer, + Table, + TableHead, + TableRow, + TableHeader, + TableBody, + TableCell, +} from '@carbon/react'; +import { ArrowRight } from '@carbon/react/icons'; +import { showModal, useLayoutType, isDesktop, formatDate } from '@openmrs/esm-framework'; +import { getPatientUuidFromUrl, type OBSERVATION_INTERPRETATION } from '@openmrs/esm-patient-common-lib'; +import styles from './individual-results-table.scss'; + +const IndividualResultsTable = ({ isLoading, parent, subRows, index }) => { + const { t } = useTranslation(); + const layout = useLayoutType(); + const patientUuid = getPatientUuidFromUrl(); + + const headerTitle = t(parent.display); + + const launchResultsDialog = (patientUuid: string, title: string, testUuid: string) => { + const dispose = showModal('timeline-results-modal', { + closeDeleteModal: () => dispose(), + patientUuid, + testUuid, + title, + }); + }; + + const tableHeaders = [ + { key: 'testName', header: t('testName', 'Test Name') }, + { + key: 'value', + header: t('value', 'Value'), + }, + { key: 'referenceRange', header: t('referenceRange', 'Reference range') }, + ]; + + const tableRows = useMemo(() => { + const rowData = subRows.flatMap((row, i) => { + const { units = '', range = '', obs: values } = row; + const isString = isNaN(parseFloat(values?.[0]?.value)); + + return { + ...row, + id: `${i}-${index}`, + testName: ( + <span className={styles['trendline-link']}> + {!isString ? ( + <span + className={styles['trendline-link-view']} + onClick={() => launchResultsDialog(patientUuid, row.display, row.conceptUuid)} + > + {row.display} + </span> + ) : ( + <span className={styles.trendlineLink}>{row.display}</span> + )} + </span> + ), + value: { + value: (row.obs[0]?.value ? row.obs[0]?.value : '') + +' ' + (row?.units ? ` ${row?.units}` : ''), + interpretation: row.obs[0]?.interpretation, + }, + referenceRange: `${range || '--'} ${units || '--'}`, + }; + }); + + return rowData; + }, [index, patientUuid, subRows]); + + if (isLoading) return <DataTableSkeleton role="progressbar" compact={isDesktop} zebra />; + if (subRows?.length) { + return ( + <div> + <DataTable rows={tableRows} headers={tableHeaders} data-floating-menu-container> + {({ rows, headers, getHeaderProps, getTableProps }) => ( + <TableContainer> + <div className={styles.cardTitle}> + <h4 className={styles.resultType}>{headerTitle}</h4> + <div className={styles.displayFlex}> + <span className={styles.date}> + {subRows[0]?.obs[0]?.obsDatetime + ? formatDate(new Date(subRows[0]?.obs[0]?.obsDatetime), { mode: 'standard' }) + : ''} + </span> + <Button + kind="ghost" + className={styles.viewTimeline} + renderIcon={(props) => <ArrowRight size={16} {...props} />} + iconDescription="view timeline" + onClick={() => launchResultsDialog(patientUuid, headerTitle, subRows[0]?.conceptUuid)} + > + {t('viewTimeline', 'View timeline')} + </Button> + </div> + </div> + <Table className={styles.table} {...getTableProps()} size={isDesktop(layout) ? 'sm' : 'md'}> + <TableHead> + <TableRow> + {headers.map((header) => ( + <TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader> + ))} + </TableRow> + </TableHead> + <TableBody> + {rows.map((row) => { + return ( + <TableRow key={row.id}> + {row.cells.map((cell) => + cell?.value?.interpretation ? ( + <TableCell className={classNames(getClasses(cell?.value?.interpretation))} key={cell.id}> + <p>{cell?.value?.value ?? cell?.value}</p> + </TableCell> + ) : ( + <TableCell key={cell.id}> + <p>{cell?.value}</p> + </TableCell> + ), + )} + </TableRow> + ); + })} + </TableBody> + </Table> + </TableContainer> + )} + </DataTable> + </div> + ); + } +}; + +export default IndividualResultsTable; + +export const getClasses = (interpretation: OBSERVATION_INTERPRETATION) => { + switch (interpretation) { + case 'OFF_SCALE_HIGH': + return styles['off-scale-high']; + + case 'CRITICALLY_HIGH': + return styles['critically-high']; + + case 'HIGH': + return styles['high']; + + case 'OFF_SCALE_LOW': + return styles['off-scale-low']; + + case 'CRITICALLY_LOW': + return styles['critically-low']; + + case 'LOW': + return styles['low']; + + case 'NORMAL': + default: + return ''; + } +}; diff --git a/packages/esm-patient-labs-app/src/test-results/individual-results-table/individual-results-table.scss b/packages/esm-patient-labs-app/src/test-results/individual-results-table/individual-results-table.scss new file mode 100644 index 0000000000..4bbae0eadb --- /dev/null +++ b/packages/esm-patient-labs-app/src/test-results/individual-results-table/individual-results-table.scss @@ -0,0 +1,137 @@ +@use '@carbon/layout'; +@use '@carbon/colors'; +@use '@carbon/type'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.off-scale-high, +.off-scale-low, +.critically-high, +.critically-low, +.high, +.low { + p { + @include type.type-style('heading-compact-01'); + } +} + +.high, +.low { + background-color: colors.$orange-10 !important; + border-top: 1px solid colors.$orange-20 !important; +} + +.critically-high, +.critically-low { + background-color: colors.$red-20 !important; + border-top: 1px solid colors.$red-20-hover !important; +} + +.off-scale-low { + p::after { + content: ' ↓↓↓'; + } +} + +.off-scale-high { + p::after { + content: ' ↑↑↑'; + } +} + +.critically-low { + p::after { + content: ' ↓↓'; + } +} + +.critically-high { + p::after { + content: ' ↑↑'; + } +} + +.low { + p::after { + content: ' ↓'; + } +} + +.high { + p::after { + content: ' ↑'; + } +} + +.trendline-link-view { + color: colors.$blue-60; + cursor: pointer; +} + +.trendline-link-view:hover { + text-decoration: underline; +} + +.resultType { + @include type.type-style('heading-compact-02'); + color: $text-02; + margin-bottom: 5px; +} + +.date { + color: $text-02; + padding-right: 0.625rem; +} + +.date::after { + content: ''; + display: block; + width: layout.$spacing-07; + padding-top: 0.188rem; + border-bottom: 0.375rem solid var(--brand-03); +} + +.displayFlex { + display: flex; + flex-direction: row; + justify-content: flex-start; +} + +.viewTimeline { + display: flex; + margin-left: auto; +} + +.cardTitle { + background-color: white; + padding: 0.5rem; +} + +.tableContainer { + padding: 0; + + :global(.cds--data-table-content) { + border: 1px solid $ui-03; + border-bottom: none; + overflow: visible; + } + + :global(.cds--data-table-header) { + padding: 0; + } + + :global(.cds--table-toolbar) { + position: relative; + height: layout.$spacing-07; + overflow: visible; + top: 0; + } + + &:global(.cds--data-table-container) { + background: none !important; + } + + :global(.cds--toolbar-content) { + height: layout.$spacing-05; + margin-bottom: layout.$spacing-02; + } +} diff --git a/packages/esm-patient-labs-app/src/test-results/panel-timeline/helpers.tsx b/packages/esm-patient-labs-app/src/test-results/panel-timeline/helpers.tsx index 9b47014ace..3a3a8f0c89 100644 --- a/packages/esm-patient-labs-app/src/test-results/panel-timeline/helpers.tsx +++ b/packages/esm-patient-labs-app/src/test-results/panel-timeline/helpers.tsx @@ -36,16 +36,24 @@ export const Grid: React.FC<{ style: React.CSSProperties; padding?: boolean; dataColumns: number; -}> = ({ dataColumns, style = {}, padding = false, ...props }) => ( - <div - style={{ - ...style, - gridTemplateColumns: `${padding ? '9em ' : ''} repeat(${dataColumns}, 5em)`, - }} - className={styles['grid']} - {...props} - /> -); +}> = ({ dataColumns, style = {}, padding = false, ...props }) => { + const minColumnWidth = 4; + const maxColumnWidth = 10; + + const dynamicColumnWidth = Math.max(minColumnWidth, Math.min(maxColumnWidth, 100 / dataColumns)); + + return ( + <div + style={{ + ...style, + gridTemplateColumns: `${padding ? '9em ' : ''} repeat(${dataColumns}, ${dynamicColumnWidth}em)`, + overflow: 'hidden', + }} + className={styles['grid']} + {...props} + /> + ); +}; export const PaddingContainer = React.forwardRef<HTMLElement, any>((props, ref) => ( <div ref={ref} className={styles['padding-container']} {...props} /> diff --git a/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.component.tsx b/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.component.tsx index 5d75d2c741..92d5541a80 100644 --- a/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.component.tsx +++ b/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.component.tsx @@ -1,11 +1,11 @@ import React from 'react'; import classNames from 'classnames'; -import { EmptyState } from '@openmrs/esm-patient-common-lib'; import { PaddingContainer, TimeSlots, Grid, RowStartCell, GridItems, ShadowBox } from './helpers'; import { type ParsedTimeType } from '../filter/filter-types'; import type { ObsRecord } from '../../types'; import useScrollIndicator from './useScroll'; import styles from './timeline.scss'; +import { EmptyState } from '@openmrs/esm-patient-common-lib'; interface PanelNameCornerProps { showShadow: boolean; diff --git a/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.scss b/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.scss index ec0b385e6e..16178351d1 100644 --- a/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.scss +++ b/packages/esm-patient-labs-app/src/test-results/panel-timeline/timeline.scss @@ -11,6 +11,11 @@ gap: 1px; justify-items: center; align-items: center; + border: 0.5px solid colors.$gray-30; +} + +.grid:hover { + border: none; } .day-column, @@ -61,7 +66,6 @@ left: 0; display: grid; grid-auto-flow: row; - gap: 0.5px; justify-items: baseline; align-items: center; padding: layout.$spacing-05; @@ -77,9 +81,7 @@ display: grid; grid-auto-flow: row; - column-gap: 1px; row-gap: 0px; - background-color: $color-gray-30; grid-template: auto auto / 9em auto; border-collapse: collapse; } @@ -109,6 +111,7 @@ left: 0px; top: 0px; z-index: 3; + border-right: 0.5px solid colors.$gray-30; span { @include type.type-style('heading-compact-01'); @@ -197,10 +200,8 @@ .recent-results-grid { display: grid; - background-color: white; overflow: auto; max-height: calc(100vh - 9rem); - border: 1px solid colors.$gray-50; } .recent-results-grid::-webkit-scrollbar { @@ -216,3 +217,4 @@ .trendline-link-view:hover { text-decoration: underline; } + diff --git a/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.extension.tsx b/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.extension.tsx index 929a91616d..7d5efdf71e 100644 --- a/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.extension.tsx +++ b/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.extension.tsx @@ -3,9 +3,8 @@ import classNames from 'classnames'; import { type TFunction, useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { ContentSwitcher, Switch, Button } from '@carbon/react'; -import { Printer, Renew } from '@carbon/react/icons'; import { EmptyState, ErrorState } from '@openmrs/esm-patient-common-lib'; -import { navigate, showModal, useConfig, useLayoutType } from '@openmrs/esm-framework'; +import { navigate, useConfig, useLayoutType } from '@openmrs/esm-framework'; import { FilterContext, FilterProvider } from '../filter'; import { useGetManyObstreeData } from '../grouped-timeline'; import { testResultsBasePath } from '../helpers'; @@ -15,9 +14,9 @@ import TreeViewWrapper from '../tree-view/tree-view-wrapper.component'; import Trendline from '../trendline/trendline.component'; import type { ConfigObject } from '../../config-schema'; import styles from './results-viewer.scss'; +import { type viewOpts } from '../../types'; type panelOpts = 'tree' | 'panel'; -type viewOpts = 'split' | 'full'; interface RefreshDataButtonProps { isTablet: boolean; @@ -59,14 +58,12 @@ const RoutedResultsViewer: React.FC<ResultsViewerProps> = ({ basePath, patientUu const ResultsViewer: React.FC<ResultsViewerProps> = ({ patientUuid, basePath, loading }) => { const { t } = useTranslation(); const isTablet = useLayoutType() === 'tablet'; - const [view, setView] = useState<viewOpts>('split'); - const config = useConfig() as ConfigObject; + const [view, setView] = useState<viewOpts>('individual-test'); const [selectedSection, setSelectedSection] = useState<panelOpts>('tree'); - const { totalResultsCount } = useContext(FilterContext); + const { totalResultsCount, resetTree } = useContext(FilterContext); const { type, testUuid } = useParams(); const isExpanded = view === 'full'; const trendlineView = testUuid && type === 'trendline'; - const showPrintButton = config.showPrintButton; const responsiveSize = isTablet ? 'lg' : 'md'; const navigateBackFromTrendlineView = useCallback(() => { @@ -75,13 +72,6 @@ const ResultsViewer: React.FC<ResultsViewerProps> = ({ patientUuid, basePath, lo }); }, [patientUuid]); - const openPrintModal = useCallback(() => { - const dispose = showModal('print-modal', { - patientUuid, - closeDialog: () => dispose(), - }); - }, [patientUuid]); - if (isTablet) { return ( <div className={styles.resultsContainer}> @@ -94,11 +84,10 @@ const ResultsViewer: React.FC<ResultsViewerProps> = ({ patientUuid, basePath, lo selectedIndex={['panel', 'tree'].indexOf(selectedSection)} onChange={({ name }: { name: panelOpts }) => setSelectedSection(name)} > - <Switch name="panel" text={t('panel', 'Panel')} /> - <Switch name="tree" text={t('tree', 'Tree')} /> + <Switch name="panel" text={t('individualTests', 'Individual tests')} /> + <Switch name="tree" text={t('overTime', 'Over time')} /> </ContentSwitcher> </div> - <RefreshDataButton isTablet={isTablet} t={t} /> </div> {selectedSection === 'tree' ? ( <TreeViewWrapper @@ -107,6 +96,7 @@ const ResultsViewer: React.FC<ResultsViewerProps> = ({ patientUuid, basePath, lo type={type} expanded={isExpanded} testUuid={testUuid} + view={view} /> ) : selectedSection === 'panel' ? ( <PanelView @@ -134,82 +124,45 @@ const ResultsViewer: React.FC<ResultsViewerProps> = ({ patientUuid, basePath, lo <div className={styles.resultsContainer}> <div className={styles.resultsHeader}> <div className={classNames(styles.leftSection, styles.leftHeaderSection)}> - <h4 style={{ flexGrow: 1 }}>{`${t('results', 'Results')} ${ - totalResultsCount ? `(${totalResultsCount})` : '' - }`}</h4> - <div className={styles.leftHeaderActions}> - <ContentSwitcher - size={responsiveSize} - selectedIndex={['panel', 'tree'].indexOf(selectedSection)} - onChange={({ name }: { name: panelOpts }) => setSelectedSection(name)} - > - <Switch name="panel" text={t('panel', 'Panel')} /> - <Switch name="tree" text={t('tree', 'Tree')} /> - </ContentSwitcher> - {showPrintButton && ( - <Button - className={styles.button} - kind="ghost" - size={isTablet ? 'md' : 'sm'} - renderIcon={Printer} - iconDescription="Print results" - onClick={openPrintModal} - > - <span>{t('print', 'Print')}</span> - </Button> - )} - </div> + <h4>{t('tests', 'Tests')}</h4> + <Button + className={styles.button} + kind="ghost" + size={isTablet ? 'md' : 'sm'} + onClick={resetTree} //TO-DO (undo selections fix) + > + <span>{t('reset', 'Reset')}</span> + </Button> </div> <div className={styles.rightSectionHeader}> <div className={styles.viewOptsContentSwitcherContainer}> + <h4 className={styles.viewOptionsText}>{`${t('results', 'Results')} ${ + totalResultsCount ? `(${totalResultsCount})` : '' + }`}</h4> <ContentSwitcher className={styles.viewOptionsSwitcher} onChange={({ name }: { name: viewOpts }) => setView(name)} selectedIndex={isExpanded ? 1 : 0} size={responsiveSize} > - <Switch name="split" text={t('split', 'Split')} disabled={loading} /> - <Switch name="full" text={t('full', 'Full')} disabled={loading} /> + <Switch name="individual-test" text={t('individualTests', 'Individual tests')} disabled={loading} /> + <Switch name="over-time" text={t('overTime', 'Over time')} disabled={loading} /> </ContentSwitcher> </div> - <RefreshDataButton isTablet={isTablet} t={t} /> </div> </div> <div className={styles.flex}> - {selectedSection === 'tree' ? ( - <TreeViewWrapper - patientUuid={patientUuid} - basePath={basePath} - type={type} - expanded={isExpanded} - testUuid={testUuid} - /> - ) : selectedSection === 'panel' ? ( - <PanelView - expanded={isExpanded} - patientUuid={patientUuid} - basePath={basePath} - type={type} - testUuid={testUuid} - /> - ) : null} + <TreeViewWrapper + patientUuid={patientUuid} + basePath={basePath} + type={type} + expanded={false} + testUuid={testUuid} + view={view} + /> </div> </div> ); }; -function RefreshDataButton({ isTablet, t }: RefreshDataButtonProps) { - return ( - <Button - className={styles.button} - kind="ghost" - renderIcon={Renew} - size={isTablet ? 'md' : 'sm'} - onClick={() => window.location.reload()} - > - <span>{t('refreshData', 'Refresh data')}</span> - </Button> - ); -} - export default RoutedResultsViewer; diff --git a/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.scss b/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.scss index e23744be84..ab992049f0 100644 --- a/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.scss +++ b/packages/esm-patient-labs-app/src/test-results/results-viewer/results-viewer.scss @@ -2,6 +2,24 @@ @use '@carbon/type'; @use '@openmrs/esm-styleguide/src/vars' as *; +:global(.omrs-breakpoint-gt-small-desktop .-esm-patient-chart__patient-chart__widthContained___Ow0JH) { + max-width: 150rem; +} + +@media (min-width: 1200px) { + :global(.-esm-patient-chart__patient-chart__widthContained___Ow0JH) { + max-width: 150rem; + } +} + +:global(.cds--data-table td, .cds--data-table th) { + vertical-align: top; +} + +:global(.cds--data-table--md td) { + padding-top: 0.1rem; +} + .resultsContainer { padding: 0; width: 100%; @@ -33,34 +51,39 @@ .leftHeaderSection { display: flex; align-items: center; + justify-content: space-between; } .viewOptsContentSwitcherContainer { - margin-left: auto; + display: grid; + grid-template-columns: 2fr 1fr; + justify-content: space-between; +} + +.viewOptionsText { + margin-top: layout.$spacing-03; + justify-self: start; } .viewOptionsSwitcher { - max-width: 10rem; + text-align: right; + justify-self: end; } .leftSection { - width: 45%; + width: 350px; margin-right: layout.$spacing-05; } .rightSection, .rightSectionHeader { - width: 55%; + width: 70%; &.fullView { width: 100%; } } -.rightSectionHeader { - display: flex; -} - .rightSection :global(.cds--skeleton) { width: 100%; } diff --git a/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view-wrapper.component.tsx b/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view-wrapper.component.tsx index b1a0bf7ff3..31219a1e2b 100644 --- a/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view-wrapper.component.tsx +++ b/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view-wrapper.component.tsx @@ -5,6 +5,7 @@ import { FilterProvider } from '../filter/filter-context'; import TreeView from './tree-view.component'; import { useConfig } from '@openmrs/esm-framework'; import { useGetManyObstreeData } from '../grouped-timeline'; +import { type viewOpts } from '../../types'; interface TreeViewWrapperProps { patientUuid: string; @@ -12,6 +13,7 @@ interface TreeViewWrapperProps { testUuid: string; expanded: boolean; type: string; + view?: viewOpts; } const TreeViewWrapper: React.FC<TreeViewWrapperProps> = (props) => { diff --git a/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view.component.tsx b/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view.component.tsx index 67f0653a44..4918329f70 100644 --- a/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view.component.tsx +++ b/packages/esm-patient-labs-app/src/test-results/tree-view/tree-view.component.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { AccordionSkeleton, DataTableSkeleton, Button } from '@carbon/react'; @@ -6,11 +6,13 @@ import { TreeViewAlt } from '@carbon/react/icons'; import { useLayoutType } from '@openmrs/esm-framework'; import FilterSet, { FilterContext } from '../filter'; import GroupedTimeline from '../grouped-timeline'; -import PanelTimelineComponent from '../panel-timeline/panel-timeline-component'; import TabletOverlay from '../tablet-overlay'; import Trendline from '../trendline/trendline.component'; import usePanelData from '../panel-view/usePanelData'; import styles from '../results-viewer/results-viewer.scss'; +import { type viewOpts } from '../../types'; +import IndividualResultsTable from '../individual-results-table/individual-results-table.component'; +import { EmptyState } from '@openmrs/esm-patient-common-lib'; interface TreeViewProps { patientUuid: string; @@ -19,9 +21,52 @@ interface TreeViewProps { loading: boolean; expanded: boolean; type: string; + view?: viewOpts; } -const TreeView: React.FC<TreeViewProps> = ({ patientUuid, basePath, testUuid, loading, expanded, type }) => { +const GroupedPanelsTables = ({ loadingPanelData }) => { + const { timelineData, parents, checkboxes, someChecked, lowestParents } = useContext(FilterContext); + const [panelName, setPanelName] = useState(''); + const { t } = useTranslation(); + let shownGroups = 0; + + const { + data: { rowData }, + } = timelineData; + + useEffect(() => { + setPanelName(''); + }, [rowData]); + + const filteredParents = lowestParents?.filter( + (parent) => parents[parent.flatName].some((kid) => checkboxes[kid]) || !someChecked, + ); + + if (rowData && rowData?.length === 0) { + return <EmptyState displayText={t('data', 'data')} headerTitle={t('dataTimelineText', 'Data timeline')} />; + } + + return ( + <> + {filteredParents?.map((parent, index) => { + shownGroups += 1; + const subRows = someChecked + ? rowData?.filter( + (row: { flatName: string }) => + parents[parent.flatName].includes(row.flatName) && checkboxes[row.flatName], + ) + : rowData?.filter((row: { flatName: string }) => parents[parent.flatName].includes(row.flatName)); + return ( + <div style={{ paddingBottom: '1rem' }}> + <IndividualResultsTable isLoading={loadingPanelData} parent={parent} subRows={subRows} index={index} /> + </div> + ); + })} + </> + ); +}; + +const TreeView: React.FC<TreeViewProps> = ({ patientUuid, basePath, testUuid, loading, expanded, type, view }) => { const tablet = useLayoutType() === 'tablet'; const [showTreeOverlay, setShowTreeOverlay] = useState(false); const { t } = useTranslation(); @@ -77,17 +122,17 @@ const TreeView: React.FC<TreeViewProps> = ({ patientUuid, basePath, testUuid, lo <Trendline patientUuid={patientUuid} conceptUuid={testUuid} basePath={basePath} showBackToTimelineButton /> ) : loading || isLoadingPanelData ? ( <DataTableSkeleton /> - ) : someChecked ? ( - <GroupedTimeline /> - ) : ( - // If no filter is selected from the filter view - // All the test results recorded for the patient needs to be shown + ) : view === 'individual-test' ? ( + <div className={styles.panelViewTimeline}> + <GroupedPanelsTables loadingPanelData={loading} /> + </div> + ) : view === 'over-time' ? ( panels.map((panel) => ( <div className={styles.panelViewTimeline}> - <PanelTimelineComponent groupedObservations={groupedObservations} activePanel={panel} /> + <GroupedTimeline /> </div> )) - )} + ) : null} </div> </> ); diff --git a/packages/esm-patient-labs-app/src/types.ts b/packages/esm-patient-labs-app/src/types.ts index ad6e5f42a6..5b66651cbd 100644 --- a/packages/esm-patient-labs-app/src/types.ts +++ b/packages/esm-patient-labs-app/src/types.ts @@ -132,3 +132,5 @@ export interface ObservationSet { uuid: string; meta: ConceptMeta; } + +export type viewOpts = 'individual-test' | 'over-time' | 'full'; diff --git a/packages/esm-patient-labs-app/translations/en.json b/packages/esm-patient-labs-app/translations/en.json index 7590a896c6..855a148ee6 100644 --- a/packages/esm-patient-labs-app/translations/en.json +++ b/packages/esm-patient-labs-app/translations/en.json @@ -23,9 +23,9 @@ "errorFetchingTestTypes": "Error fetching results for \"{{searchTerm}}\"", "errorLoadingTestTypes": "Error occured when loading test types", "female": "Female", - "full": "Full", "goToDrugOrderForm": "Order form", "hideResultsTable": "Hide results table", + "individualTests": "Individual tests", "labOrders": "Lab orders", "labReferenceNumber": "Lab reference number", "male": "Male", @@ -44,6 +44,7 @@ "ordered": "Ordered", "orderReason": "Order reason", "other": "Other", + "overTime": "Over time", "panel": "Panel", "panels": "panels", "pleaseRequiredFields": "Please fill all required fields", @@ -54,8 +55,8 @@ "recentResults": "Recent Results", "recentTestResults": "recent test results", "referenceRange": "Reference range", - "refreshData": "Refresh data", "removeFromBasket": "Remove from basket", + "reset": "Reset", "resetTreeText": "Reset tree", "resetView": "to reset this view", "resulted": "Resulted", @@ -75,12 +76,12 @@ "seeAllResults": "See all results", "showResultsTable": "Show results table", "showTree": "Show tree", - "split": "Split", "startDate": "Start date", "testName": "Test name", "testResults": "test results", "testResults_title": "Test Results", "testResultsData": "Test results data", + "tests": "Tests", "testType": "Test type", "timeline": "Timeline", "tree": "Tree", @@ -99,5 +100,6 @@ "unknown": "Unknown", "usingADifferentTerm": "using a different term", "value": "Value", - "view": "View" + "view": "View", + "viewTimeline": "View timeline" } diff --git a/packages/esm-patient-orders-app/src/lab-results/result-form-field.component.tsx b/packages/esm-patient-orders-app/src/lab-results/lab-results-form-field.component.tsx similarity index 56% rename from packages/esm-patient-orders-app/src/lab-results/result-form-field.component.tsx rename to packages/esm-patient-orders-app/src/lab-results/lab-results-form-field.component.tsx index 38c968decf..52f6dbb0e6 100644 --- a/packages/esm-patient-orders-app/src/lab-results/result-form-field.component.tsx +++ b/packages/esm-patient-orders-app/src/lab-results/lab-results-form-field.component.tsx @@ -1,29 +1,29 @@ import React from 'react'; -import { TextInput, Select, SelectItem } from '@carbon/react'; +import { NumberInput, Select, SelectItem, TextInput } from '@carbon/react'; import { useTranslation } from 'react-i18next'; -import { Controller } from 'react-hook-form'; +import { type Control, Controller } from 'react-hook-form'; import { type LabOrderConcept } from './lab-results.resource'; import styles from './lab-results-form.scss'; interface ResultFormFieldProps { - defaultValue: any; concept: LabOrderConcept; - control: any; - register: any; - errors?: any; + control: Control<any, any>; + defaultValue: any; } -const ResultFormField: React.FC<ResultFormFieldProps> = ({ defaultValue, register, concept, control, errors }) => { +const ResultFormField: React.FC<ResultFormFieldProps> = ({ concept, control, defaultValue }) => { const { t } = useTranslation(); - const isTextOrNumeric = (concept) => concept.datatype?.display === 'Text' || concept.datatype?.display === 'Numeric'; - const isCoded = (concept) => concept.datatype?.display === 'Coded'; - const isPanel = (concept) => concept.setMembers?.length > 0; + + const isCoded = (concept: LabOrderConcept) => concept.datatype?.display === 'Coded'; + const isNumeric = (concept: LabOrderConcept) => concept.datatype?.display === 'Numeric'; + const isPanel = (concept: LabOrderConcept) => concept.setMembers?.length > 0; + const isText = (concept: LabOrderConcept) => concept.datatype?.display === 'Text'; const printValueRange = (concept: LabOrderConcept) => { if (concept?.datatype?.display === 'Numeric') { const maxVal = Math.max(concept?.hiAbsolute, concept?.hiCritical, concept?.hiNormal); const minVal = Math.min(concept?.lowAbsolute, concept?.lowCritical, concept?.lowNormal); - return `(${minVal ?? 0} - ${maxVal > 0 ? maxVal : '--'} ${concept?.units ?? ''})`; + return ` (${minVal ?? 0} - ${maxVal > 0 ? maxVal : '--'} ${concept?.units ?? ''})`; } return ''; }; @@ -36,25 +36,38 @@ const ResultFormField: React.FC<ResultFormFieldProps> = ({ defaultValue, registe return ( <> - {Object.keys(errors).length > 0 && <div className={styles.errorDiv}>All fields are required</div>} - {isTextOrNumeric(concept) && ( + {isText(concept) && ( <Controller - name={concept.uuid} control={control} - rules={{ - required: true, - }} + name={concept.uuid} render={({ field }) => ( <TextInput - key={concept.uuid} className={styles.textInput} + id={concept.uuid} + key={concept.uuid} + labelText={concept?.display ?? ''} + type="text" {...field} - type={concept.datatype.display === 'Numeric' ? 'number' : 'text'} - labelText={ - concept?.display + (concept.datatype.display === 'Numeric' ? ` ${printValueRange(concept)}` ?? '' : '') - } - defaultValue={defaultValue?.value} - autoFocus + /> + )} + /> + )} + + {isNumeric(concept) && ( + <Controller + control={control} + name={concept.uuid} + render={({ field }) => ( + <NumberInput + allowEmpty + className={styles.numberInput} + disableWheel + hideSteppers + id={concept.uuid} + key={concept.uuid} + label={concept?.display + printValueRange(concept)} + onChange={(event) => field.onChange(event.target.value)} + value={field.value || ''} /> )} /> @@ -64,18 +77,14 @@ const ResultFormField: React.FC<ResultFormFieldProps> = ({ defaultValue, registe <Controller name={concept.uuid} control={control} - rules={{ - required: true, - }} render={({ field }) => ( <Select - key={concept.uuid} - className={styles.textInput} {...field} - invalidText={t('required', 'Required')} - labelText={concept?.display} - rules={{ required: true }} + className={styles.textInput} defaultValue={defaultValue?.value?.uuid} + id={`select-${concept.uuid}`} + key={concept.uuid} + labelText={concept?.display} > <SelectItem text={t('chooseAnOption', 'Choose an option')} value="" /> {concept?.answers?.length && @@ -90,49 +99,56 @@ const ResultFormField: React.FC<ResultFormFieldProps> = ({ defaultValue, registe )} {isPanel(concept) && - concept.setMembers.map((member, index) => { - if (isTextOrNumeric(member)) { - return ( + concept.setMembers.map((member) => ( + <React.Fragment key={member.uuid}> + {isText(member) && ( <Controller control={control} name={member.uuid} - rules={{ - required: true, - }} render={({ field }) => ( <TextInput - key={member.uuid} - className={styles.textInput} {...field} - type={member.datatype.display === 'Numeric' ? 'number' : 'text'} - labelText={ - member?.display + (member.datatype.display === 'Numeric' ? printValueRange(member) ?? '' : '') - } - autoFocus={index === 0} - defaultValue={getSavedMemberValue(member.uuid, member.datatype.display)} + id={`text-${member.uuid}`} + className={styles.textInput} + key={member.uuid} + labelText={member?.display ?? ''} + type="text" /> )} /> - ); - } - - if (isCoded(member)) { - return ( + )} + {isNumeric(member) && ( + <Controller + control={control} + name={member.uuid} + render={({ field }) => ( + <NumberInput + allowEmpty + className={styles.numberInput} + disableWheel + hideSteppers + id={`number-${member.uuid}`} + key={member.uuid} + label={member?.display + printValueRange(member)} + onChange={(event) => field.onChange(event.target.value)} + value={field.value || ''} + /> + )} + /> + )} + {isCoded(member) && ( <Controller name={member.uuid} control={control} - rules={{ - required: true, - }} render={({ field }) => ( <Select - key={member.uuid} - className={styles.textInput} {...field} - type="text" - labelText={member?.display} - autoFocus={index === 0} + className={styles.textInput} defaultValue={getSavedMemberValue(member.uuid, member.datatype.display)} + id={`select-${member.uuid}`} + key={member.uuid} + labelText={member?.display} + type="text" > <SelectItem text={t('chooseAnOption', 'Choose an option')} value="" /> @@ -144,9 +160,9 @@ const ResultFormField: React.FC<ResultFormFieldProps> = ({ defaultValue, registe </Select> )} /> - ); - } - })} + )} + </React.Fragment> + ))} </> ); }; diff --git a/packages/esm-patient-orders-app/src/lab-results/lab-results-form.component.tsx b/packages/esm-patient-orders-app/src/lab-results/lab-results-form.component.tsx index 599e1c51fb..4632389dd0 100644 --- a/packages/esm-patient-orders-app/src/lab-results/lab-results-form.component.tsx +++ b/packages/esm-patient-orders-app/src/lab-results/lab-results-form.component.tsx @@ -1,22 +1,22 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; -import { restBaseUrl, showSnackbar, useAbortController, useLayoutType } from '@openmrs/esm-framework'; -import { Button, ButtonSet, Form, InlineLoading, Stack } from '@carbon/react'; +import { mutate } from 'swr'; +import { Button, ButtonSet, Form, InlineLoading, InlineNotification, Stack } from '@carbon/react'; import { type DefaultPatientWorkspaceProps, type Order } from '@openmrs/esm-patient-common-lib'; +import { restBaseUrl, showSnackbar, useAbortController, useLayoutType } from '@openmrs/esm-framework'; import { useOrderConceptByUuid, updateOrderResult, useLabEncounter, useObservation } from './lab-results.resource'; -import ResultFormField from './result-form-field.component'; +import ResultFormField from './lab-results-form-field.component'; import styles from './lab-results-form.scss'; -import { mutate } from 'swr'; export interface LabResultsFormProps extends DefaultPatientWorkspaceProps { order: Order; } const LabResultsForm: React.FC<LabResultsFormProps> = ({ - order, closeWorkspace, closeWorkspaceWithSavedChanges, + order, promptBeforeClosing, }) => { const { t } = useTranslation(); @@ -24,17 +24,17 @@ const LabResultsForm: React.FC<LabResultsFormProps> = ({ const isTablet = useLayoutType() === 'tablet'; const [obsUuid, setObsUuid] = useState(''); const [isEditing, setIsEditing] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); const [initialValues, setInitialValues] = useState(null); const [isLoadingInitialValues, setIsLoadingInitialValues] = useState(false); const { concept, isLoading: isLoadingConcepts } = useOrderConceptByUuid(order.concept.uuid); const { encounter, isLoading: isLoadingEncounter, mutate: mutateLabOrders } = useLabEncounter(order.encounter.uuid); const { data, isLoading: isLoadingObs, error: isErrorObs } = useObservation(obsUuid); + const [showEmptyFormErrorNotification, setShowEmptyFormErrorNotification] = useState(false); const { control, register, - formState: { errors, isDirty }, + formState: { errors, isDirty, isSubmitting }, getValues, handleSubmit, } = useForm<{ testResult: any }>({ @@ -82,11 +82,18 @@ const LabResultsForm: React.FC<LabResultsFormProps> = ({ ); } - const saveLabResults = (data, e) => { - setIsSubmitting(true); - e.preventDefault(); - // assign result to test order - const documentedValues = getValues(); + const saveLabResults = () => { + const formValues = getValues(); + + const isEmptyForm = Object.values(formValues).every( + (value) => value === '' || value === null || value === undefined, + ); + + if (isEmptyForm) { + setShowEmptyFormErrorNotification(true); + return; + } + let obsValue = []; if (concept.set && concept.setMembers.length > 0) { @@ -160,7 +167,6 @@ const LabResultsForm: React.FC<LabResultsFormProps> = ({ abortController, ).then( () => { - setIsSubmitting(false); closeWorkspaceWithSavedChanges(); mutateLabOrders(); mutate( @@ -177,7 +183,6 @@ const LabResultsForm: React.FC<LabResultsFormProps> = ({ }); }, (err) => { - setIsSubmitting(false); showSnackbar({ title: t('errorSavingLabResults', 'Error saving lab results'), kind: 'error', @@ -185,6 +190,8 @@ const LabResultsForm: React.FC<LabResultsFormProps> = ({ }); }, ); + + setShowEmptyFormErrorNotification(false); }; return ( @@ -192,22 +199,23 @@ const LabResultsForm: React.FC<LabResultsFormProps> = ({ <div className={styles.grid}> {concept.setMembers.length > 0 && <p className={styles.heading}>{concept.display}</p>} {concept && ( - <Stack gap={2}> + <Stack gap={5}> {!isLoadingInitialValues ? ( - <ResultFormField - defaultValue={initialValues} - register={register} - concept={concept} - control={control} - errors={errors} - /> + <ResultFormField defaultValue={initialValues} concept={concept} control={control} /> ) : ( <InlineLoading description={t('loadingInitialValues', 'Loading initial values') + '...'} /> )} </Stack> )} + {showEmptyFormErrorNotification && ( + <InlineNotification + className={styles.emptyFormError} + lowContrast + title={t('error', 'Error')} + subtitle={t('pleaseFillField', 'Please fill at least one field') + '.'} + /> + )} </div> - <ButtonSet className={isTablet ? styles.tablet : styles.desktop}> <Button className={styles.button} kind="secondary" disabled={isSubmitting} onClick={closeWorkspace}> {t('discard', 'Discard')} diff --git a/packages/esm-patient-orders-app/src/lab-results/lab-results-form.scss b/packages/esm-patient-orders-app/src/lab-results/lab-results-form.scss index 9d04d45b1c..259fd5454a 100644 --- a/packages/esm-patient-orders-app/src/lab-results/lab-results-form.scss +++ b/packages/esm-patient-orders-app/src/lab-results/lab-results-form.scss @@ -89,3 +89,7 @@ .spacer { margin-top: layout.$spacing-05; } + +.emptyFormError { + margin: 0.625rem 0; +} diff --git a/packages/esm-patient-orders-app/src/utils/index.ts b/packages/esm-patient-orders-app/src/utils/index.ts index ec019258a9..1f8c556287 100644 --- a/packages/esm-patient-orders-app/src/utils/index.ts +++ b/packages/esm-patient-orders-app/src/utils/index.ts @@ -78,7 +78,7 @@ export function buildLabOrder(order: Order, action?: OrderAction) { careSetting: order.careSetting.uuid, instructions: order.instructions, urgency: order.urgency, - labReferenceNumber: order.labReferenceNumber, + accessionNumber: order.accessionNumber, testType: { label: order.concept.display, conceptUuid: order.concept.uuid, diff --git a/packages/esm-patient-orders-app/translations/en.json b/packages/esm-patient-orders-app/translations/en.json index 0de477794f..1ac3c77404 100644 --- a/packages/esm-patient-orders-app/translations/en.json +++ b/packages/esm-patient-orders-app/translations/en.json @@ -47,6 +47,7 @@ "orders": "Orders", "Orders": "Orders", "orderType": "Order type", + "pleaseFillField": "Please fill at least one field", "pleaseFillRequiredFields": "Please fill all the required fields", "print": "Print", "printedBy": "Printed by", @@ -55,7 +56,6 @@ "reasonForCancellation": "Reason for cancellation", "reasonForCancellationRequired": "Reason for cancellation is required", "refills": "Refills", - "required": "Required", "result": "Result", "saveAndClose": "Save and close", "saveDrugOrderFailed": "Error ordering {{orderName}}", diff --git a/yarn.lock b/yarn.lock index d95d1e6437..301eb0a3ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5330,8 +5330,8 @@ __metadata: linkType: soft "@openmrs/esm-form-engine-lib@npm:next": - version: 2.1.0-pre.1382 - resolution: "@openmrs/esm-form-engine-lib@npm:2.1.0-pre.1382" + version: 2.1.0-pre.1384 + resolution: "@openmrs/esm-form-engine-lib@npm:2.1.0-pre.1384" dependencies: "@carbon/react": "npm:>1.47.0 <1.50.0" classnames: "npm:^2.5.1" @@ -5349,7 +5349,7 @@ __metadata: react: 18.x react-i18next: 11.x swr: 2.x - checksum: 10/ea696eb023a9a09d0ead5979ed1c5c0c73641175b1c95f83caa357192bd3757942c2f37e3bf9f0c5f2255de241103689e41271668ead82bfa7762701579e84a9 + checksum: 10/85d380e9081ae844865de5425f3f803d947fb28e875eb03cfd9ba8c976b8ead7b7c72fd4ff10608f4b26a6fd831263e27fa113e9528c4c3de4fa632bb4755f8b languageName: node linkType: hard