@mherman22/esm-act-app 1.0.1-pre.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.editorconfig +12 -0
  2. package/.husky/pre-commit +7 -0
  3. package/.husky/pre-push +6 -0
  4. package/.prettierignore +14 -0
  5. package/.turbo.json +18 -0
  6. package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
  7. package/LICENSE +401 -0
  8. package/README.md +115 -0
  9. package/SECURITY.md +12 -0
  10. package/__mocks__/react-i18next.js +50 -0
  11. package/dist/117.js +1 -0
  12. package/dist/117.js.map +1 -0
  13. package/dist/131.css +1 -0
  14. package/dist/131.css.map +1 -0
  15. package/dist/131.js +1 -0
  16. package/dist/131.js.map +1 -0
  17. package/dist/197.js +1 -0
  18. package/dist/197.js.map +1 -0
  19. package/dist/237.js +1 -0
  20. package/dist/237.js.map +1 -0
  21. package/dist/29.js +1 -0
  22. package/dist/29.js.map +1 -0
  23. package/dist/338.css +1 -0
  24. package/dist/338.css.map +1 -0
  25. package/dist/338.js +1 -0
  26. package/dist/338.js.map +1 -0
  27. package/dist/362.js +1 -0
  28. package/dist/362.js.map +1 -0
  29. package/dist/379.js +1 -0
  30. package/dist/379.js.map +1 -0
  31. package/dist/410.js +1 -0
  32. package/dist/410.js.map +1 -0
  33. package/dist/430.css +1 -0
  34. package/dist/430.css.map +1 -0
  35. package/dist/430.js +1 -0
  36. package/dist/430.js.map +1 -0
  37. package/dist/450.css +1 -0
  38. package/dist/450.css.map +1 -0
  39. package/dist/450.js +1 -0
  40. package/dist/450.js.map +1 -0
  41. package/dist/517.js +1 -0
  42. package/dist/517.js.map +1 -0
  43. package/dist/538.js +1 -0
  44. package/dist/538.js.map +1 -0
  45. package/dist/578.js +1 -0
  46. package/dist/578.js.map +1 -0
  47. package/dist/638.js +1 -0
  48. package/dist/638.js.map +1 -0
  49. package/dist/708.css +1 -0
  50. package/dist/708.css.map +1 -0
  51. package/dist/708.js +1 -0
  52. package/dist/708.js.map +1 -0
  53. package/dist/749.js +1 -0
  54. package/dist/749.js.map +1 -0
  55. package/dist/823.css +1 -0
  56. package/dist/823.css.map +1 -0
  57. package/dist/823.js +1 -0
  58. package/dist/823.js.map +1 -0
  59. package/dist/898.js +1 -0
  60. package/dist/898.js.map +1 -0
  61. package/dist/943.js +1 -0
  62. package/dist/943.js.map +1 -0
  63. package/dist/957.css +1 -0
  64. package/dist/957.css.map +1 -0
  65. package/dist/957.js +43 -0
  66. package/dist/957.js.map +1 -0
  67. package/dist/977.js +1 -0
  68. package/dist/977.js.map +1 -0
  69. package/dist/98.js +1 -0
  70. package/dist/98.js.map +1 -0
  71. package/dist/997.js +1 -0
  72. package/dist/997.js.map +1 -0
  73. package/dist/999.js +6 -0
  74. package/dist/999.js.map +1 -0
  75. package/dist/mherman22-esm-act-app.js +3 -0
  76. package/dist/mherman22-esm-act-app.js.buildmanifest.json +781 -0
  77. package/dist/mherman22-esm-act-app.js.map +1 -0
  78. package/dist/routes.json +1 -0
  79. package/eslint.config.mjs +20 -0
  80. package/package.json +99 -0
  81. package/prettier.config.js +8 -0
  82. package/rspack.config.js +1 -0
  83. package/src/access/act-dashboard-link.component.tsx +16 -0
  84. package/src/access/screen-access.component.tsx +15 -0
  85. package/src/access/screen-access.test.tsx +81 -0
  86. package/src/access/sign-in.test-helper.ts +24 -0
  87. package/src/act-home/act-home-card.component.tsx +27 -0
  88. package/src/act-home/act-home-card.scss +25 -0
  89. package/src/act-home/act-home-dashboard-link.component.tsx +7 -0
  90. package/src/act-home/act-home-dashboard-link.test.tsx +32 -0
  91. package/src/act-home/act-home-dashboard.component.tsx +30 -0
  92. package/src/act-home/act-home-dashboard.test.tsx +43 -0
  93. package/src/act-home/act-home.meta.ts +8 -0
  94. package/src/act-home/act-home.scss +22 -0
  95. package/src/act-home/care-cascade.component.tsx +77 -0
  96. package/src/act-home/care-cascade.scss +42 -0
  97. package/src/act-home/care-cascade.test.tsx +128 -0
  98. package/src/act-home/quick-actions.component.tsx +32 -0
  99. package/src/act-home/quick-actions.scss +32 -0
  100. package/src/act-home/quick-actions.test.tsx +51 -0
  101. package/src/act-home/worklist-tiles.component.tsx +69 -0
  102. package/src/act-home/worklist-tiles.scss +50 -0
  103. package/src/act-home/worklist-tiles.test.tsx +133 -0
  104. package/src/config-schema.test.ts +83 -0
  105. package/src/config-schema.ts +129 -0
  106. package/src/declarations.d.ts +19 -0
  107. package/src/fetch-all.ts +20 -0
  108. package/src/flag-gaps/flag-gaps.resource.test.ts +57 -0
  109. package/src/flag-gaps/flag-gaps.resource.ts +69 -0
  110. package/src/flag-gaps/flag-gaps.scss +19 -0
  111. package/src/flag-gaps/flag-gaps.workspace.test.tsx +208 -0
  112. package/src/flag-gaps/flag-gaps.workspace.tsx +149 -0
  113. package/src/index.ts +33 -0
  114. package/src/registry/csv.ts +16 -0
  115. package/src/registry/registry-bpg.test.tsx +84 -0
  116. package/src/registry/registry-csv.test.tsx +100 -0
  117. package/src/registry/registry-dashboard-link.component.tsx +7 -0
  118. package/src/registry/registry-dashboard-link.test.tsx +32 -0
  119. package/src/registry/registry-filters.test.tsx +218 -0
  120. package/src/registry/registry-filters.ts +81 -0
  121. package/src/registry/registry-flags.test.tsx +67 -0
  122. package/src/registry/registry.component.tsx +255 -0
  123. package/src/registry/registry.fixture.ts +14 -0
  124. package/src/registry/registry.meta.ts +6 -0
  125. package/src/registry/registry.scss +41 -0
  126. package/src/registry/registry.test.tsx +147 -0
  127. package/src/reports/report-dataset.resource.test.tsx +189 -0
  128. package/src/reports/report-dataset.resource.ts +51 -0
  129. package/src/reports/report-date.test.ts +18 -0
  130. package/src/reports/report-date.ts +12 -0
  131. package/src/rhd-flags/rhd-flag-lists.resource.test.tsx +240 -0
  132. package/src/rhd-flags/rhd-flag-lists.resource.ts +88 -0
  133. package/src/routes.json +66 -0
  134. package/src/routes.test.ts +32 -0
  135. package/tools/i18next-parser.config.js +89 -0
  136. package/tools/setup-tests.ts +5 -0
  137. package/tools/update-openmrs-deps.mjs +42 -0
  138. package/translations/en.json +52 -0
  139. package/tsconfig.json +24 -0
  140. package/vitest.config.ts +21 -0
