@openmrs/esm-react-utils 4.0.0-pre.0 → 4.0.1-pre.206

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": "4.0.0-pre.0",
3
+ "version": "4.0.1-pre.206",
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": "^4.0.0-pre.0",
59
- "@openmrs/esm-config": "^4.0.0-pre.0",
60
- "@openmrs/esm-error-handling": "^4.0.0-pre.0",
61
- "@openmrs/esm-extensions": "^4.0.0-pre.0",
62
- "@openmrs/esm-globals": "^4.0.0-pre.0",
58
+ "@openmrs/esm-api": "^4.0.1-pre.206",
59
+ "@openmrs/esm-config": "^4.0.1-pre.206",
60
+ "@openmrs/esm-error-handling": "^4.0.1-pre.206",
61
+ "@openmrs/esm-extensions": "^4.0.1-pre.206",
62
+ "@openmrs/esm-globals": "^4.0.1-pre.206",
63
63
  "dayjs": "^1.10.8",
64
64
  "i18next": "^19.6.0",
65
65
  "react": "^18.1.0",
@@ -68,5 +68,5 @@
68
68
  "rxjs": "^6.5.3",
69
69
  "unistore": "^3.5.2"
70
70
  },
71
- "gitHead": "254a7226212aac82df4434639b2584e24ac240e4"
71
+ "gitHead": "7ac6e01b41238739cdf5d20efdff50062f46f500"
72
72
  }
@@ -1,8 +1,8 @@
1
1
  import React from "react";
2
2
  import "@testing-library/jest-dom/extend-expect";
3
3
  import { render, screen } from "@testing-library/react";
4
- import { navigate, interpolateUrl } from "@openmrs/esm-config";
5
4
  import userEvent from "@testing-library/user-event";
5
+ import { navigate, interpolateUrl } from "@openmrs/esm-config";
6
6
  import { ConfigurableLink } from "./ConfigurableLink";
7
7
 
8
8
  jest.mock("single-spa");
@@ -37,17 +37,21 @@ describe(`ConfigurableLink`, () => {
37
37
  });
38
38
 
39
39
  it(`calls navigate on normal click but not special clicks`, async () => {
40
+ const user = userEvent.setup();
41
+
40
42
  const link = screen.getByRole("link", { name: /spa home/i });
41
- await userEvent.pointer({ target: link, keys: "[MouseRight]" });
43
+ await user.pointer({ target: link, keys: "[MouseRight]" });
42
44
  expect(navigate).not.toHaveBeenCalled();
43
- await userEvent.click(link);
45
+ await user.click(link);
44
46
  expect(navigate).toHaveBeenCalledWith({ to: path });
45
47
  });
46
48
 
47
49
  it(`calls navigate on enter`, async () => {
50
+ const user = userEvent.setup();
51
+
48
52
  expect(navigate).not.toHaveBeenCalled();
49
53
  const link = screen.getByRole("link", { name: /spa home/i });
50
- await userEvent.type(link, "{enter}");
54
+ await user.type(link, "{enter}");
51
55
  expect(navigate).toHaveBeenCalledWith({ to: path });
52
56
  });
53
57
  });
@@ -1,8 +1,12 @@
1
1
  /** @module @category Navigation */
2
2
  import React, { MouseEvent, AnchorHTMLAttributes } from "react";
3
- import { navigate, interpolateUrl } from "@openmrs/esm-config";
3
+ import { navigate, interpolateUrl, TemplateParams } from "@openmrs/esm-config";
4
4
 
