@bathiran212/esm-patient-notes-app 2.0.0
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.
- package/README.md +4 -0
- package/dist/1076.js +1 -0
- package/dist/1076.js.map +1 -0
- package/dist/1339.js +1 -0
- package/dist/1339.js.map +1 -0
- package/dist/1480.js +1 -0
- package/dist/1480.js.map +1 -0
- package/dist/1646.js +1 -0
- package/dist/1646.js.map +1 -0
- package/dist/1789.js +1 -0
- package/dist/1789.js.map +1 -0
- package/dist/1869.js +1 -0
- package/dist/1869.js.map +1 -0
- package/dist/1871.js +1 -0
- package/dist/1871.js.map +1 -0
- package/dist/1877.js +1 -0
- package/dist/1877.js.map +1 -0
- package/dist/2153.js +1 -0
- package/dist/2153.js.map +1 -0
- package/dist/2317.js +1 -0
- package/dist/2317.js.map +1 -0
- package/dist/2416.js +1 -0
- package/dist/2416.js.map +1 -0
- package/dist/2544.js +27 -0
- package/dist/2544.js.map +1 -0
- package/dist/282.js +1 -0
- package/dist/282.js.map +1 -0
- package/dist/2824.js +1 -0
- package/dist/2824.js.map +1 -0
- package/dist/2842.js +1 -0
- package/dist/2842.js.map +1 -0
- package/dist/2881.js +1 -0
- package/dist/2881.js.map +1 -0
- package/dist/3378.js +1 -0
- package/dist/3378.js.map +1 -0
- package/dist/3720.js +1 -0
- package/dist/3720.js.map +1 -0
- package/dist/3963.js +1 -0
- package/dist/3963.js.map +1 -0
- package/dist/3989.js +1 -0
- package/dist/3989.js.map +1 -0
- package/dist/4106.js +1 -0
- package/dist/4106.js.map +1 -0
- package/dist/4111.js +1 -0
- package/dist/4111.js.map +1 -0
- package/dist/434.js +1 -0
- package/dist/434.js.map +1 -0
- package/dist/4348.js +1 -0
- package/dist/4348.js.map +1 -0
- package/dist/4383.js +1 -0
- package/dist/4383.js.map +1 -0
- package/dist/4658.js +1 -0
- package/dist/4658.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/4928.js +1 -0
- package/dist/4928.js.map +1 -0
- package/dist/5117.js +1 -0
- package/dist/5117.js.map +1 -0
- package/dist/5132.js +1 -0
- package/dist/5132.js.map +1 -0
- package/dist/5145.js +1 -0
- package/dist/5145.js.map +1 -0
- package/dist/5503.js +1 -0
- package/dist/5503.js.map +1 -0
- package/dist/556.js +1 -0
- package/dist/556.js.map +1 -0
- package/dist/5644.js +1 -0
- package/dist/5644.js.map +1 -0
- package/dist/5697.js +1 -0
- package/dist/5697.js.map +1 -0
- package/dist/5861.js +1 -0
- package/dist/5861.js.map +1 -0
- package/dist/5940.js +1 -0
- package/dist/5940.js.map +1 -0
- package/dist/6047.js +1 -0
- package/dist/6047.js.map +1 -0
- package/dist/6371.js +1 -0
- package/dist/6371.js.map +1 -0
- package/dist/6377.js +1 -0
- package/dist/6377.js.map +1 -0
- package/dist/6444.js +1 -0
- package/dist/6444.js.map +1 -0
- package/dist/6508.js +1 -0
- package/dist/6508.js.map +1 -0
- package/dist/6724.js +1 -0
- package/dist/6724.js.map +1 -0
- package/dist/6904.js +1 -0
- package/dist/6904.js.map +1 -0
- package/dist/7045.js +1 -0
- package/dist/7045.js.map +1 -0
- package/dist/7103.js +1 -0
- package/dist/7103.js.map +1 -0
- package/dist/7175.js +1 -0
- package/dist/7175.js.map +1 -0
- package/dist/7182.js +1 -0
- package/dist/7182.js.map +1 -0
- package/dist/7205.js +11 -0
- package/dist/7205.js.map +1 -0
- package/dist/7646.js +17 -0
- package/dist/7646.js.map +1 -0
- package/dist/7742.js +1 -0
- package/dist/7742.js.map +1 -0
- package/dist/7912.js +1 -0
- package/dist/7912.js.map +1 -0
- package/dist/8358.js +1 -0
- package/dist/8358.js.map +1 -0
- package/dist/8359.js +1 -0
- package/dist/8359.js.map +1 -0
- package/dist/8369.js +1 -0
- package/dist/8369.js.map +1 -0
- package/dist/8695.js +1 -0
- package/dist/8695.js.map +1 -0
- package/dist/8722.js +1 -0
- package/dist/8722.js.map +1 -0
- package/dist/903.js +1 -0
- package/dist/903.js.map +1 -0
- package/dist/9061.js +1 -0
- package/dist/9061.js.map +1 -0
- package/dist/9072.js +1 -0
- package/dist/9072.js.map +1 -0
- package/dist/9105.js +1 -0
- package/dist/9105.js.map +1 -0
- package/dist/9712.js +1 -0
- package/dist/9712.js.map +1 -0
- package/dist/9771.js +1 -0
- package/dist/9771.js.map +1 -0
- package/dist/9806.js +1 -0
- package/dist/9806.js.map +1 -0
- package/dist/main.js +16 -0
- package/dist/main.js.map +1 -0
- package/dist/openmrs-esm-patient-notes-app.js +6 -0
- package/dist/openmrs-esm-patient-notes-app.js.buildmanifest.json +1760 -0
- package/dist/openmrs-esm-patient-notes-app.js.map +1 -0
- package/dist/routes.json +1 -0
- package/package.json +58 -0
- package/rspack.config.js +1 -0
- package/src/config-schema.ts +28 -0
- package/src/dashboard.meta.ts +7 -0
- package/src/declarations.d.ts +4 -0
- package/src/index.ts +45 -0
- package/src/notes/notes-overview.extension.tsx +74 -0
- package/src/notes/notes-overview.scss +40 -0
- package/src/notes/notes-overview.test.tsx +101 -0
- package/src/notes/paginated-notes.component.tsx +182 -0
- package/src/notes/visit-note-config-schema.ts +38 -0
- package/src/notes/visit-notes-form.scss +219 -0
- package/src/notes/visit-notes-form.test.tsx +523 -0
- package/src/notes/visit-notes-form.workspace.tsx +853 -0
- package/src/notes/visit-notes.resource.ts +113 -0
- package/src/routes.json +48 -0
- package/src/sticky-notes/delete-sticky-note-button.component.tsx +39 -0
- package/src/sticky-notes/delete-sticky-note-button.scss +13 -0
- package/src/sticky-notes/delete-sticky-note.modal.test.tsx +72 -0
- package/src/sticky-notes/delete-sticky-note.modal.tsx +62 -0
- package/src/sticky-notes/edit-sticky-note-button.component.tsx +20 -0
- package/src/sticky-notes/sticky-note-header-button.component.tsx +100 -0
- package/src/sticky-notes/sticky-note-header-button.scss +38 -0
- package/src/sticky-notes/sticky-note-header-button.test.tsx +182 -0
- package/src/sticky-notes/sticky-note-panel.component.tsx +88 -0
- package/src/sticky-notes/sticky-note-panel.scss +54 -0
- package/src/sticky-notes/sticky-note-panel.test.tsx +66 -0
- package/src/sticky-notes/sticky-note.modal.scss +3 -0
- package/src/sticky-notes/sticky-note.modal.test.tsx +115 -0
- package/src/sticky-notes/sticky-note.modal.tsx +93 -0
- package/src/sticky-notes/sticky-note.resource.test.ts +24 -0
- package/src/sticky-notes/sticky-note.resource.ts +82 -0
- package/src/sticky-notes/utils.test.ts +36 -0
- package/src/sticky-notes/utils.ts +9 -0
- package/src/types/index.ts +203 -0
- package/src/visit-note-action-button.extension.tsx +28 -0
- package/src/visit-note-action-button.test.tsx +42 -0
- package/translations/am.json +55 -0
- package/translations/ar.json +55 -0
- package/translations/ar_SY.json +55 -0
- package/translations/bn.json +55 -0
- package/translations/cs.json +55 -0
- package/translations/de.json +55 -0
- package/translations/en.json +55 -0
- package/translations/en_US.json +55 -0
- package/translations/es.json +55 -0
- package/translations/es_MX.json +55 -0
- package/translations/fr.json +55 -0
- package/translations/he.json +55 -0
- package/translations/hi.json +55 -0
- package/translations/hi_IN.json +55 -0
- package/translations/id.json +55 -0
- package/translations/it.json +55 -0
- package/translations/ka.json +55 -0
- package/translations/km.json +55 -0
- package/translations/ku.json +55 -0
- package/translations/ky.json +55 -0
- package/translations/lg.json +55 -0
- package/translations/ne.json +55 -0
- package/translations/pl.json +55 -0
- package/translations/pt.json +55 -0
- package/translations/pt_BR.json +55 -0
- package/translations/qu.json +55 -0
- package/translations/ro_RO.json +55 -0
- package/translations/ru_RU.json +55 -0
- package/translations/si.json +55 -0
- package/translations/sq.json +55 -0
- package/translations/sw.json +55 -0
- package/translations/sw_KE.json +55 -0
- package/translations/tr.json +55 -0
- package/translations/tr_TR.json +55 -0
- package/translations/uk.json +55 -0
- package/translations/uz.json +55 -0
- package/translations/uz@Latn.json +55 -0
- package/translations/uz_UZ.json +55 -0
- package/translations/vi.json +55 -0
- package/translations/zh.json +55 -0
- package/translations/zh_CN.json +55 -0
- package/translations/zh_TW.json +55 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +4 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { vi, describe, it, expect, test, beforeEach, type Mock } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { showModal, useConfig, useOnClickOutside } from '@openmrs/esm-framework';
|
|
6
|
+
import { mockStickyNote } from '__mocks__';
|
|
7
|
+
import { mockPatient } from 'tools';
|
|
8
|
+
import { type ConfigObject } from '../config-schema';
|
|
9
|
+
import { useStickyNote } from './sticky-note.resource';
|
|
10
|
+
import StickyNoteHeaderButton from './sticky-note-header-button.component';
|
|
11
|
+
|
|
12
|
+
vi.mock('./sticky-note.resource', () => ({
|
|
13
|
+
useStickyNote: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('./sticky-note-panel.component', () => ({ default: () => <div data-testid="sticky-note-panel" /> }));
|
|
17
|
+
|
|
18
|
+
const mockUseStickyNote = useStickyNote as Mock;
|
|
19
|
+
const mockShowModal = showModal as Mock;
|
|
20
|
+
const mockUseOnClickOutside = useOnClickOutside as Mock;
|
|
21
|
+
const mockUseConfig = vi.mocked(useConfig<ConfigObject>);
|
|
22
|
+
|
|
23
|
+
// The framework's jest mock of useOnClickOutside is a no-op. Swap in a minimal real
|
|
24
|
+
// implementation so the "closes on outside click" test can exercise the wiring.
|
|
25
|
+
const useRealOnClickOutside = <T extends HTMLElement>(handler: (e: MouseEvent) => void, active: boolean) => {
|
|
26
|
+
const ref = React.useRef<T>(null);
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
if (!active) return;
|
|
29
|
+
const listener = (e: MouseEvent) => {
|
|
30
|
+
if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) return;
|
|
31
|
+
handler(e);
|
|
32
|
+
};
|
|
33
|
+
window.addEventListener('mousedown', listener);
|
|
34
|
+
return () => window.removeEventListener('mousedown', listener);
|
|
35
|
+
}, [handler, active]);
|
|
36
|
+
return ref;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('StickyNoteHeaderButton', () => {
|
|
40
|
+
const patientUuid = mockPatient.id;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
mockUseConfig.mockReturnValue({ stickyNoteConceptUuid: 'concept-uuid' } as ConfigObject);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does not render when stickyNoteConceptUuid is disabled via empty string', () => {
|
|
47
|
+
mockUseConfig.mockReturnValue({ stickyNoteConceptUuid: '' } as ConfigObject);
|
|
48
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
49
|
+
|
|
50
|
+
const { container } = render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
51
|
+
|
|
52
|
+
expect(container).toBeEmptyDOMElement();
|
|
53
|
+
expect(screen.queryByRole('button', { name: /sticky note/i })).not.toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders a button labelled "Sticky note"', () => {
|
|
57
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
58
|
+
|
|
59
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
60
|
+
|
|
61
|
+
expect(screen.getByRole('button', { name: /sticky note/i })).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('shows a "1" badge when a note exists', () => {
|
|
65
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
66
|
+
|
|
67
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
68
|
+
|
|
69
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not show a badge when no note exists', () => {
|
|
73
|
+
mockUseStickyNote.mockReturnValue({ note: undefined, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
74
|
+
|
|
75
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
76
|
+
|
|
77
|
+
expect(screen.queryByText('1')).not.toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('toggles the panel on click when a note exists', async () => {
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
83
|
+
|
|
84
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
85
|
+
const button = screen.getByRole('button', { name: /sticky note/i });
|
|
86
|
+
|
|
87
|
+
expect(screen.queryByTestId('sticky-note-panel')).not.toBeInTheDocument();
|
|
88
|
+
await user.click(button);
|
|
89
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
90
|
+
await user.click(button);
|
|
91
|
+
expect(screen.queryByTestId('sticky-note-panel')).not.toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('opens the create modal on click when no note exists', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
const mutate = vi.fn();
|
|
97
|
+
mockUseStickyNote.mockReturnValue({ note: undefined, isLoading: false, error: undefined, mutate });
|
|
98
|
+
|
|
99
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
100
|
+
await user.click(screen.getByRole('button', { name: /sticky note/i }));
|
|
101
|
+
|
|
102
|
+
expect(mockShowModal).toHaveBeenCalledWith('sticky-note-modal', expect.objectContaining({ patientUuid, mutate }));
|
|
103
|
+
expect(screen.queryByTestId('sticky-note-panel')).not.toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('opens the panel (not the create modal) while loading', async () => {
|
|
107
|
+
const user = userEvent.setup();
|
|
108
|
+
mockUseStickyNote.mockReturnValue({ note: undefined, isLoading: true, error: undefined, mutate: vi.fn() });
|
|
109
|
+
|
|
110
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
111
|
+
await user.click(screen.getByRole('button', { name: /sticky note/i }));
|
|
112
|
+
|
|
113
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
114
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('opens the panel (not the create modal) when the fetch failed', async () => {
|
|
118
|
+
const user = userEvent.setup();
|
|
119
|
+
mockUseStickyNote.mockReturnValue({
|
|
120
|
+
note: undefined,
|
|
121
|
+
isLoading: false,
|
|
122
|
+
error: new Error('Failed'),
|
|
123
|
+
mutate: vi.fn(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
127
|
+
await user.click(screen.getByRole('button', { name: /sticky note/i }));
|
|
128
|
+
|
|
129
|
+
expect(mockShowModal).not.toHaveBeenCalled();
|
|
130
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('closes the panel on Escape', async () => {
|
|
134
|
+
const user = userEvent.setup();
|
|
135
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
136
|
+
|
|
137
|
+
render(<StickyNoteHeaderButton patientUuid={patientUuid} />);
|
|
138
|
+
await user.click(screen.getByRole('button', { name: /sticky note/i }));
|
|
139
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
140
|
+
|
|
141
|
+
await user.keyboard('{Escape}');
|
|
142
|
+
expect(screen.queryByTestId('sticky-note-panel')).not.toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('closes the panel on outside click', async () => {
|
|
146
|
+
const user = userEvent.setup();
|
|
147
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
148
|
+
mockUseOnClickOutside.mockImplementation(useRealOnClickOutside);
|
|
149
|
+
|
|
150
|
+
render(
|
|
151
|
+
<div>
|
|
152
|
+
<StickyNoteHeaderButton patientUuid={patientUuid} />
|
|
153
|
+
<button type="button">Outside</button>
|
|
154
|
+
</div>,
|
|
155
|
+
);
|
|
156
|
+
await user.click(screen.getByRole('button', { name: /sticky note/i }));
|
|
157
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
158
|
+
|
|
159
|
+
await user.click(screen.getByRole('button', { name: /outside/i }));
|
|
160
|
+
expect(screen.queryByTestId('sticky-note-panel')).not.toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('ignores outside clicks that land inside an open modal', async () => {
|
|
164
|
+
const user = userEvent.setup();
|
|
165
|
+
mockUseStickyNote.mockReturnValue({ note: mockStickyNote, isLoading: false, error: undefined, mutate: vi.fn() });
|
|
166
|
+
mockUseOnClickOutside.mockImplementation(useRealOnClickOutside);
|
|
167
|
+
|
|
168
|
+
render(
|
|
169
|
+
<div>
|
|
170
|
+
<StickyNoteHeaderButton patientUuid={patientUuid} />
|
|
171
|
+
<div role="dialog">
|
|
172
|
+
<button type="button">In modal</button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>,
|
|
175
|
+
);
|
|
176
|
+
await user.click(screen.getByRole('button', { name: /sticky note/i }));
|
|
177
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
178
|
+
|
|
179
|
+
await user.click(screen.getByRole('button', { name: /in modal/i }));
|
|
180
|
+
expect(screen.getByTestId('sticky-note-panel')).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from 'react';
|
|
2
|
+
import { Button, SkeletonText } from '@carbon/react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { formatDate, showModal } from '@openmrs/esm-framework';
|
|
5
|
+
import { type StickyNoteObs } from './sticky-note.resource';
|
|
6
|
+
import { decodeHtmlEntities } from './utils';
|
|
7
|
+
import DeleteStickyNote from './delete-sticky-note-button.component';
|
|
8
|
+
import EditStickyNote from './edit-sticky-note-button.component';
|
|
9
|
+
import styles from './sticky-note-panel.scss';
|
|
10
|
+
|
|
11
|
+
interface StickyNotePanelProps {
|
|
12
|
+
isLoading: boolean;
|
|
13
|
+
error: Error | undefined;
|
|
14
|
+
mutate: () => void;
|
|
15
|
+
note: StickyNoteObs | undefined;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
patientUuid: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const StickyNotePanel: React.FC<StickyNotePanelProps> = ({ error, isLoading, mutate, note, onClose, patientUuid }) => {
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
const decodedValue = useMemo(() => (note ? decodeHtmlEntities(note.value) : ''), [note]);
|
|
23
|
+
|
|
24
|
+
const handleEdit = useCallback(() => {
|
|
25
|
+
if (!note) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dispose = showModal('sticky-note-modal', {
|
|
30
|
+
close: () => dispose(),
|
|
31
|
+
existingNote: note,
|
|
32
|
+
mutate,
|
|
33
|
+
patientUuid,
|
|
34
|
+
});
|
|
35
|
+
}, [mutate, note, patientUuid]);
|
|
36
|
+
|
|
37
|
+
if (isLoading) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={styles.stickyNoteContainer}>
|
|
40
|
+
<div className={styles.noteContent}>
|
|
41
|
+
<SkeletonText paragraph lineCount={3} />
|
|
42
|
+
</div>
|
|
43
|
+
<div className={styles.noteFooter}>
|
|
44
|
+
<SkeletonText width="40%" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (error) {
|
|
51
|
+
return (
|
|
52
|
+
<div className={styles.stickyNoteContainer}>
|
|
53
|
+
<div className={styles.errorState}>
|
|
54
|
+
<p className={styles.errorTitle}>{t('errorLoadingStickyNote', "Couldn't load sticky note.")}</p>
|
|
55
|
+
<Button kind="ghost" size="sm" onClick={() => mutate()}>
|
|
56
|
+
{t('retry', 'Retry')}
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!note) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const creatorDisplay = note.auditInfo?.creator?.display;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={styles.stickyNoteContainer}>
|
|
71
|
+
<div className={styles.noteContent}>
|
|
72
|
+
<p>{decodedValue}</p>
|
|
73
|
+
</div>
|
|
74
|
+
<div className={styles.noteFooter}>
|
|
75
|
+
<div className={styles.noteMetadata}>
|
|
76
|
+
{formatDate(new Date(note.obsDatetime), { mode: 'wide' })}
|
|
77
|
+
{creatorDisplay ? ` · ${creatorDisplay}` : ''}
|
|
78
|
+
</div>
|
|
79
|
+
<div className={styles.noteActions}>
|
|
80
|
+
<EditStickyNote onEdit={handleEdit} />
|
|
81
|
+
<DeleteStickyNote noteUuid={note.uuid} mutate={mutate} onClose={onClose} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export default StickyNotePanel;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/type';
|
|
3
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
4
|
+
|
|
5
|
+
.stickyNoteContainer {
|
|
6
|
+
position: absolute;
|
|
7
|
+
top: 34px;
|
|
8
|
+
right: 0;
|
|
9
|
+
z-index: 99;
|
|
10
|
+
width: 24rem;
|
|
11
|
+
box-shadow:
|
|
12
|
+
0 2px 10px 1px rgba(0, 0, 0, 0.12),
|
|
13
|
+
0 2px 6px 1px rgba(171, 198, 213, 0.12);
|
|
14
|
+
background-color: $ui-background;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.noteContent {
|
|
18
|
+
padding: layout.$spacing-05;
|
|
19
|
+
min-height: 8rem;
|
|
20
|
+
@include type.type-style('body-long-01');
|
|
21
|
+
white-space: pre-wrap;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.noteFooter {
|
|
25
|
+
display: flex;
|
|
26
|
+
justify-content: space-between;
|
|
27
|
+
align-items: center;
|
|
28
|
+
padding: layout.$spacing-03 layout.$spacing-05;
|
|
29
|
+
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
30
|
+
background-color: rgba(255, 255, 255, 0.2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.noteMetadata {
|
|
34
|
+
@include type.type-style('caption-01');
|
|
35
|
+
color: var(--cds-text-02);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.noteActions {
|
|
39
|
+
display: flex;
|
|
40
|
+
gap: layout.$spacing-02;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.errorState {
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
align-items: center;
|
|
47
|
+
text-align: center;
|
|
48
|
+
padding: layout.$spacing-06 layout.$spacing-05;
|
|
49
|
+
gap: layout.$spacing-03;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.errorTitle {
|
|
53
|
+
@include type.type-style('body-short-01');
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { vi, describe, it, expect, type Mock } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { showModal } from '@openmrs/esm-framework';
|
|
6
|
+
import { mockStickyNote } from '__mocks__';
|
|
7
|
+
import { mockPatient } from 'tools';
|
|
8
|
+
import StickyNotePanel from './sticky-note-panel.component';
|
|
9
|
+
|
|
10
|
+
const mockShowModal = showModal as Mock;
|
|
11
|
+
|
|
12
|
+
describe('StickyNotePanel', () => {
|
|
13
|
+
const patientUuid = mockPatient.id;
|
|
14
|
+
const onClose = vi.fn();
|
|
15
|
+
const mutate = vi.fn();
|
|
16
|
+
|
|
17
|
+
const defaultProps = {
|
|
18
|
+
error: undefined,
|
|
19
|
+
isLoading: false,
|
|
20
|
+
mutate,
|
|
21
|
+
onClose,
|
|
22
|
+
patientUuid,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('shows a skeleton while loading', () => {
|
|
26
|
+
render(<StickyNotePanel {...defaultProps} isLoading={true} note={undefined} />);
|
|
27
|
+
|
|
28
|
+
expect(screen.queryByText(/simple notes/i)).not.toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('shows an inline error with a retry button when the fetch fails', async () => {
|
|
32
|
+
const user = userEvent.setup();
|
|
33
|
+
render(<StickyNotePanel {...defaultProps} error={new Error('Failed')} note={undefined} />);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByText(/couldn't load sticky note/i)).toBeInTheDocument();
|
|
36
|
+
const retry = screen.getByRole('button', { name: /retry/i });
|
|
37
|
+
await user.click(retry);
|
|
38
|
+
expect(mutate).toHaveBeenCalledTimes(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders nothing when no note exists and not loading', () => {
|
|
42
|
+
const { container } = render(<StickyNotePanel {...defaultProps} note={undefined} />);
|
|
43
|
+
|
|
44
|
+
expect(container).toBeEmptyDOMElement();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders note text with creator and date metadata', () => {
|
|
48
|
+
render(<StickyNotePanel {...defaultProps} note={mockStickyNote} />);
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText(/simple notes/i)).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText(/Dr\. Ray Romano/)).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('opens the edit modal when the edit icon is clicked', async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
|
|
57
|
+
render(<StickyNotePanel {...defaultProps} note={mockStickyNote} />);
|
|
58
|
+
|
|
59
|
+
await user.click(screen.getByRole('button', { name: /edit sticky note/i }));
|
|
60
|
+
|
|
61
|
+
expect(mockShowModal).toHaveBeenCalledWith(
|
|
62
|
+
'sticky-note-modal',
|
|
63
|
+
expect.objectContaining({ existingNote: mockStickyNote, mutate, patientUuid }),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { showSnackbar, useConfig } from '@openmrs/esm-framework';
|
|
6
|
+
import { mockStickyNote } from '__mocks__';
|
|
7
|
+
import { mockPatient } from 'tools';
|
|
8
|
+
import { type ConfigObject } from '../config-schema';
|
|
9
|
+
import { createStickyNote, updateStickyNote } from './sticky-note.resource';
|
|
10
|
+
import StickyNoteModal from './sticky-note.modal';
|
|
11
|
+
|
|
12
|
+
vi.mock('./sticky-note.resource', () => ({
|
|
13
|
+
createStickyNote: vi.fn(),
|
|
14
|
+
updateStickyNote: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('./utils', () => ({
|
|
18
|
+
decodeHtmlEntities: (text: string) => text,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const mockUseConfig = vi.mocked(useConfig<ConfigObject>);
|
|
22
|
+
const mockShowSnackbar = vi.mocked(showSnackbar);
|
|
23
|
+
const mockCreateStickyNote = vi.mocked(createStickyNote);
|
|
24
|
+
const mockUpdateStickyNote = vi.mocked(updateStickyNote);
|
|
25
|
+
|
|
26
|
+
describe('StickyNoteModal', () => {
|
|
27
|
+
const patientUuid = mockPatient.id;
|
|
28
|
+
const defaultProps = {
|
|
29
|
+
close: vi.fn(),
|
|
30
|
+
mutate: vi.fn(),
|
|
31
|
+
patientUuid,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
mockUseConfig.mockReturnValue({
|
|
36
|
+
stickyNoteConceptUuid: 'concept-uuid',
|
|
37
|
+
} as ConfigObject);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders an empty create form by default', () => {
|
|
41
|
+
render(<StickyNoteModal {...defaultProps} />);
|
|
42
|
+
|
|
43
|
+
expect(screen.getByRole('textbox')).toHaveValue('');
|
|
44
|
+
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('keeps Save disabled when the input is only whitespace', async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(<StickyNoteModal {...defaultProps} />);
|
|
50
|
+
|
|
51
|
+
await user.type(screen.getByRole('textbox'), ' ');
|
|
52
|
+
|
|
53
|
+
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps Save disabled in edit mode when the value is cleared to whitespace', async () => {
|
|
57
|
+
const user = userEvent.setup();
|
|
58
|
+
render(<StickyNoteModal {...defaultProps} existingNote={mockStickyNote} />);
|
|
59
|
+
|
|
60
|
+
const textarea = screen.getByRole('textbox');
|
|
61
|
+
await user.clear(textarea);
|
|
62
|
+
await user.type(textarea, ' ');
|
|
63
|
+
|
|
64
|
+
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('creates a new sticky note and shows a success snackbar', async () => {
|
|
68
|
+
const user = userEvent.setup();
|
|
69
|
+
mockCreateStickyNote.mockResolvedValue({} as any);
|
|
70
|
+
|
|
71
|
+
render(<StickyNoteModal {...defaultProps} />);
|
|
72
|
+
|
|
73
|
+
await user.type(screen.getByRole('textbox'), 'New note');
|
|
74
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
75
|
+
|
|
76
|
+
expect(mockCreateStickyNote).toHaveBeenCalledWith(patientUuid, 'New note', 'concept-uuid');
|
|
77
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
|
|
78
|
+
expect(defaultProps.close).toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('prefills the textarea in edit mode and updates on save', async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
mockUpdateStickyNote.mockResolvedValue({} as any);
|
|
84
|
+
|
|
85
|
+
render(<StickyNoteModal {...defaultProps} existingNote={mockStickyNote} />);
|
|
86
|
+
|
|
87
|
+
const textarea = screen.getByRole('textbox');
|
|
88
|
+
expect(textarea).toHaveValue(mockStickyNote.value);
|
|
89
|
+
|
|
90
|
+
await user.clear(textarea);
|
|
91
|
+
await user.type(textarea, 'Updated note');
|
|
92
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
93
|
+
|
|
94
|
+
expect(mockUpdateStickyNote).toHaveBeenCalledWith(mockStickyNote.uuid, 'Updated note');
|
|
95
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('disables Save when the edit leaves the value unchanged', () => {
|
|
99
|
+
render(<StickyNoteModal {...defaultProps} existingNote={mockStickyNote} />);
|
|
100
|
+
|
|
101
|
+
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('shows an error snackbar when the save fails', async () => {
|
|
105
|
+
const user = userEvent.setup();
|
|
106
|
+
mockCreateStickyNote.mockRejectedValue(new Error('Server error'));
|
|
107
|
+
|
|
108
|
+
render(<StickyNoteModal {...defaultProps} />);
|
|
109
|
+
|
|
110
|
+
await user.type(screen.getByRole('textbox'), 'New note');
|
|
111
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
112
|
+
|
|
113
|
+
expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { Button, InlineLoading, ModalBody, ModalFooter, ModalHeader, TextArea } from '@carbon/react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { getCoreTranslation, showSnackbar, useConfig } from '@openmrs/esm-framework';
|
|
5
|
+
import { type ConfigObject } from '../config-schema';
|
|
6
|
+
import { createStickyNote, type StickyNoteObs, updateStickyNote } from './sticky-note.resource';
|
|
7
|
+
import { decodeHtmlEntities } from './utils';
|
|
8
|
+
import styles from './sticky-note.modal.scss';
|
|
9
|
+
|
|
10
|
+
const MAX_NOTE_LENGTH = 300;
|
|
11
|
+
|
|
12
|
+
interface StickyNoteModalProps {
|
|
13
|
+
close: () => void;
|
|
14
|
+
existingNote?: StickyNoteObs;
|
|
15
|
+
mutate: () => void;
|
|
16
|
+
patientUuid: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const StickyNoteModal: React.FC<StickyNoteModalProps> = ({ close, existingNote, mutate, patientUuid }) => {
|
|
20
|
+
const { t } = useTranslation();
|
|
21
|
+
const { stickyNoteConceptUuid } = useConfig<ConfigObject>();
|
|
22
|
+
const decodedExistingValue = useMemo(
|
|
23
|
+
() => (existingNote?.value ? decodeHtmlEntities(existingNote.value) : ''),
|
|
24
|
+
[existingNote],
|
|
25
|
+
);
|
|
26
|
+
const [value, setValue] = useState(decodedExistingValue);
|
|
27
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
28
|
+
|
|
29
|
+
const isEditMode = Boolean(existingNote);
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
const isUnchanged = isEditMode && trimmed === decodedExistingValue.trim();
|
|
32
|
+
|
|
33
|
+
const handleSave = useCallback(async () => {
|
|
34
|
+
if (!trimmed || isUnchanged) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setIsSaving(true);
|
|
38
|
+
try {
|
|
39
|
+
if (isEditMode) {
|
|
40
|
+
await updateStickyNote(existingNote.uuid, trimmed);
|
|
41
|
+
} else {
|
|
42
|
+
await createStickyNote(patientUuid, trimmed, stickyNoteConceptUuid);
|
|
43
|
+
}
|
|
44
|
+
showSnackbar({
|
|
45
|
+
kind: 'success',
|
|
46
|
+
title: isEditMode
|
|
47
|
+
? t('stickyNoteUpdated', 'Sticky note updated')
|
|
48
|
+
: t('stickyNoteCreated', 'Sticky note created'),
|
|
49
|
+
});
|
|
50
|
+
mutate();
|
|
51
|
+
close();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
showSnackbar({
|
|
54
|
+
kind: 'error',
|
|
55
|
+
title: t('errorSavingStickyNote', 'Error saving sticky note'),
|
|
56
|
+
subtitle: error instanceof Error ? error.message : undefined,
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
setIsSaving(false);
|
|
60
|
+
}
|
|
61
|
+
}, [close, existingNote, isEditMode, isUnchanged, mutate, patientUuid, stickyNoteConceptUuid, t, trimmed]);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<ModalHeader closeModal={close} title={t('stickyNote', 'Sticky note')} />
|
|
66
|
+
<ModalBody>
|
|
67
|
+
<TextArea
|
|
68
|
+
id="sticky-note-text"
|
|
69
|
+
labelText={t('stickyNote', 'Sticky note')}
|
|
70
|
+
hideLabel
|
|
71
|
+
value={value}
|
|
72
|
+
onChange={(e) => setValue(e.target.value)}
|
|
73
|
+
rows={4}
|
|
74
|
+
maxLength={MAX_NOTE_LENGTH}
|
|
75
|
+
enableCounter
|
|
76
|
+
counterMode="character"
|
|
77
|
+
className={styles.textArea}
|
|
78
|
+
placeholder={t('enterNotePlaceholder', 'Enter your sticky note here...')}
|
|
79
|
+
/>
|
|
80
|
+
</ModalBody>
|
|
81
|
+
<ModalFooter>
|
|
82
|
+
<Button kind="secondary" onClick={close} disabled={isSaving}>
|
|
83
|
+
{getCoreTranslation('cancel')}
|
|
84
|
+
</Button>
|
|
85
|
+
<Button kind="primary" onClick={handleSave} disabled={!trimmed || isUnchanged || isSaving}>
|
|
86
|
+
{isSaving ? <InlineLoading description={t('saving', 'Saving') + '...'} /> : getCoreTranslation('save')}
|
|
87
|
+
</Button>
|
|
88
|
+
</ModalFooter>
|
|
89
|
+
</>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export default StickyNoteModal;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isStandaloneStickyNote, type StickyNoteObs } from './sticky-note.resource';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const baseObs: StickyNoteObs = {
|
|
5
|
+
uuid: 'uuid',
|
|
6
|
+
value: 'note text',
|
|
7
|
+
obsDatetime: '2026-01-20T11:38:02+00:00',
|
|
8
|
+
encounter: null,
|
|
9
|
+
formNamespaceAndPath: null,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('isStandaloneStickyNote', () => {
|
|
13
|
+
it('accepts an obs with no encounter and no form binding', () => {
|
|
14
|
+
expect(isStandaloneStickyNote(baseObs)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects an obs attached to an encounter', () => {
|
|
18
|
+
expect(isStandaloneStickyNote({ ...baseObs, encounter: { uuid: 'encounter-uuid' } })).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects an obs created via a form engine', () => {
|
|
22
|
+
expect(isStandaloneStickyNote({ ...baseObs, formNamespaceAndPath: 'form-ns^form-path' })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|