@openmrs/esm-react-utils 3.4.1-pre.151 → 3.4.1-pre.161

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/jest.config.js CHANGED
@@ -1,10 +1,11 @@
1
1
  module.exports = {
2
2
  transform: {
3
- "^.+\\.tsx?$": ["@swc/jest"],
3
+ "^.+\\.(j|t)sx?$": ["@swc/jest"],
4
4
  },
5
- setupFiles: ["<rootDir>/src/setup-tests.js"],
5
+ setupFilesAfterEnv: ["<rootDir>/src/setup-tests.js"],
6
6
  moduleNameMapper: {
7
7
  "lodash-es": "lodash",
8
+ "^lodash-es/(.*)$": "lodash/$1",
8
9
  "@openmrs/esm-error-handling":
9
10
  "<rootDir>/__mocks__/openmrs-esm-error-handling.mock.ts",
10
11
  "@openmrs/esm-state": "<rootDir>/__mocks__/openmrs-esm-state.mock.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-react-utils",
3
- "version": "3.4.1-pre.151",
3
+ "version": "3.4.1-pre.161",
4
4
  "license": "MPL-2.0",
5
5
  "description": "React utilities for OpenMRS.",
6
6
  "browser": "dist/openmrs-esm-react-utils.js",
@@ -55,11 +55,11 @@
55
55
  "react-i18next": "11.x"
56
56
  },
57
57
  "devDependencies": {
58
- "@openmrs/esm-api": "^3.4.1-pre.151",
59
- "@openmrs/esm-config": "^3.4.1-pre.151",
60
- "@openmrs/esm-error-handling": "^3.4.1-pre.151",
61
- "@openmrs/esm-extensions": "^3.4.1-pre.151",
62
- "@openmrs/esm-globals": "^3.4.1-pre.151",
58
+ "@openmrs/esm-api": "^3.4.1-pre.161",
59
+ "@openmrs/esm-config": "^3.4.1-pre.161",
60
+ "@openmrs/esm-error-handling": "^3.4.1-pre.161",
61
+ "@openmrs/esm-extensions": "^3.4.1-pre.161",
62
+ "@openmrs/esm-globals": "^3.4.1-pre.161",
63
63
  "dayjs": "^1.10.8",
64
64
  "i18next": "^19.6.0",
65
65
  "react": "^16.13.1",
@@ -68,5 +68,5 @@
68
68
  "rxjs": "^6.5.3",
69
69
  "unistore": "^3.5.2"
70
70
  },
71
- "gitHead": "f8709e7ec5c11e10682ef7e2f1463c4d343c22e5"
71
+ "gitHead": "6db8e72832eb5cd8ab262dbaab7e8eaf841f92fd"
72
72
  }
package/src/Extension.tsx CHANGED
@@ -1,10 +1,18 @@
1
1
  import { renderExtension } from "@openmrs/esm-extensions";