5
- function handleClick(event: MouseEvent, to: string) {
5
+ function handleClick(
6
+ event: MouseEvent,
7
+ to: string,
8
+ templateParams?: TemplateParams
9
+ ) {
6
10
  if (
7
11
  !event.metaKey &&
8
12
  !event.ctrlKey &&
@@ -10,7 +14,7 @@ function handleClick(event: MouseEvent, to: string) {
10
14
  event.button == 0
11
15
  ) {
12
16
  event.preventDefault();
13
- navigate({ to });
17
+ navigate({ to, templateParams });
14
18
  }
15
19
  }
16
20
 
@@ -20,25 +24,30 @@ function handleClick(event: MouseEvent, to: string) {
20
24
  export interface ConfigurableLinkProps
21
25
  extends AnchorHTMLAttributes<HTMLAnchorElement> {
22
26
  to: string;
27
+ templateParams?: TemplateParams;
23
28
  }
24
29
 
25
30
  /**
26
31
  * A React link component which calls [[navigate]] when clicked
27
32
  *
28
33
  * @param to The target path or URL. Supports interpolation. See [[navigate]]
34
+ * @param urlParams: A dictionary of values to interpolate into the URL, in addition to the default keys `openmrsBase` and `openmrsSpaBase`.
29
35
  * @param children Inline elements within the link
30
36
  * @param otherProps Any other valid props for an <a> tag except `href` and `onClick`
31
37
  */
32
- export const ConfigurableLink: React.FC<ConfigurableLinkProps> = ({
38
+ export function ConfigurableLink({
33
39
  to,
40
+ templateParams,
34
41
  children,
35
42
  ...otherProps
36
- }) => (
37
- <a
38
- onClick={(event) => handleClick(event, to)}
39
- href={interpolateUrl(to)}
40
- {...otherProps}
41
- >
42
- {children}
43
- </a>
44
- );
43
+ }: ConfigurableLinkProps) {
44
+ return (
45
+ <a
46
+ onClick={(event) => handleClick(event, to, templateParams)}
47
+ href={interpolateUrl(to, templateParams)}
48
+ {...otherProps}
49
+ >
50
+ {children}
51
+ </a>
52
+ );
53
+ }
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,101 +4,141 @@ 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;
7
+ export interface ExtensionSlotBaseProps {
8
+ name: string;
9
+ /** @deprecated Use `name` */
10
+ extensionSlotName?: string;
11
+ select?: (extensions: Array<ConnectedExtension>) => Array<ConnectedExtension>;
12
+ state?: Record<string, any>;
40
13
  }
41
14
 
42
- export interface ExtensionSlotBaseProps {
15
+ export interface OldExtensionSlotBaseProps {
16
+ name?: string;
17
+ /** @deprecated Use `name` */
43
18
  extensionSlotName: string;
44
19
  select?: (extensions: Array<ConnectedExtension>) => Array<ConnectedExtension>;
45
20
  state?: Record<string, any>;
46
21
  }
47
22
 
48
- export type ExtensionSlotProps = ExtensionSlotBaseProps &
49
- React.HTMLAttributes<HTMLDivElement>;
23
+ export type ExtensionSlotProps = (
24
+ | OldExtensionSlotBaseProps
25
+ | ExtensionSlotBaseProps
26
+ ) &
27
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
28
+ children?:
29
+ | React.ReactNode
30
+ | ((extension: ConnectedExtension) => React.ReactNode);
31
+ };
50
32
 
51
33
  function defaultSelect(extensions: Array<ConnectedExtension>) {
52
34
  return extensions;
53
35
  }
54
36
 
55
- export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
56
- 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,
57
88
  select = defaultSelect,
58
89
  children,
59
90
  state,
60
91
  style,
61
92
  ...divProps
62
- }: ExtensionSlotProps) => {
63
- const slotRef = useRef(null);
64
- const { extensions, extensionSlotModuleName } =
65
- useExtensionSlot(extensionSlotName);
66
- const stateRef = useRef(state);
67
-
68
- if (!isShallowEqual(stateRef.current, state)) {
69
- stateRef.current = state;
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
+ );
70
98
  }
71
99
 
72
- const content = useMemo(
73
- () =>
74
- extensionSlotName &&
75
- select(extensions).map((extension) => (
76
- <ComponentContext.Provider
77
- key={extension.id}
78
- value={{
79
- moduleName: extensionSlotModuleName, // moduleName is not used by the receiving Extension
80
- extension: {
81
- extensionId: extension.id,
82
- extensionSlotName,
83
- extensionSlotModuleName,
84
- },
85
- }}
86
- >
87
- {children ?? <Extension state={stateRef.current} />}
88
- </ComponentContext.Provider>
89
- )),
90
- [select, extensions, extensionSlotName, stateRef.current]
100
+ const name = (extensionSlotName ?? legacyExtensionSlotName) as string;
101
+ const slotRef = useRef(null);
102
+ const { extensions, extensionSlotModuleName } = useExtensionSlot(name);
103
+
104
+ const extensionsToRender = useMemo(
105
+ () => select(extensions),
106
+ [select, extensions]
91
107
  );
92
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
+
93
115
  return (
94
116
  <div
95
117
  ref={slotRef}
96
- data-extension-slot-name={extensionSlotName}
118
+ data-extension-slot-name={name}
97
119
  data-extension-slot-module-name={extensionSlotModuleName}
98
120
  style={{ ...style, position: "relative" }}
99
121
  {...divProps}
100
122
  >
101
- {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] ??
137
+ (typeof children != "function" ? children : null) ?? (
138
+ <Extension state={state} />
139
+ )}
140
+ </ComponentContext.Provider>
141
+ ))}
102
142
  </div>
103
143
  );
104
- };
144
+ }
@@ -1,14 +1,16 @@
1
1
  /** @module @category API */
2
+ import { getCurrentUser, LoggedInUser, userHasAccess } from "@openmrs/esm-api";
2
3
  import React, { useEffect, useState } from "react";
3
- import { getCurrentUser, userHasAccess, LoggedInUser } from "@openmrs/esm-api";
4
4
 
5
5
  export interface UserHasAccessProps {
6
- privilege: string;
6
+ privilege: string | string[];
7
+ fallback?: React.ReactNode;
7
8
  children?: React.ReactNode;
8
9
  }
9
10
 
10
11
  export const UserHasAccess: React.FC<UserHasAccessProps> = ({
11
12
  privilege,
13
+ fallback,
12
14
  children,
13
15
  }) => {
14
16
  const [user, setUser] = useState<LoggedInUser | null>(null);
@@ -22,7 +24,7 @@ export const UserHasAccess: React.FC<UserHasAccessProps> = ({
22
24
 
23
25
  if (user && userHasAccess(privilege, user)) {
24
26
  return <>{children}</>;
27
+ } else {
28
+ return fallback ? <>{fallback}</> : null;
25
29
  }
26
-
27
- return null;
28
30
  };