@@ -0,0 +1,77 @@
1
+ import React, { useMemo } from 'react';
2
+ import dayjs from 'dayjs';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { InlineNotification, SkeletonText } from '@carbon/react';
5
+ import { formatDate, toOmrsIsoString, useConfig } from '@openmrs/esm-framework';
6
+ import { type Config } from '../config-schema';
7
+ import { ScreenAccess } from '../access/screen-access.component';
8
+ import { useReportDataset } from '../reports/report-dataset.resource';
9
+ import { ActHomeCard } from './act-home-card.component';
10
+ import styles from './care-cascade.scss';
11
+
12
+ function Cascade() {
13
+ const { t } = useTranslation();
14
+ const { careCascade } = useConfig<Config>();
15
+ // The reports app runs a date as that day's midnight, so the counts match the report run for today.
16
+ const today = useMemo(() => dayjs().startOf('day'), []);
17
+ const { rows, isLoading, error } = useReportDataset(careCascade.report, { endDate: toOmrsIsoString(today.toDate()) });
18
+ // The cascade descriptor names its columns step and patients, one row per step.
19
+ const byStep = new Map(rows.map((row) => [String(row.step), Number(row.patients)]));
20
+ const steps = careCascade.steps
21
+ .filter((step) => byStep.has(step))
22
+ .map((step) => ({ step, patients: byStep.get(step) }));
23
+ const largest = Math.max(0, ...steps.map((row) => row.patients));
24
+
25
+ return (
26
+ <ActHomeCard
27
+ title={t('careCascade', 'Care cascade')}
28
+ link={{ label: t('report', 'Report'), to: careCascade.reportUrl }}
29
+ >
30
+ {error ? (
31
+ <InlineNotification
32
+ kind="error"
33
+ lowContrast
34
+ hideCloseButton
35
+ title={t('couldNotLoadCareCascade', 'Could not load the care cascade')}
36
+ />
37
+ ) : isLoading ? (
38
+ <div data-testid="cascade-loading">
39
+ <SkeletonText paragraph lineCount={4} />
40
+ </div>
41
+ ) : steps.length ? (
42
+ <>
43
+ <div className={styles.steps}>
44
+ {steps.map((row) => (
45
+ <div key={row.step} data-testid="cascade-step" className={styles.step}>
46
+ <span>{row.step}</span>
47
+ <span className={styles.track}>
48
+ <span
49
+ data-testid="cascade-bar"
50
+ className={styles.bar}
51
+ style={{ width: `${largest ? Math.round((row.patients / largest) * 100) : 0}%` }}
52
+ />
53
+ </span>
54
+ <span className={styles.count}>{row.patients}</span>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ <p className={styles.asOf}>
59
+ {t('cascadeReportDate', 'Report date: {{date}}', {
60
+ date: formatDate(today.toDate(), { time: false, noToday: true }),
61
+ })}
62
+ </p>
63
+ </>
64
+ ) : (
65
+ <p className={styles.empty}>{t('noCareCascadeData', 'No care cascade data')}</p>
66
+ )}
67
+ </ActHomeCard>
68
+ );
69
+ }
70
+
71
+ export default function CareCascade() {
72
+ return (
73
+ <ScreenAccess screen="home">
74
+ <Cascade />
75
+ </ScreenAccess>
76
+ );
77
+ }
@@ -0,0 +1,42 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .steps {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: layout.$spacing-03;
9
+ }
10
+
11
+ .step {
12
+ @include type.type-style('body-compact-01');
13
+ display: grid;
14
+ grid-template-columns: 12rem 1fr 3rem;
15
+ align-items: center;
16
+ gap: layout.$spacing-04;
17
+ }
18
+
19
+ .track {
20
+ height: layout.$spacing-05;
21
+ }
22
+
23
+ .bar {
24
+ display: block;
25
+ height: 100%;
26
+ @include brand-01(background-color);
27
+ }
28
+
29
+ .count {
30
+ @include type.type-style('heading-compact-01');
31
+ text-align: right;
32
+ }
33
+
34
+ .asOf {
35
+ @include type.type-style('helper-text-01');
36
+ margin-top: layout.$spacing-04;
37
+ color: $text-02;
38
+ }
39
+
40
+ .empty {
41
+ @include type.type-style('body-compact-01');
42
+ }
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { homePrivilege, signInWith } from '../access/sign-in.test-helper';
5
+ import { useReportDataset } from '../reports/report-dataset.resource';
6
+ import CareCascade from './care-cascade.component';
7
+
8
+ vi.mock('../reports/report-dataset.resource', () => ({ useReportDataset: vi.fn() }));
9
+ const mockUseReportDataset = vi.mocked(useReportDataset);
10
+
11
+ const cascade = [
12
+ { step_order: 1, step: 'Active', patients: 20 },
13
+ { step_order: 2, step: 'Prescribed Prophylaxis', patients: 17 },
14
+ { step_order: 3, step: 'Oral', patients: 2 },
15
+ { step_order: 4, step: 'BPG', patients: 15 },
16
+ { step_order: 5, step: 'Initiated BPG', patients: 11 },
17
+ { step_order: 6, step: 'Adherent', patients: 0 },
18
+ ];
19
+
20
+ function dataset(value: Partial<ReturnType<typeof useReportDataset>>) {
21
+ mockUseReportDataset.mockReturnValue({ columns: [], rows: [], isLoading: false, error: undefined, ...value });
22
+ }
23
+
24
+ describe('CareCascade', () => {
25
+ beforeEach(async () => {
26
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
27
+ await signInWith([homePrivilege]);
28
+ });
29
+
30
+ it('draws the configured steps of the cascade report, in the configured order, with their counts', () => {
31
+ dataset({ rows: cascade });
32
+
33
+ render(<CareCascade />);
34
+
35
+ const steps = screen.getAllByTestId('cascade-step');
36
+ expect(steps.map((step) => step.textContent)).toEqual([
37
+ 'Active20',
38
+ 'Prescribed Prophylaxis17',
39
+ 'Initiated BPG11',
40
+ 'Adherent0',
41
+ ]);
42
+ });
43
+
44
+ it('leaves out a configured step the report does not return', async () => {
45
+ await signInWith([homePrivilege], {
46
+ careCascade: { report: 'r', reportUrl: '${openmrsSpaBase}/reports', steps: ['Active', 'Covered today'] },
47
+ });
48
+ dataset({ rows: cascade });
49
+
50
+ render(<CareCascade />);
51
+
52
+ expect(screen.getAllByTestId('cascade-step').map((step) => step.textContent)).toEqual(['Active20']);
53
+ });
54
+
55
+ it('sizes each bar against the largest step', () => {
56
+ dataset({ rows: cascade });
57
+
58
+ render(<CareCascade />);
59
+
60
+ const widths = screen.getAllByTestId('cascade-bar').map((bar) => bar.style.width);
61
+ expect(widths).toEqual(['100%', '85%', '55%', '0%']);
62
+ });
63
+
64
+ it('evaluates the configured report up to the start of today, as the reports app runs it for today', () => {
65
+ vi.useFakeTimers({ now: new Date(2026, 8, 28, 10, 30) });
66
+ dataset({ rows: cascade });
67
+
68
+ render(<CareCascade />);
69
+
70
+ vi.useRealTimers();
71
+ const [report, params] = mockUseReportDataset.mock.calls.at(-1);
72
+ expect(report).toBe('9c6751ae-65fc-5f25-9aa6-8c65cb1dff68');
73
+ expect(new Date(params.endDate).getTime()).toBe(new Date(2026, 8, 28).getTime());
74
+ });
75
+
76
+ it('evaluates the report set in the config', async () => {
77
+ await signInWith([homePrivilege], {
78
+ careCascade: { report: 'RHD Care Cascade', reportUrl: '${openmrsSpaBase}/reports', steps: ['Active'] },
79
+ });
80
+ dataset({ rows: cascade });
81
+
82
+ render(<CareCascade />);
83
+
84
+ expect(mockUseReportDataset.mock.calls.at(-1)[0]).toBe('RHD Care Cascade');
85
+ });
86
+
87
+ it('links to the full report', () => {
88
+ dataset({ rows: cascade });
89
+
90
+ render(<CareCascade />);
91
+
92
+ expect(screen.getByRole('link', { name: /report/i })).toHaveAttribute('href', '/openmrs/spa/reports');
93
+ });
94
+
95
+ it('says so when the report has no rows', () => {
96
+ dataset({ rows: [] });
97
+
98
+ render(<CareCascade />);
99
+
100
+ expect(screen.getByText('No care cascade data')).toBeInTheDocument();
101
+ });
102
+
103
+ it('says so when the report cannot be evaluated', () => {
104
+ dataset({ error: new Error('Server responded with 404') });
105
+
106
+ render(<CareCascade />);
107
+
108
+ expect(screen.getByText('Could not load the care cascade')).toBeInTheDocument();
109
+ expect(screen.queryByTestId('cascade-step')).not.toBeInTheDocument();
110
+ });
111
+
112
+ it('shows a placeholder while the report runs', () => {
113
+ dataset({ isLoading: true });
114
+
115
+ render(<CareCascade />);
116
+
117
+ expect(screen.getByTestId('cascade-loading')).toBeInTheDocument();
118
+ });
119
+
120
+ it('is hidden from a user without the ACT home privilege', async () => {
121
+ await signInWith(['View Patient Flags']);
122
+ dataset({ rows: cascade });
123
+
124
+ render(<CareCascade />);
125
+
126
+ expect(screen.queryByText('Care cascade')).not.toBeInTheDocument();
127
+ });
128
+ });
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Add, ArrowUpRight, Search } from '@carbon/react/icons';
4
+ import { ConfigurableLink, useConfig } from '@openmrs/esm-framework';
5
+ import { type Config } from '../config-schema';
6
+ import { ScreenAccess } from '../access/screen-access.component';
7
+ import styles from './quick-actions.scss';
8
+
9
+ export default function QuickActions() {
10
+ const { t } = useTranslation();
11
+ const { quickActions } = useConfig<Config>();
12
+ const actions = [
13
+ { label: t('registerPatient', 'Register patient'), to: quickActions.registerPatientUrl, Icon: Add },
14
+ { label: t('enterProphylaxis', 'Enter prophylaxis'), to: quickActions.enterProphylaxisUrl, Icon: ArrowUpRight },
15
+ { label: t('findPatient', 'Find a patient'), to: quickActions.findPatientUrl, Icon: Search },
16
+ ];
17
+
18
+ return (
19
+ <ScreenAccess screen="home">
20
+ <div className={styles.actions}>
21
+ {actions.map(({ label, to, Icon }) => (
22
+ <ConfigurableLink key={label} to={to} className={styles.action}>
23
+ <span className={styles.icon}>
24
+ <Icon size={20} />
25
+ </span>
26
+ {label}
27
+ </ConfigurableLink>
28
+ ))}
29
+ </div>
30
+ </ScreenAccess>
31
+ );
32
+ }
@@ -0,0 +1,32 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .actions {
6
+ display: grid;
7
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
8
+ gap: layout.$spacing-05;
9
+ }
10
+
11
+ .action {
12
+ @include type.type-style('heading-compact-02');
13
+ display: flex;
14
+ align-items: center;
15
+ gap: layout.$spacing-05;
16
+ padding: layout.$spacing-05;
17
+ background-color: $ui-01;
18
+ border: 1px solid $ui-03;
19
+ color: $ui-05;
20
+ text-decoration: none;
21
+
22
+ &:hover {
23
+ background-color: $ui-02;
24
+ }
25
+ }
26
+
27
+ .icon {
28
+ display: flex;
29
+ padding: layout.$spacing-03;
30
+ @include brand-01(background-color);
31
+ color: $ui-01;
32
+ }
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { type Config } from '../config-schema';
5
+ import { homePrivilege, signInWith } from '../access/sign-in.test-helper';
6
+ import QuickActions from './quick-actions.component';
7
+
8
+ const defaultQuickActions: Config['quickActions'] = {
9
+ registerPatientUrl: '${openmrsSpaBase}/patient-registration',
10
+ enterProphylaxisUrl: '${openmrsSpaBase}/forms',
11
+ findPatientUrl: '${openmrsSpaBase}/search',
12
+ };
13
+
14
+ describe('QuickActions', () => {
15
+ beforeEach(async () => {
16
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
17
+ await signInWith([homePrivilege]);
18
+ });
19
+
20
+ it('links register patient, enter prophylaxis and find a patient to their screens', () => {
21
+ render(<QuickActions />);
22
+
23
+ expect(screen.getByRole('link', { name: /register patient/i })).toHaveAttribute(
24
+ 'href',
25
+ '/openmrs/spa/patient-registration',
26
+ );
27
+ expect(screen.getByRole('link', { name: /enter prophylaxis/i })).toHaveAttribute('href', '/openmrs/spa/forms');
28
+ expect(screen.getByRole('link', { name: /find a patient/i })).toHaveAttribute('href', '/openmrs/spa/search');
29
+ });
30
+
31
+ it('uses the links set in the config', async () => {
32
+ await signInWith([homePrivilege], {
33
+ quickActions: { ...defaultQuickActions, enterProphylaxisUrl: '${openmrsSpaBase}/forms/prophylaxis' },
34
+ });
35
+
36
+ render(<QuickActions />);
37
+
38
+ expect(screen.getByRole('link', { name: /enter prophylaxis/i })).toHaveAttribute(
39
+ 'href',
40
+ '/openmrs/spa/forms/prophylaxis',
41
+ );
42
+ });
43
+
44
+ it('is hidden from a user without the ACT home privilege', async () => {
45
+ await signInWith(['View Patient Flags']);
46
+
47
+ render(<QuickActions />);
48
+
49
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
50
+ });
51
+ });
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { InlineNotification, SkeletonText } from '@carbon/react';
4
+ import { ConfigurableLink } from '@openmrs/esm-framework';
5
+ import { ScreenAccess } from '../access/screen-access.component';
6
+ import { ActHomeCard } from './act-home-card.component';
7
+ import { type RhdFlagList, useRhdFlagLists } from '../rhd-flags/rhd-flag-lists.resource';
8
+ import styles from './worklist-tiles.scss';
9
+
10
+ const patientListsUrl = '${openmrsSpaBase}/home/patient-lists';
11
+
12
+ function WorklistTile({ list }: { list: RhdFlagList }) {
13
+ const content = (
14
+ <div
15
+ data-testid="worklist-tile"
16
+ data-priority={list.priority}
17
+ className={`${styles.tile} ${styles[list.priority]}`}
18
+ >
19
+ <span className={styles.count}>{list.memberCount}</span>
20
+ <span className={styles.name}>{list.flagName}</span>
21
+ </div>
22
+ );
23
+ return list.cohortUuid ? (
24
+ <ConfigurableLink to={`${patientListsUrl}/${list.cohortUuid}`} className={styles.link}>
25
+ {content}
26
+ </ConfigurableLink>
27
+ ) : (
28
+ content
29
+ );
30
+ }
31
+
32
+ function Worklists() {
33
+ const { t } = useTranslation();
34
+ const { lists, isLoading, error } = useRhdFlagLists();
35
+ const riskFirst = [...lists.filter((l) => l.priority === 'risk'), ...lists.filter((l) => l.priority !== 'risk')];
36
+
37
+ return (
38
+ <ActHomeCard title={t('worklists', 'Worklists')} link={{ label: t('allLists', 'All lists'), to: patientListsUrl }}>
39
+ {error ? (
40
+ <InlineNotification
41
+ kind="error"
42
+ lowContrast
43
+ hideCloseButton
44
+ title={t('couldNotLoadWorklists', 'Could not load the worklists')}
45
+ />
46
+ ) : isLoading ? (
47
+ <div data-testid="worklists-loading">
48
+ <SkeletonText paragraph lineCount={3} />
49
+ </div>
50
+ ) : lists.length === 0 ? (
51
+ <p className={styles.empty}>{t('noRhdFlagLists', 'No RHD flag lists found')}</p>
52
+ ) : (
53
+ <div className={styles.tiles}>
54
+ {riskFirst.map((list) => (
55
+ <WorklistTile key={list.flagName} list={list} />
56
+ ))}
57
+ </div>
58
+ )}
59
+ </ActHomeCard>
60
+ );
61
+ }
62
+
63
+ export default function WorklistTiles() {
64
+ return (
65
+ <ScreenAccess screen="home">
66
+ <Worklists />
67
+ </ScreenAccess>
68
+ );
69
+ }
@@ -0,0 +1,50 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+ @use '@openmrs/esm-styleguide/src/vars' as *;
5
+
6
+ .tiles {
7
+ display: grid;
8
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
9
+ gap: layout.$spacing-04;
10
+ }
11
+
12
+ .link {
13
+ color: inherit;
14
+ text-decoration: none;
15
+ }
16
+
17
+ .tile {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: layout.$spacing-02;
21
+ height: 100%;
22
+ padding: layout.$spacing-04;
23
+ background-color: $ui-01;
24
+ border: 1px solid $ui-03;
25
+ border-top-width: layout.$spacing-02;
26
+ }
27
+
28
+ .link .tile:hover {
29
+ background-color: $ui-03;
30
+ }
31
+
32
+ .risk {
33
+ border-top-color: colors.$red-60;
34
+ }
35
+
36
+ .dataQuality {
37
+ border-top-color: colors.$orange-40;
38
+ }
39
+
40
+ .count {
41
+ @include type.type-style('heading-05');
42
+ }
43
+
44
+ .name {
45
+ @include type.type-style('body-compact-01');
46
+ }
47
+
48
+ .empty {
49
+ @include type.type-style('body-compact-01');
50
+ }
@@ -0,0 +1,133 @@
1
+ import React from 'react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { render, screen, within } from '@testing-library/react';
4
+ import { homePrivilege, signInWith } from '../access/sign-in.test-helper';
5
+ import { type RhdFlagList, useRhdFlagLists } from '../rhd-flags/rhd-flag-lists.resource';
6
+ import WorklistTiles from './worklist-tiles.component';
7
+
8
+ vi.mock('../rhd-flags/rhd-flag-lists.resource', () => ({ useRhdFlagLists: vi.fn() }));
9
+ const mockUseRhdFlagLists = vi.mocked(useRhdFlagLists);
10
+
11
+ const list = (flagName: string, memberCount: number, priority: RhdFlagList['priority'], cohortUuid: string | null) => ({
12
+ flagName,
13
+ memberCount,
14
+ priority,
15
+ cohortUuid,
16
+ });
17
+
18
+ const demoLists: Array<RhdFlagList> = [
19
+ list('RHD prophylaxis overdue', 4, 'risk', 'overdue'),
20
+ list('RHD lost to follow-up', 1, 'risk', 'lost'),
21
+ list('RHD prophylaxis not prescribed', 2, 'dataQuality', 'not-prescribed'),
22
+ list('RHD INR target missing', 2, 'dataQuality', 'inr'),
23
+ list('RHD 30-day follow-up due', 3, 'dataQuality', 'follow-up'),
24
+ list('RHD perfusion issues not recorded', 1, 'dataQuality', 'perfusion'),
25
+ list('RHD site infection not recorded', 1, 'dataQuality', 'infection'),
26
+ list('RHD bacterial sepsis not recorded', 2, 'dataQuality', 'sepsis'),
27
+ list('RHD delivery outcome overdue', 1, 'dataQuality', 'delivery'),
28
+ list('RHD death not recorded on patient', 0, 'dataQuality', 'death'),
29
+ ];
30
+
31
+ function lists(value: Partial<ReturnType<typeof useRhdFlagLists>>) {
32
+ mockUseRhdFlagLists.mockReturnValue({ lists: [], isLoading: false, error: undefined, ...value });
33
+ }
34
+
35
+ describe('WorklistTiles', () => {
36
+ beforeEach(async () => {
37
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
38
+ await signInWith([homePrivilege]);
39
+ });
40
+
41
+ it('shows one tile per list with its count, opening the list in Patient lists', () => {
42
+ lists({ lists: demoLists });
43
+
44
+ render(<WorklistTiles />);
45
+
46
+ const tiles = screen.getAllByTestId('worklist-tile');
47
+ expect(tiles.map((tile) => tile.textContent)).toEqual(demoLists.map((l) => `${l.memberCount}${l.flagName}`));
48
+ expect(screen.getByRole('link', { name: /RHD prophylaxis overdue/ })).toHaveAttribute(
49
+ 'href',
50
+ '/openmrs/spa/home/patient-lists/overdue',
51
+ );
52
+ expect(screen.getByRole('link', { name: /all lists/i })).toHaveAttribute('href', '/openmrs/spa/home/patient-lists');
53
+ });
54
+
55
+ it('puts the risk lists first, each group in the order the lists come', () => {
56
+ lists({ lists: [demoLists[2], demoLists[0], demoLists[3], demoLists[1]] });
57
+
58
+ render(<WorklistTiles />);
59
+
60
+ expect(screen.getAllByTestId('worklist-tile').map((tile) => tile.textContent)).toEqual([
61
+ '4RHD prophylaxis overdue',
62
+ '1RHD lost to follow-up',
63
+ '2RHD prophylaxis not prescribed',
64
+ '2RHD INR target missing',
65
+ ]);
66
+ });
67
+
68
+ it('marks risk lists and missing data lists apart', () => {
69
+ lists({ lists: demoLists });
70
+
71
+ render(<WorklistTiles />);
72
+
73
+ const [overdue, , notPrescribed] = screen.getAllByTestId('worklist-tile');
74
+ expect(overdue).toHaveAttribute('data-priority', 'risk');
75
+ expect(notPrescribed).toHaveAttribute('data-priority', 'dataQuality');
76
+ });
77
+
78
+ it('shows 0 for a list with no members, still opening the list', () => {
79
+ lists({ lists: demoLists });
80
+
81
+ render(<WorklistTiles />);
82
+
83
+ expect(screen.getAllByTestId('worklist-tile').at(-1)).toHaveTextContent('0RHD death not recorded on patient');
84
+ expect(screen.getByRole('link', { name: /RHD death not recorded on patient/ })).toHaveAttribute(
85
+ 'href',
86
+ '/openmrs/spa/home/patient-lists/death',
87
+ );
88
+ });
89
+
90
+ it('shows 0 without a link for a configured flag that has no list yet', () => {
91
+ lists({ lists: [list('RHD new flag', 0, 'dataQuality', null)] });
92
+
93
+ render(<WorklistTiles />);
94
+
95
+ expect(screen.getByTestId('worklist-tile')).toHaveTextContent('0RHD new flag');
96
+ expect(screen.queryByRole('link', { name: /RHD new flag/ })).not.toBeInTheDocument();
97
+ });
98
+
99
+ it('says so when there are no RHD flag lists', () => {
100
+ lists({ lists: [] });
101
+
102
+ render(<WorklistTiles />);
103
+
104
+ expect(screen.getByText('No RHD flag lists found')).toBeInTheDocument();
105
+ expect(screen.queryByTestId('worklist-tile')).not.toBeInTheDocument();
106
+ });
107
+
108
+ it('says so when the lists cannot be loaded', () => {
109
+ lists({ error: new Error('Server responded with 403') });
110
+
111
+ render(<WorklistTiles />);
112
+
113
+ expect(screen.getByText('Could not load the worklists')).toBeInTheDocument();
114
+ expect(screen.queryByTestId('worklist-tile')).not.toBeInTheDocument();
115
+ });
116
+
117
+ it('shows placeholders while the lists load', () => {
118
+ lists({ isLoading: true });
119
+
120
+ render(<WorklistTiles />);
121
+
122
+ expect(screen.getByTestId('worklists-loading')).toBeInTheDocument();
123
+ });
124
+
125
+ it('is hidden from a user without the ACT home privilege', async () => {
126
+ await signInWith(['View Patient Flags']);
127
+ lists({ lists: demoLists });
128
+
129
+ render(<WorklistTiles />);
130
+
131
+ expect(screen.queryByText('Worklists')).not.toBeInTheDocument();
132
+ });
133
+ });