2
- import React, { useCallback, useContext, useEffect, useState } from "react";
2
+ import React, {
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { Parcel } from "single-spa";
3
10
  import { ComponentContext } from ".";
4
11
  import { ExtensionData } from "./ComponentContext";
5
12
 
6
13
  export interface ExtensionProps {
7
14
  state?: Record<string, any>;
15
+ /** @deprecated Pass a function as the child of `ExtensionSlot` instead. */
8
16
  wrap?(
9
17
  slot: React.ReactNode,
10
18
  extension: ExtensionData
@@ -23,14 +31,21 @@ export interface ExtensionProps {
23
31
  export const Extension: React.FC<ExtensionProps> = ({ state, wrap }) => {
24
32
  const [domElement, setDomElement] = useState<HTMLDivElement>();
25
33
  const { extension } = useContext(ComponentContext);
34
+ const parcel = useRef<Parcel | null>();
35
+
36
+ if (wrap) {
37
+ console.warn(
38
+ "`wrap` prop of Extension is being used. This will be removed in a future release."
39
+ );
40
+ }
26
41
 
27
42
  const ref = useCallback((node) => {
28
43
  setDomElement(node);
29
44
  }, []);
30
45
 
31
46
  useEffect(() => {
32
- if (domElement != null && extension) {
33
- return renderExtension(
47
+ if (domElement != null && extension && !parcel.current) {
48
+ parcel.current = renderExtension(
34
49
  domElement,
35
50
  extension.extensionSlotName,
36
51
  extension.extensionSlotModuleName,
@@ -38,6 +53,9 @@ export const Extension: React.FC<ExtensionProps> = ({ state, wrap }) => {
38
53
  undefined,
39
54
  state
40
55
  );
56
+ return () => {
57
+ parcel.current && parcel.current.unmount();
58
+ };
41
59
  }
42
60
  }, [
43
61
  extension?.extensionSlotName,
@@ -47,9 +65,15 @@ export const Extension: React.FC<ExtensionProps> = ({ state, wrap }) => {
47
65
  domElement,
48
66
  ]);
49
67
 
50
- // The extension is rendered into the `<slot>`. It is surrounded by a
51
- // `<div>` with relative positioning in order to allow the UI Editor
52
- // to absolutely position elements within it.
68
+ useEffect(() => {
69
+ if (parcel.current && parcel.current.update) {
70
+ parcel.current.update({ ...state });
71
+ }
72
+ }, [parcel.current, state]);
73
+
74
+ // The extension is rendered into the `<div>`. The `<div>` has relative
75
+ // positioning in order to allow the UI Editor to absolutely position
76
+ // elements within it.
53
77
  const slot = (
54
78
  <div
55
79
  ref={ref}
@@ -4,41 +4,6 @@ import { ComponentContext } from "./ComponentContext";
4
4
  import { Extension } from "./Extension";
5
5
  import { useExtensionSlot } from "./useExtensionSlot";
6
6
 
7
- function isShallowEqual(prevDeps: any, nextDeps: any) {
8
- if (prevDeps === nextDeps) {
9
- return true;
10
- }
11
-
12
- if (!prevDeps && nextDeps) {
13
- return false;
14
- }
15
-
16
- if (prevDeps && !nextDeps) {
17
- return false;
18
- }
19
-
20
- if (typeof prevDeps !== "object" || typeof nextDeps !== "object") {
21
- return false;
22
- }
23
-
24
- const prev = Object.keys(prevDeps);
25
- const next = Object.keys(nextDeps);
26
-
27
- if (prev.length !== next.length) {
28
- return false;
29
- }
30
-
31
- for (let i = 0; i < prev.length; i++) {
32
- const key = prev[i];
33
-
34
- if (!(key in nextDeps) || nextDeps[key] !== prevDeps[key]) {
35
- return false;
36
- }
37
- }
38
-
39
- return true;
40
- }
41
-
42
7
  export interface ExtensionSlotBaseProps {
43
8
  name: string;
44
9
  /** @deprecated Use `name` */
@@ -59,51 +24,94 @@ export type ExtensionSlotProps = (
59
24
  | OldExtensionSlotBaseProps
60
25
  | ExtensionSlotBaseProps
61
26
  ) &
62
- React.HTMLAttributes<HTMLDivElement>;
27
+ React.HTMLAttributes<HTMLDivElement> & {
28
+ children?:
29
+ | React.ReactNode
30
+ | ((extension: ConnectedExtension) => React.ReactNode);
31
+ };
63
32
 
64
33
  function defaultSelect(extensions: Array<ConnectedExtension>) {
65
34
  return extensions;
66
35
  }
67
36
 
68
- export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
69
- name: goodName,
70
- extensionSlotName,
37
+ /**
38
+ * An [extension slot](https://o3-dev.docs.openmrs.org/#/main/extensions).
39
+ * A place with a name. Extensions that get connected to that name
40
+ * will be rendered into this.
41
+ *
42
+ * @param props.name The name of the extension slot
43
+ * @param props.select An optional function for filtering or otherwise modifying
44
+ * the list of extensions that will be rendered.
45
+ * @param props.state *Only works if no children are provided*. Passes data
46
+ * through as props to the extensions that are mounted here. If `ExtensionSlot`
47
+ * has children, you must pass the state through the `state` param of the
48
+ * `Extension` component.
49
+ * @param props.children There are two different ways to use `ExtensionSlot`
50
+ * children.
51
+ * - Passing a `ReactNode`, the "normal" way. The child must contain the component
52
+ * `Extension`. Whatever is passed as the child will be rendered once per extension.
53
+ * See the first example below.
54
+ * - Passing a function, the "render props" way. The child must be a function
55
+ * which takes a [[ConnectedExtension]] as argument and returns a `ReactNode`.
56
+ * the resulting react node must contain the component `Extension`. It will
57
+ * be run for each extension. See the second example below.
58
+ *
59
+ * @example
60
+ * Passing a react node as children
61
+ *
62
+ * ```tsx
63
+ * <ExtensionSlot name="Foo">
64
+ * <div style={{ width: 10rem }}>
65
+ * <Extension />
66
+ * </div>
67
+ * </ExtensionSlot>
68
+ * ```
69
+ *
70
+ * @example
71
+ * Passing a function as children
72
+ *
73
+ * ```tsx
74
+ * <ExtensionSlot name="Bar">
75
+ * {(extension) => (
76
+ * <h1>{extension.name}</h1>
77
+ * <div style={{ color: extension.meta.color }}>
78
+ * <Extension />
79
+ * </div>
80
+ * )}
81
+ * </ExtensionSlot>
82
+ * ```
83
+ *
84
+ */
85
+ export function ExtensionSlot({
86
+ name: extensionSlotName,
87
+ extensionSlotName: legacyExtensionSlotName,
71
88
  select = defaultSelect,
72
89
  children,
73
90
  state,
74
91
  style,
75
92
  ...divProps
76
- }: ExtensionSlotProps) => {
77
- const name = (goodName ?? extensionSlotName) as string;
93
+ }: ExtensionSlotProps) {
94
+ if (children && state) {
95
+ throw new Error(
96
+ "Both children and state have been provided. If children are provided, the state must be passed as a prop to the `Extension` component."
97
+ );
98
+ }
99
+
100
+ const name = (extensionSlotName ?? legacyExtensionSlotName) as string;
78
101
  const slotRef = useRef(null);
79
102
  const { extensions, extensionSlotModuleName } = useExtensionSlot(name);
80
- const stateRef = useRef(state);
81
-
82
- if (!isShallowEqual(stateRef.current, state)) {
83
- stateRef.current = state;
84
- }
85
103
 
86
- const content = useMemo(
87
- () =>
88
- name &&
89
- select(extensions).map((extension) => (
90
- <ComponentContext.Provider
91
- key={extension.id}
92
- value={{
93
- moduleName: extensionSlotModuleName, // moduleName is not used by the receiving Extension
94
- extension: {
95
- extensionId: extension.id,
96
- extensionSlotName: name,
97
- extensionSlotModuleName,
98
- },
99
- }}
100
- >
101
- {children ?? <Extension state={stateRef.current} />}
102
- </ComponentContext.Provider>
103
- )),
104
- [select, extensions, name, stateRef.current]
104
+ const extensionsToRender = useMemo(
105
+ () => select(extensions),
106
+ [select, extensions]
105
107
  );
106
108
 
109
+ const extensionsFromChildrenFunction = useMemo(() => {
110
+ if (typeof children == "function" && !React.isValidElement(children)) {
111
+ return extensionsToRender.map((extension) => children(extension));
112
+ }
113
+ }, [children, extensionsToRender]);
114
+
107
115
  return (
108
116
  <div
109
117
  ref={slotRef}
@@ -112,7 +120,24 @@ export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
112
120
  style={{ ...style, position: "relative" }}
113
121
  {...divProps}
114
122
  >
115
- {content}
123
+ {name &&
124
+ extensionsToRender.map((extension, i) => (
125
+ <ComponentContext.Provider
126
+ key={extension.id}
127
+ value={{
128
+ moduleName: extensionSlotModuleName, // moduleName is not used by the receiving Extension
129
+ extension: {
130
+ extensionId: extension.id,
131
+ extensionSlotName: name,
132
+ extensionSlotModuleName,
133
+ },
134
+ }}
135
+ >
136
+ {extensionsFromChildrenFunction?.[i] ?? children ?? (
137
+ <Extension state={state} />
138
+ )}
139
+ </ComponentContext.Provider>
140
+ ))}
116
141
  </div>
117
142
  );
118
- };
143
+ }
@@ -0,0 +1,272 @@
1
+ import React, { useCallback, useReducer } from "react";
2
+ import { render, screen, waitFor, within } from "@testing-library/react";
3
+ import {
4
+ attach,
5
+ ConnectedExtension,
6
+ getExtensionNameFromId,
7
+ registerExtension,
8
+ updateInternalExtensionStore,
9
+ } from "@openmrs/esm-extensions";
10
+ import {
11
+ getSyncLifecycle,
12
+ Extension,
13
+ ExtensionSlot,
14
+ openmrsComponentDecorator,
15
+ useExtensionSlotMeta,
16
+ ExtensionData,
17
+ } from ".";
18
+ import userEvent from "@testing-library/user-event";
19
+
20
+ // For some reason in the text context `isEqual` always returns true
21
+ // when using the import substitution in jest.config.js. Here's a custom
22
+ // mock.
23
+ jest.mock(
24
+ "lodash-es/isEqual",
25
+ () => (a, b) => JSON.stringify(a) == JSON.stringify(b)
26
+ );
27
+
28
+ describe("ExtensionSlot, Extension, and useExtensionSlotMeta", () => {
29
+ beforeEach(() => {
30
+ updateInternalExtensionStore(() => ({ slots: {}, extensions: {} }));
31
+ });
32
+
33
+ test("Extension receives state changes passed through (not using <Extension>)", async () => {
34
+ function EnglishExtension({ suffix }) {
35
+ return <div>English{suffix}</div>;
36
+ }
37
+ registerSimpleExtension("English", "esm-languages-app", EnglishExtension);
38
+ attach("Box", "English");
39
+ const App = openmrsComponentDecorator({
40
+ moduleName: "esm-languages-app",
41
+ featureName: "Languages",
42
+ disableTranslations: true,
43
+ })(() => {
44
+ const [suffix, toggleSuffix] = useReducer(
45
+ (suffix) => (suffix == "!" ? "?" : "!"),
46
+ "!"
47
+ );
48
+ return (
49
+ <div>
50
+ <ExtensionSlot name="Box" state={{ suffix }} />
51
+ <button onClick={toggleSuffix}>Toggle suffix</button>
52
+ </div>
53
+ );
54
+ });
55
+ render(<App />);
56
+
57
+ await waitFor(() =>
58
+ expect(screen.getByText(/English/)).toBeInTheDocument()
59
+ );
60
+ expect(screen.getByText(/English/)).toHaveTextContent("English!");
61
+ userEvent.click(screen.getByText("Toggle suffix"));
62
+ await waitFor(() =>
63
+ expect(screen.getByText(/English/)).toHaveTextContent("English?")
64
+ );
65
+ });
66
+
67
+ test("Extension receives state changes (using <Extension>)", async () => {
68
+ function HaitianCreoleExtension({ suffix }) {
69
+ return <div>Haitian Creole{suffix}</div>;
70
+ }
71
+ registerSimpleExtension(
72
+ "Haitian",
73
+ "esm-languages-app",
74
+ HaitianCreoleExtension
75
+ );
76
+ attach("Box", "Haitian");
77
+ const App = openmrsComponentDecorator({
78
+ moduleName: "esm-languages-app",
79
+ featureName: "Languages",
80
+ disableTranslations: true,
81
+ })(() => {
82
+ const [suffix, toggleSuffix] = useReducer(
83
+ (suffix) => (suffix == "!" ? "?" : "!"),
84
+ "!"
85
+ );
86
+
87
+ return (
88
+ <div>
89
+ <ExtensionSlot name="Box">
90
+ {suffix}
91
+ <Extension state={{ suffix }} />
92
+ </ExtensionSlot>
93
+ <button onClick={toggleSuffix}>Toggle suffix</button>
94
+ </div>
95
+ );
96
+ });
97
+ render(<App />);
98
+
99
+ await waitFor(() =>
100
+ expect(screen.getByText(/Haitian/)).toBeInTheDocument()
101
+ );
102
+ expect(screen.getByText(/Haitian/)).toHaveTextContent("Haitian Creole!");
103
+ userEvent.click(screen.getByText("Toggle suffix"));
104
+ await waitFor(() =>
105
+ expect(screen.getByText(/Haitian/)).toHaveTextContent("Haitian Creole?")
106
+ );
107
+ });
108
+
109
+ test("ExtensionSlot throws error if both state and children provided", () => {
110
+ const App = () => (
111
+ <ExtensionSlot name="Box" state={{ color: "red" }}>
112
+ <Extension />
113
+ </ExtensionSlot>
114
+ );
115
+ expect(() => render(<App />)).toThrowError(
116
+ expect.objectContaining({
117
+ message: expect.stringMatching(/children.*state/),
118
+ })
119
+ );
120
+ });
121
+
122
+ test("Extension Slot receives meta", async () => {
123
+ registerSimpleExtension("Spanish", "esm-languages-app", undefined, {
124
+ code: "es",
125
+ });
126
+ attach("Box", "Spanish");
127
+ const App = openmrsComponentDecorator({
128
+ moduleName: "esm-languages-app",
129
+ featureName: "Languages",
130
+ disableTranslations: true,
131
+ })(() => {
132
+ const metas = useExtensionSlotMeta("Box");
133
+ const wrapItem = useCallback(
134
+ (slot: React.ReactNode, extension: ExtensionData) => {
135
+ return (
136
+ <div>
137
+ <h1>
138
+ {metas[getExtensionNameFromId(extension.extensionId)].code}
139
+ </h1>
140
+ {slot}
141
+ </div>
142
+ );
143
+ },
144
+ [metas]
145
+ );
146
+ return (
147
+ <div>
148
+ <ExtensionSlot name="Box">
149
+ <Extension wrap={wrapItem} />
150
+ </ExtensionSlot>
151
+ </div>
152
+ );
153
+ });
154
+ render(<App />);
155
+
156
+ await waitFor(() =>
157
+ expect(screen.getByRole("heading")).toBeInTheDocument()
158
+ );
159
+ expect(screen.getByRole("heading")).toHaveTextContent("es");
160
+ expect(screen.getByText("Spanish")).toBeInTheDocument();
161
+ });
162
+
163
+ test("Both meta and state can be used at the same time", async () => {
164
+ function SwahiliExtension({ suffix }) {
165
+ return <div>Swahili{suffix}</div>;
166
+ }
167
+ registerSimpleExtension("Swahili", "esm-languages-app", SwahiliExtension, {
168
+ code: "sw",
169
+ });
170
+ attach("Box", "Swahili");
171
+ const App = openmrsComponentDecorator({
172
+ moduleName: "esm-languages-app",
173
+ featureName: "Languages",
174
+ disableTranslations: true,
175
+ })(() => {
176
+ const [suffix, toggleSuffix] = useReducer(
177
+ (suffix) => (suffix == "!" ? "?" : "!"),
178
+ "!"
179
+ );
180
+ const metas = useExtensionSlotMeta("Box");
181
+ const wrapItem = useCallback(
182
+ (slot: React.ReactNode, extension: ExtensionData) => {
183
+ return (
184
+ <div>
185
+ <h1>
186
+ {metas[getExtensionNameFromId(extension.extensionId)].code}
187
+ </h1>
188
+ {slot}
189
+ </div>
190
+ );
191
+ },
192
+ [metas]
193
+ );
194
+ return (
195
+ <div>
196
+ <ExtensionSlot name="Box">
197
+ <Extension wrap={wrapItem} state={{ suffix }} />
198
+ </ExtensionSlot>
199
+ <button onClick={toggleSuffix}>Toggle suffix</button>
200
+ </div>
201
+ );
202
+ });
203
+ render(<App />);
204
+
205
+ await waitFor(() =>
206
+ expect(screen.getByRole("heading")).toBeInTheDocument()
207
+ );
208
+ expect(screen.getByRole("heading")).toHaveTextContent("sw");
209
+ expect(screen.getByText(/Swahili/)).toHaveTextContent("Swahili!");
210
+ userEvent.click(screen.getByText("Toggle suffix"));
211
+ await waitFor(() =>
212
+ expect(screen.getByText(/Swahili/)).toHaveTextContent("Swahili?")
213
+ );
214
+ });
215
+
216
+ test("Extension Slot renders function children", async () => {
217
+ registerSimpleExtension("Urdu", "esm-languages-app", undefined, {
218
+ code: "urd",
219
+ });
220
+ registerSimpleExtension("Hindi", "esm-languages-app", undefined, {
221
+ code: "hi",
222
+ });
223
+ attach("Box", "Urdu");
224
+ attach("Box", "Hindi");
225
+ const App = openmrsComponentDecorator({
226
+ moduleName: "esm-languages-app",
227
+ featureName: "Languages",
228
+ disableTranslations: true,
229
+ })(() => {
230
+ return (
231
+ <div>
232
+ <ExtensionSlot name="Box">
233
+ {(extension: ConnectedExtension) => (
234
+ <div data-testid={extension.name}>
235
+ <h2>{extension.meta.code}</h2>
236
+ <Extension />
237
+ </div>
238
+ )}
239
+ </ExtensionSlot>
240
+ </div>
241
+ );
242
+ });
243
+ render(<App />);
244
+
245
+ await waitFor(() => expect(screen.getByTestId("Urdu")).toBeInTheDocument());
246
+ expect(
247
+ within(screen.getByTestId("Urdu")).getByRole("heading")
248
+ ).toHaveTextContent("urd");
249
+ expect(
250
+ within(screen.getByTestId("Hindi")).getByRole("heading")
251
+ ).toHaveTextContent("hi");
252
+ });
253
+ });
254
+
255
+ function registerSimpleExtension(
256
+ name: string,
257
+ moduleName: string,
258
+ Component?: React.ComponentType<any>,
259
+ meta: object = {}
260
+ ) {
261
+ const SimpleComponent = () => <div>{name}</div>;
262
+ registerExtension({
263
+ name,
264
+ moduleName,
265
+ load: getSyncLifecycle(Component ?? SimpleComponent, {
266
+ moduleName,
267
+ featureName: moduleName,
268
+ disableTranslations: true,
269
+ }),
270
+ meta,
271
+ });
272
+ }
package/src/public.ts CHANGED
@@ -12,7 +12,6 @@ export * from "./useConnectedExtensions";
12
12
  export * from "./useConnectivity";
13
13
  export * from "./usePatient";
14
14
  export * from "./useCurrentPatient";
15
- export * from "./useExtensionSlot";
16
15
  export * from "./useExtensionSlotMeta";
17
16
  export * from "./useExtensionStore";
18
17
  export * from "./useLayoutType";
@@ -1,3 +1,5 @@
1
+ import "@testing-library/jest-dom/extend-expect";
2
+
1
3
  window.System = {
2
4
  import: (name) => import(name),
3
5
  resolve: jest.fn().mockImplementation(() => {
@@ -1,7 +1,7 @@
1
1
  /** @module @category Extension */
2
2
  import { useEffect, useState } from "react";
3
3
  import { getExtensionStore } from "@openmrs/esm-extensions";
4
- import { isEqual } from "lodash";
4
+ import isEqual from "lodash-es/isEqual";
5
5
 
6
6
  /**
7
7
  * Gets the assigned extension ids for a given extension slot name.
@@ -5,7 +5,7 @@ import {
5
5
  ExtensionStore,
6
6
  getExtensionStore,
7
7
  } from "@openmrs/esm-extensions";
8
- import { isEqual } from "lodash";
8
+ import isEqual from "lodash/isEqual";
9
9
 
10
10
  /**
11
11
  * Gets the assigned extensions for a given extension slot name.