From 879042c3cc3b7107ee1f3cae7c6ffae6cf028ac2 Mon Sep 17 00:00:00 2001 From: Brandon Istenes Date: Fri, 15 Jan 2021 10:13:51 -0800 Subject: [PATCH] Make the UI Editor work through portals (#53) --- packages/esm-extensions/src/extensions.ts | 31 ++++---- packages/esm-extensions/src/render.ts | 17 +++++ packages/esm-extensions/src/store.ts | 18 ++++- .../__mocks__/openmrs-esm-api.mock.tsx | 73 ++++++------------- .../__mocks__/openmrs-esm-extensions.mock.tsx | 2 - .../openmrs-esm-react-utils.mock.tsx | 12 +++ .../esm-implementer-tools-app/jest.config.js | 4 +- .../esm-implementer-tools-app/package.json | 3 +- .../openmrs-backend-dependencies.ts | 2 +- .../configuration/configuration.component.tsx | 33 ++++----- .../src/configuration/configuration.test.tsx | 9 +-- .../configuration/description.component.tsx | 65 ++++++++--------- .../extension-slots-config-tree.tsx | 35 +++------ .../value-editors/object-editor.tsx | 6 +- .../src/implementer-tools.component.tsx | 13 ++-- .../src/popup/popup.component.tsx | 2 +- .../esm-implementer-tools-app/src/store.ts | 48 +++++++++--- .../ui-editor/extension-overlay.component.tsx | 44 +++++++++++ .../src/ui-editor/portal.tsx | 5 ++ .../src/ui-editor/styles.css | 55 ++++++++++++++ .../src/ui-editor/ui-editor.tsx | 56 ++++++++++++++ packages/esm-react-utils/src/Extension.tsx | 19 +++-- .../esm-react-utils/src/ExtensionSlot.tsx | 42 ++--------- .../esm-react-utils/src/createUseStore.ts | 31 ++++++++ packages/esm-react-utils/src/index.ts | 2 + .../esm-react-utils/src/useExtensionSlot.ts | 24 ++++-- .../esm-react-utils/src/useExtensionStore.ts | 4 + packages/esm-react-utils/tsconfig.json | 3 +- 28 files changed, 427 insertions(+), 231 deletions(-) create mode 100644 packages/esm-implementer-tools-app/src/ui-editor/extension-overlay.component.tsx create mode 100644 packages/esm-implementer-tools-app/src/ui-editor/portal.tsx create mode 100644 packages/esm-implementer-tools-app/src/ui-editor/styles.css create mode 100644 packages/esm-implementer-tools-app/src/ui-editor/ui-editor.tsx create mode 100644 packages/esm-react-utils/src/createUseStore.ts create mode 100644 packages/esm-react-utils/src/useExtensionStore.ts diff --git a/packages/esm-extensions/src/extensions.ts b/packages/esm-extensions/src/extensions.ts index 897570e41..f01709afe 100644 --- a/packages/esm-extensions/src/extensions.ts +++ b/packages/esm-extensions/src/extensions.ts @@ -15,6 +15,7 @@ function createNewExtensionSlotInstance(): ExtensionSlotInstance { idOrder: [], removedIds: [], registered: 1, + domElement: null, }; } @@ -48,6 +49,7 @@ export const registerExtension: ( name, load, moduleName, + instances: {}, }; } ); @@ -204,10 +206,12 @@ function getUpdatedExtensionSlotInfoForUnregistration( * * @param moduleName The name of the module that contains the extension slot * @param actualExtensionSlotName The extension slot name that is actually used + * @param domElement The HTML element of the extension slot */ export function registerExtensionSlot( moduleName: string, - actualExtensionSlotName: string + actualExtensionSlotName: string, + domElement: HTMLElement ) { updateExtensionStore(async (state) => { const slotName = @@ -224,7 +228,16 @@ export function registerExtensionSlot( ...state, slots: { ...state.slots, - [slotName]: updatedSlot, + [slotName]: { + ...updatedSlot, + instances: { + ...updatedSlot.instances, + [moduleName]: { + ...updatedSlot.instances[moduleName], + domElement, + }, + }, + }, }, }; }); @@ -268,20 +281,6 @@ export function getExtensionSlotsForModule(moduleName: string) { ); } -const uiEditorSettingKey = "openmrs:isUIEditorEnabled"; - -export function getIsUIEditorEnabled(): boolean { - try { - return JSON.parse(localStorage.getItem(uiEditorSettingKey) ?? "false"); - } catch { - return false; - } -} - -export function setIsUIEditorEnabled(enabled: boolean) { - localStorage.setItem(uiEditorSettingKey, JSON.stringify(enabled)); -} - /** * @internal * Just for testing. diff --git a/packages/esm-extensions/src/render.ts b/packages/esm-extensions/src/render.ts index 0504a7d75..9e59d2266 100644 --- a/packages/esm-extensions/src/render.ts +++ b/packages/esm-extensions/src/render.ts @@ -1,6 +1,8 @@ import { mountRootParcel } from "single-spa"; +import { cloneDeep, set } from "lodash-es"; import { getExtensionRegistration } from "./extensions"; import { getActualRouteProps } from "./route"; +import { updateExtensionStore } from "./store"; export interface Lifecycle { bootstrap(): void; @@ -57,6 +59,21 @@ export function renderExtension( domElement, }) ); + + updateExtensionStore((state) => + set( + cloneDeep(state), + [ + "extensions", + extensionName, + "instances", + extensionSlotModuleName, + actualExtensionSlotName, + "domElement", + ], + domElement + ) + ); } else { throw Error( `Couldn't find extension '${extensionName}' to attach to '${actualExtensionSlotName}'` diff --git a/packages/esm-extensions/src/store.ts b/packages/esm-extensions/src/store.ts index ec5879a4b..496571ef3 100644 --- a/packages/esm-extensions/src/store.ts +++ b/packages/esm-extensions/src/store.ts @@ -9,9 +9,21 @@ export interface ExtensionRegistration extends ExtensionDefinition { moduleName: string; } +export interface ExtensionInfo extends ExtensionRegistration { + /** + * The instances where the extension has been rendered using `renderExtension`, + * indexed by slotModuleName and actualExtensionSlotName + */ + instances: Record>; +} + +export interface ExtensionInstance { + domElement: HTMLElement; +} + export interface ExtensionStore { slots: Record; - extensions: Record; + extensions: Record; } export interface ExtensionSlotInstance { @@ -39,6 +51,10 @@ export interface ExtensionSlotInstance { * The number of active registrations on the instance. */ registered: number; + /** + * The dom element at which the slot is mounted + */ + domElement: HTMLElement | null; } export interface ExtensionSlotInfo { diff --git a/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-api.mock.tsx b/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-api.mock.tsx index 34ba4abf3..0cff84b12 100644 --- a/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-api.mock.tsx +++ b/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-api.mock.tsx @@ -1,59 +1,28 @@ -import createStore, { Store } from "unistore"; - export function openmrsFetch() { return new Promise(() => {}); } -interface StoreEntity { - value: Store; - active: boolean; -} - -const availableStores: Record = {}; - -export function createGlobalStore( - name: string, - initialState: TState -): Store { - const available = availableStores[name]; - - if (available) { - if (available.active) { - console.error( - "Cannot override an existing store. Make sure that stores are only created once." - ); - } else { - available.value.setState(initialState, true); - } - - available.active = true; - return available.value; - } else { - const store = createStore(initialState); - - availableStores[name] = { - value: store, - active: true, - }; - - return store; - } +let state; + +function makeStore(state) { + return { + getState: () => state, + setState: (val) => { + state = { ...state, ...val }; + }, + subscribe: (updateFcn) => { + updateFcn(state); + return () => {}; + }, + unsubscribe: () => {}, + }; } -export function getGlobalStore( - name: string, - fallbackState?: TState -): Store { - const available = availableStores[name]; +export const createGlobalStore = jest.fn().mockImplementation((n, value) => { + state = value; + return makeStore(state); +}); - if (!available) { - const store = createStore(fallbackState); - availableStores[name] = { - value: store, - active: false, - }; - return store; - } - - return available.value; -} +export const getGlobalStore = jest + .fn() + .mockImplementation(() => makeStore(state)); diff --git a/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-extensions.mock.tsx b/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-extensions.mock.tsx index 9e393a392..8c900c5c9 100644 --- a/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-extensions.mock.tsx +++ b/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-extensions.mock.tsx @@ -1,5 +1,3 @@ -export const getIsUIEditorEnabled = (): boolean => true; - export const setIsUIEditorEnabled = (boolean): void => {}; let state = { slots: {}, extensions: {} }; diff --git a/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-react-utils.mock.tsx b/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-react-utils.mock.tsx index f7581d508..fc72ef4f9 100644 --- a/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-react-utils.mock.tsx +++ b/packages/esm-implementer-tools-app/__mocks__/openmrs-esm-react-utils.mock.tsx @@ -1,4 +1,6 @@ +import { Store } from "unistore"; import React from "react"; +import { extensionStore } from "@openmrs/esm-extensions"; export const ExtensionContext = React.createContext({ extensionSlotName: "", @@ -15,3 +17,13 @@ export const openmrsRootDecorator = jest export const UserHasAccess = jest.fn().mockImplementation((props: any) => { return props.children; }); + +export const createUseStore = (store: Store) => (actions) => { + const state = store.getState(); + return { ...state, ...actions }; +}; + +export const useExtensionStore = (actions) => { + const state = extensionStore.getState(); + return { ...state, ...actions }; +}; diff --git a/packages/esm-implementer-tools-app/jest.config.js b/packages/esm-implementer-tools-app/jest.config.js index 3242c39f2..121805234 100644 --- a/packages/esm-implementer-tools-app/jest.config.js +++ b/packages/esm-implementer-tools-app/jest.config.js @@ -10,11 +10,11 @@ module.exports = { "\\.(css)$": "identity-obj-proxy", "@openmrs/esm-api": "/__mocks__/openmrs-esm-api.mock.tsx", "@openmrs/esm-config": "/__mocks__/openmrs-esm-config.mock.tsx", - "@openmrs/esm-react-utils": - "/__mocks__/openmrs-esm-react-utils.mock.tsx", "@openmrs/esm-extensions": "/__mocks__/openmrs-esm-extensions.mock.tsx", "@openmrs/esm-styleguide": "/__mocks__/openmrs-esm-styleguide.mock.tsx", + "@openmrs/esm-react-utils": + "/__mocks__/openmrs-esm-react-utils.mock.tsx", }, }; diff --git a/packages/esm-implementer-tools-app/package.json b/packages/esm-implementer-tools-app/package.json index 0c0c1e6e0..e04f1ac20 100644 --- a/packages/esm-implementer-tools-app/package.json +++ b/packages/esm-implementer-tools-app/package.json @@ -13,7 +13,8 @@ "test": "jest --config jest.config.js --passWithNoTests", "build": "webpack --mode=production", "typescript": "tsc", - "lint": "eslint src --ext ts,tsx" + "lint": "eslint src --ext ts,tsx", + "format": "prettier --write src/**" }, "keywords": [ "openmrs", diff --git a/packages/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts b/packages/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts index 0f3f757a4..097b3ba57 100644 --- a/packages/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts +++ b/packages/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts @@ -1,6 +1,6 @@ import { openmrsFetch } from "@openmrs/esm-api"; import * as semver from "semver"; -import { difference } from "lodash-es"; +import difference from "lodash-es/difference"; const installedBackendModules: Array> = []; const modulesWithMissingBackendModules: MissingBackendModules[] = []; diff --git a/packages/esm-implementer-tools-app/src/configuration/configuration.component.tsx b/packages/esm-implementer-tools-app/src/configuration/configuration.component.tsx index 094164355..4dc6d3e2f 100644 --- a/packages/esm-implementer-tools-app/src/configuration/configuration.component.tsx +++ b/packages/esm-implementer-tools-app/src/configuration/configuration.component.tsx @@ -10,20 +10,26 @@ import { Button, Column, Grid, Row, Toggle } from "carbon-components-react"; import { Download16, TrashCan16 } from "@carbon/icons-react"; import styles from "./configuration.styles.css"; import { ConfigTree } from "./config-tree.component"; -import { - getIsUIEditorEnabled, - setIsUIEditorEnabled, -} from "@openmrs/esm-extensions"; +import { getStore, ImplementerToolsStore, useStore } from "../store"; import { Description } from "./description.component"; -export default function Configuration(props: ConfigurationProps) { +export type ConfigurationProps = { + setHasAlert(value: boolean): void; +}; + +const actions = { + toggleIsUIEditorEnabled({ isUIEditorEnabled }: ImplementerToolsStore) { + return { isUIEditorEnabled: !isUIEditorEnabled }; + }, +}; + +export function Configuration({ setHasAlert }: ConfigurationProps) { + const { isUIEditorEnabled, toggleIsUIEditorEnabled } = useStore(actions); const [config, setConfig] = useState({}); const [isDevConfigActive, setIsDevConfigActive] = useState( getAreDevDefaultsOn() ); - const [isUIEditorActive, setIsUIEditorActive] = useState( - getIsUIEditorEnabled() - ); + const store = getStore(); const tempConfig = getTemporaryConfig(); const tempConfigObjUrl = new Blob( [JSON.stringify(tempConfig, undefined, 2)], @@ -60,11 +66,8 @@ export default function Configuration(props: ConfigurationProps) { { - setIsUIEditorActive(!isUIEditorActive); - setIsUIEditorEnabled(!isUIEditorActive); - }} + toggled={isUIEditorEnabled} + onToggle={toggleIsUIEditorEnabled} /> @@ -109,7 +112,3 @@ export default function Configuration(props: ConfigurationProps) { ); } - -type ConfigurationProps = { - setHasAlert(value: boolean): void; -}; diff --git a/packages/esm-implementer-tools-app/src/configuration/configuration.test.tsx b/packages/esm-implementer-tools-app/src/configuration/configuration.test.tsx index 08dc3fb2f..0b698568e 100644 --- a/packages/esm-implementer-tools-app/src/configuration/configuration.test.tsx +++ b/packages/esm-implementer-tools-app/src/configuration/configuration.test.tsx @@ -13,8 +13,7 @@ import { setTemporaryConfigValue, Type, } from "@openmrs/esm-config"; -import { Provider } from "unistore/react"; -import Configuration from "./configuration.component"; +import { Configuration } from "./configuration.component"; import { getStore } from "../store"; import { performConceptSearch, @@ -109,11 +108,7 @@ describe(``, () => { }); function renderConfiguration() { - render( - - {}} /> - - ); + render( {}} />); } it(`renders without dying`, async () => { diff --git a/packages/esm-implementer-tools-app/src/configuration/description.component.tsx b/packages/esm-implementer-tools-app/src/configuration/description.component.tsx index dc38a9ecd..684066219 100644 --- a/packages/esm-implementer-tools-app/src/configuration/description.component.tsx +++ b/packages/esm-implementer-tools-app/src/configuration/description.component.tsx @@ -1,37 +1,34 @@ import React from "react"; -import { connect } from "unistore/react"; +import { useStore } from "../store"; import styles from "./description.styles.css"; -export const Description = connect("activeItemDescription")( - ({ activeItemDescription }) => { - return ( -
- {activeItemDescription ? ( - <> -

- {activeItemDescription.path.slice(1).join(" → ")} -

-

- {activeItemDescription.description} -

-

- {activeItemDescription.source === "default" ? ( - <>The current value is the default. - ) : activeItemDescription.source ? ( - <> - The current value comes from {activeItemDescription.source}. - - ) : null} -

- {activeItemDescription.value ?

Value:

: null} -

- {Array.isArray(activeItemDescription.value) - ? activeItemDescription.value.map((v) =>

{v}

) - : activeItemDescription.value} -

- - ) : null} -
- ); - } -); +export function Description() { + const { activeItemDescription } = useStore(); + return ( +
+ {activeItemDescription ? ( + <> +

+ {activeItemDescription.path.slice(1).join(" → ")} +

+

+ {activeItemDescription.description} +

+

+ {activeItemDescription.source === "default" ? ( + <>The current value is the default. + ) : activeItemDescription.source ? ( + <>The current value comes from {activeItemDescription.source}. + ) : null} +

+ {activeItemDescription.value ?

Value:

: null} +

+ {Array.isArray(activeItemDescription.value) + ? activeItemDescription.value.map((v) =>

{v}

) + : activeItemDescription.value} +

+ + ) : null} +
+ ); +} diff --git a/packages/esm-implementer-tools-app/src/configuration/extension-slots-config-tree.tsx b/packages/esm-implementer-tools-app/src/configuration/extension-slots-config-tree.tsx index b8a19e7d6..09bedb062 100644 --- a/packages/esm-implementer-tools-app/src/configuration/extension-slots-config-tree.tsx +++ b/packages/esm-implementer-tools-app/src/configuration/extension-slots-config-tree.tsx @@ -1,33 +1,24 @@ import React, { useEffect, useMemo, useState } from "react"; import { ExtensionSlotConfig } from "@openmrs/esm-config"; -import { - ExtensionSlotInfo, - extensionStore, - ExtensionStore, -} from "@openmrs/esm-extensions"; -import { Provider, connect } from "unistore/react"; +import { extensionStore } from "@openmrs/esm-extensions"; +import { useExtensionStore } from "@openmrs/esm-react-utils"; import EditableValue from "./editable-value.component"; -import { getGlobalStore } from "@openmrs/esm-api"; import { getStore } from "../store"; -import { isEqual } from "lodash-es"; +import isEqual from "lodash-es/isEqual"; import { ExtensionConfigureTree } from "./extension-configure-tree"; import { Subtree } from "./layout/subtree.component"; -interface ExtensionsSlotsConfigTreeProps { +interface ExtensionSlotsConfigTreeProps { config: { [key: string]: any }; moduleName: string; } -interface ExtensionSlotsConfigTreeImplProps - extends ExtensionsSlotsConfigTreeProps { - slots: Record; -} +export function ExtensionSlotsConfigTree({ + config, + moduleName, +}: ExtensionSlotsConfigTreeProps) { + const { slots } = useExtensionStore(); -const ExtensionSlotsConfigTreeImpl = connect( - (state: ExtensionStore, _: ExtensionsSlotsConfigTreeProps) => ({ - slots: state.slots, - }) -)(({ config, moduleName, slots }: ExtensionSlotsConfigTreeImplProps) => { const extensionSlotNames = useMemo( () => Object.keys(slots).filter((name) => moduleName in slots[name].instances), @@ -44,14 +35,6 @@ const ExtensionSlotsConfigTreeImpl = connect( ))} ) : null; -}); - -export function ExtensionSlotsConfigTree(props) { - return ( - - - - ); } interface ExtensionSlotConfigProps { diff --git a/packages/esm-implementer-tools-app/src/configuration/value-editors/object-editor.tsx b/packages/esm-implementer-tools-app/src/configuration/value-editors/object-editor.tsx index 94e4a6d3d..95842a332 100644 --- a/packages/esm-implementer-tools-app/src/configuration/value-editors/object-editor.tsx +++ b/packages/esm-implementer-tools-app/src/configuration/value-editors/object-editor.tsx @@ -1,17 +1,15 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { - Button, StructuredListBody, StructuredListCell, StructuredListRow, StructuredListWrapper, Tile, } from "carbon-components-react"; -import { Add16, TrashCan16 } from "@carbon/icons-react"; import { ValueEditorField } from "./value-editor-field"; import { ConfigValueDescriptor } from "../editable-value.component"; import { Type } from "@openmrs/esm-config"; -import { cloneDeep } from "lodash-es"; +import cloneDeep from "lodash-es/cloneDeep"; import styles from "./object-editor.styles.css"; interface ObjectEditorProps { diff --git a/packages/esm-implementer-tools-app/src/implementer-tools.component.tsx b/packages/esm-implementer-tools-app/src/implementer-tools.component.tsx index 0e14f011b..9c531fc5f 100644 --- a/packages/esm-implementer-tools-app/src/implementer-tools.component.tsx +++ b/packages/esm-implementer-tools-app/src/implementer-tools.component.tsx @@ -1,15 +1,16 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; +import { Provider } from "unistore/react"; import { UserHasAccess } from "@openmrs/esm-react-utils"; -import { connect, Provider } from "unistore/react"; import Popup from "./popup/popup.component"; import styles from "./implementer-tools.styles.css"; -import { getStore } from "./store"; import { showToast } from "@openmrs/esm-styleguide"; import { checkModules, MissingBackendModules, } from "./backend-dependencies/openmrs-backend-dependencies"; import { NotificationActionButton } from "carbon-components-react/lib/components/Notification"; +import { getStore, useStore } from "./store"; +import { UiEditor } from "./ui-editor/ui-editor"; export default function ImplementerTools() { const store = getStore(); @@ -23,7 +24,7 @@ export default function ImplementerTools() { ); } -const PopupHandler = connect("isOpen")(({ isOpen }) => { +function PopupHandler() { const [hasAlert, setHasAlert] = useState(false); const [visibleTabIndex, setVisibleTabIndex] = useState(0); const [ @@ -34,6 +35,7 @@ const PopupHandler = connect("isOpen")(({ isOpen }) => { modulesWithWrongBackendModulesVersion, setModulesWithWrongBackendModulesVersion, ] = useState>([]); + const { isOpen, isUIEditorEnabled } = useStore(); function togglePopup() { getStore().setState({ isOpen: !isOpen }); @@ -102,6 +104,7 @@ const PopupHandler = connect("isOpen")(({ isOpen }) => { visibleTabIndex={visibleTabIndex} /> ) : null} + {isUIEditorEnabled ? : null} ); -}); +} diff --git a/packages/esm-implementer-tools-app/src/popup/popup.component.tsx b/packages/esm-implementer-tools-app/src/popup/popup.component.tsx index 0198a8be7..adfddd95f 100644 --- a/packages/esm-implementer-tools-app/src/popup/popup.component.tsx +++ b/packages/esm-implementer-tools-app/src/popup/popup.component.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { Button, ContentSwitcher, Switch } from "carbon-components-react"; import { Close16 } from "@carbon/icons-react"; import styles from "./popup.styles.css"; -import Configuration from "../configuration/configuration.component"; +import { Configuration } from "../configuration/configuration.component"; import { ModuleDiagnostics } from "../backend-dependencies/backend-dependecies.component"; import { MissingBackendModules } from "../backend-dependencies/openmrs-backend-dependencies"; diff --git a/packages/esm-implementer-tools-app/src/store.ts b/packages/esm-implementer-tools-app/src/store.ts index e1ef8010e..6191e440f 100644 --- a/packages/esm-implementer-tools-app/src/store.ts +++ b/packages/esm-implementer-tools-app/src/store.ts @@ -1,9 +1,12 @@ import { createGlobalStore, getGlobalStore } from "@openmrs/esm-api"; +import { createUseStore } from "@openmrs/esm-react-utils"; +import { Store } from "unistore"; export interface ImplementerToolsStore { activeItemDescription?: ActiveItemDescription; configPathBeingEdited: null | string[]; isOpen: boolean; + isUIEditorEnabled: boolean; } export interface ActiveItemDescription { @@ -13,29 +16,56 @@ export interface ActiveItemDescription { source?: string; } -createGlobalStore("implementer-tools", { - activeItemDescription: null, - configPathBeingEdited: null, - isOpen: getIsImplementerToolsOpen(), -}); +const store: Store = createGlobalStore( + "implementer-tools", + { + activeItemDescription: undefined, + configPathBeingEdited: null, + isOpen: getIsImplementerToolsOpen(), + isUIEditorEnabled: getIsUIEditorEnabled(), + } +); export const getStore = () => getGlobalStore("implementer-tools"); +export const useStore = createUseStore(store); + let lastValueOfIsOpen = getIsImplementerToolsOpen(); +let lastValueOfIsUiEditorEnabled = getIsUIEditorEnabled(); getStore().subscribe((state) => { if (state.isOpen != lastValueOfIsOpen) { setIsImplementerToolsOpen(state.isOpen); lastValueOfIsOpen = state.isOpen; } + if (state.isUIEditorEnabled != lastValueOfIsUiEditorEnabled) { + setIsUIEditorEnabled(state.isUIEditorEnabled); + lastValueOfIsUiEditorEnabled = state.isUIEditorEnabled; + } }); +function getIsImplementerToolsOpen(): boolean { + return ( + JSON.parse( + localStorage.getItem("openmrs:openmrsImplementerToolsAreOpen") || "false" + ) ?? false + ); +} + function setIsImplementerToolsOpen(value: boolean): void { - localStorage.setItem("openmrsImplementerToolsAreOpen", JSON.stringify(value)); + localStorage.setItem( + "openmrs:openmrsImplementerToolsAreOpen", + JSON.stringify(value) + ); } -function getIsImplementerToolsOpen(): boolean { - return JSON.parse( - localStorage.getItem("openmrsImplementerToolsAreOpen") || "false" +function getIsUIEditorEnabled(): boolean { + return ( + JSON.parse(localStorage.getItem("openmrs:isUIEditorEnabled") || "false") ?? + false ); } + +function setIsUIEditorEnabled(enabled: boolean) { + localStorage.setItem("openmrs:isUIEditorEnabled", JSON.stringify(enabled)); +} diff --git a/packages/esm-implementer-tools-app/src/ui-editor/extension-overlay.component.tsx b/packages/esm-implementer-tools-app/src/ui-editor/extension-overlay.component.tsx new file mode 100644 index 000000000..d5fcb793e --- /dev/null +++ b/packages/esm-implementer-tools-app/src/ui-editor/extension-overlay.component.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useState } from "react"; +import { Portal } from "./portal"; +import styles from "./styles.css"; + +export interface ExtensionOverlayProps { + extensionName: string; + slotModuleName: string; + slotName: string; + domElement: HTMLElement; +} + +export function ExtensionOverlay({ + extensionName, + slotModuleName, + slotName, + domElement, +}: ExtensionOverlayProps) { + const [overlayDomElement, setOverlayDomElement] = useState(); + + useEffect(() => { + if (domElement) { + const newOverlayDomElement = document.createElement("div"); + domElement.parentElement?.appendChild(newOverlayDomElement); + setOverlayDomElement(newOverlayDomElement); + } + }, [domElement]); + + return overlayDomElement ? ( + + + + ) : null; +} + +function Content({ extensionId }) { + return ( + + ); +} diff --git a/packages/esm-implementer-tools-app/src/ui-editor/portal.tsx b/packages/esm-implementer-tools-app/src/ui-editor/portal.tsx new file mode 100644 index 000000000..6a6279392 --- /dev/null +++ b/packages/esm-implementer-tools-app/src/ui-editor/portal.tsx @@ -0,0 +1,5 @@ +import { createPortal } from "react-dom"; + +export function Portal({ el, children }) { + return el ? createPortal(children, el) : null; +} diff --git a/packages/esm-implementer-tools-app/src/ui-editor/styles.css b/packages/esm-implementer-tools-app/src/ui-editor/styles.css new file mode 100644 index 000000000..bad9a7280 --- /dev/null +++ b/packages/esm-implementer-tools-app/src/ui-editor/styles.css @@ -0,0 +1,55 @@ +.slotOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(43, 43, 185, 0.1); + border: 1px solid rgba(43, 43, 185, 0.4); + pointer-events: none; +} + +.slotName { + background-color: rgb(255, 255, 255, 0.85); + border: 1px solid rgba(43, 43, 185, 0.4); + color: #393939; + position: absolute; + bottom: 0; + right: 0; + padding: 0.5em 0.5em 0.5em 0.5em; + pointer-events: none; +} + +.extensionOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: none; + border: none; +} + +.extensionOverlay:hover { + background-color: rgba(43, 43, 185, 0.1); +} + +/* Show the tooltip text when you mouse over the tooltip container */ +.extensionOverlay:focus .extensionTooltip, +.extensionOverlay:hover .extensionTooltip { + visibility: visible; + opacity: 1; +} + +.extensionTooltip { + visibility: hidden; + width: auto; + background-color: rgb(255, 255, 255, 0.85); + text-align: center; + padding: 0.5em 0.5em 0.5em 0.5em; + border: 1px solid rgba(43, 43, 185, 0.4); + + position: absolute; + top: 0; + left: 0; +} diff --git a/packages/esm-implementer-tools-app/src/ui-editor/ui-editor.tsx b/packages/esm-implementer-tools-app/src/ui-editor/ui-editor.tsx new file mode 100644 index 000000000..736556eda --- /dev/null +++ b/packages/esm-implementer-tools-app/src/ui-editor/ui-editor.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import styles from "./styles.css"; +import { Portal } from "./portal"; +import { ExtensionOverlay } from "./extension-overlay.component"; +import { useExtensionStore } from "@openmrs/esm-react-utils"; + +export function UiEditor() { + const { slots, extensions } = useExtensionStore(); + + return ( + <> + {slots + ? Object.entries(slots).map(([slotName, slotInfo]) => + Object.entries(slotInfo.instances).map( + ([slotModuleName, slotInstance]) => ( + + + + ) + ) + ) + : null} + {extensions + ? Object.entries(extensions).map(([extensionName, extensionInfo]) => + Object.entries(extensionInfo.instances).map( + ([slotModuleName, bySlotName]) => + Object.entries(bySlotName).map( + ([slotName, extensionInstance]) => { + return ( + + ); + } + ) + ) + ) + : null} + + ); +} + +export function SlotOverlay({ slotName }) { + return ( + <> +
+
{slotName}
+ + ); +} diff --git a/packages/esm-react-utils/src/Extension.tsx b/packages/esm-react-utils/src/Extension.tsx index 18a096578..d69128ef2 100644 --- a/packages/esm-react-utils/src/Extension.tsx +++ b/packages/esm-react-utils/src/Extension.tsx @@ -1,6 +1,5 @@ import React, { useContext } from "react"; -import { TooltipIcon } from "carbon-components-react"; -import { renderExtension, getIsUIEditorEnabled } from "@openmrs/esm-extensions"; +import { renderExtension } from "@openmrs/esm-extensions"; import { ExtensionContext } from "./ExtensionContext"; export interface ExtensionProps { @@ -42,15 +41,15 @@ export const Extension: React.FC = ({ state }) => { attachedExtensionSlotName, extensionSlotModuleName, extensionId, + ref.current, ]); - return getIsUIEditorEnabled() ? ( - -
- -
-
- ) : ( - + return ( + // The extension is rendered into the ``. It is surrounded by a + // `
` with relative positioning in order to allow the UI Editor + // to absolutely position elements within it. +
+ +
); }; diff --git a/packages/esm-react-utils/src/ExtensionSlot.tsx b/packages/esm-react-utils/src/ExtensionSlot.tsx index 7651c1222..49fbc353a 100644 --- a/packages/esm-react-utils/src/ExtensionSlot.tsx +++ b/packages/esm-react-utils/src/ExtensionSlot.tsx @@ -1,8 +1,5 @@ -import React, { ReactNode } from "react"; -import { - getIsUIEditorEnabled, - getExtensionRegistration, -} from "@openmrs/esm-extensions"; +import React, { CSSProperties, ReactNode, useRef } from "react"; +import { getExtensionRegistration } from "@openmrs/esm-extensions"; import { ExtensionContext } from "./ExtensionContext"; import { Extension } from "./Extension"; import { useExtensionSlot } from "./useExtensionSlot"; @@ -11,26 +8,9 @@ export interface ExtensionSlotBaseProps { extensionSlotName: string; children?: ReactNode; state?: Record; - className?: string; + style?: CSSProperties; } -const slotStyle = { - backgroundColor: "rgba(43, 43, 185, 0.1)", - position: "relative", - border: "1px solid rgba(43, 43, 185, 0.4)", - margin: "-1px", // accomodates the border -}; - -const slotNameStyle = { - backgroundColor: "rgb(255 255 255 / 71%)", - border: "1px solid rgba(43, 43, 185, 0.4)", - color: "#393939", - position: "absolute", - bottom: "-1px", - right: "-1px", - padding: "0.5em 0.5em 0.5em 0.5em", -}; - // remainder of props are for the top-level
export type ExtensionSlotProps = ExtensionSlotBaseProps & T; @@ -38,21 +18,18 @@ export const ExtensionSlot: React.FC = ({ extensionSlotName: actualExtensionSlotName, children, state, - className, + style, ...divProps }: ExtensionSlotProps) => { + const slotRef = useRef(null); const { attachedExtensionSlotName, extensionIdsToRender, extensionSlotModuleName, - } = useExtensionSlot(actualExtensionSlotName); + } = useExtensionSlot(actualExtensionSlotName, slotRef); return ( -
+
{extensionIdsToRender.map((extensionId) => { const extensionRegistration = getExtensionRegistration(extensionId); @@ -74,11 +51,6 @@ export const ExtensionSlot: React.FC = ({ ) ); })} - {getIsUIEditorEnabled() && ( -
- slot "{attachedExtensionSlotName}" -
- )}
); }; diff --git a/packages/esm-react-utils/src/createUseStore.ts b/packages/esm-react-utils/src/createUseStore.ts new file mode 100644 index 000000000..499f00520 --- /dev/null +++ b/packages/esm-react-utils/src/createUseStore.ts @@ -0,0 +1,31 @@ +import { useEffect, useMemo, useState } from "react"; +import { Store, BoundAction } from "unistore"; + +export type Actions = Function | { [key: string]: Function }; +export type BoundActions = { [key: string]: BoundAction }; + +function bindActions(store: Store, actions: Actions) { + if (typeof actions == "function") { + actions = actions(store); + } + const bound = {}; + for (let i in actions) { + bound[i] = store.action(actions[i]); + } + return bound; +} + +export function createUseStore(store: Store) { + return function useStore(actions?: Actions): T & BoundActions { + const [state, set] = useState(store.getState()); + useEffect(() => store.subscribe((state) => set(state)), []); + let boundActions = {}; + if (actions) { + boundActions = useMemo(() => bindActions(store, actions), [ + store, + actions, + ]); + } + return { ...state, ...(boundActions as BoundActions) }; + }; +} diff --git a/packages/esm-react-utils/src/index.ts b/packages/esm-react-utils/src/index.ts index 802896176..2e42a7dd7 100644 --- a/packages/esm-react-utils/src/index.ts +++ b/packages/esm-react-utils/src/index.ts @@ -1,4 +1,5 @@ export * from "./ConfigurableLink"; +export * from "./createUseStore"; export * from "./Extension"; export * from "./ExtensionSlot"; export * from "./getLifecycle"; @@ -7,6 +8,7 @@ export * from "./openmrsRootDecorator"; export * from "./openmrsExtensionDecorator"; export * from "./useConfig"; export * from "./useCurrentPatient"; +export * from "./useExtensionStore"; export * from "./useForceUpdate"; export * from "./useNavigationContext"; export * from "./UserHasAccess"; diff --git a/packages/esm-react-utils/src/useExtensionSlot.ts b/packages/esm-react-utils/src/useExtensionSlot.ts index 1e5288dc1..ba37533e6 100644 --- a/packages/esm-react-utils/src/useExtensionSlot.ts +++ b/packages/esm-react-utils/src/useExtensionSlot.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { MutableRefObject, useContext, useEffect, useState } from "react"; import { registerExtensionSlot, unregisterExtensionSlot, @@ -7,7 +7,10 @@ import { } from "@openmrs/esm-extensions"; import { ModuleNameContext } from "./ModuleNameContext"; -export function useExtensionSlot(actualExtensionSlotName: string) { +export function useExtensionSlot( + actualExtensionSlotName: string, + ref: MutableRefObject +) { const extensionSlotModuleName = useContext(ModuleNameContext); if (!extensionSlotModuleName) { @@ -22,10 +25,19 @@ export function useExtensionSlot(actualExtensionSlotName: string) { ] = useState<[string | undefined, Array]>([undefined, []]); useEffect(() => { - registerExtensionSlot(extensionSlotModuleName, actualExtensionSlotName); - return () => - unregisterExtensionSlot(extensionSlotModuleName, actualExtensionSlotName); - }, []); + if (ref.current) { + registerExtensionSlot( + extensionSlotModuleName, + actualExtensionSlotName, + ref.current + ); + return () => + unregisterExtensionSlot( + extensionSlotModuleName, + actualExtensionSlotName + ); + } + }, [ref.current]); useEffect(() => { const update = (state: ExtensionStore) => { diff --git a/packages/esm-react-utils/src/useExtensionStore.ts b/packages/esm-react-utils/src/useExtensionStore.ts new file mode 100644 index 000000000..f2cd834db --- /dev/null +++ b/packages/esm-react-utils/src/useExtensionStore.ts @@ -0,0 +1,4 @@ +import { ExtensionStore, extensionStore } from "@openmrs/esm-extensions"; +import { createUseStore } from "./createUseStore"; + +export const useExtensionStore = createUseStore(extensionStore); diff --git a/packages/esm-react-utils/tsconfig.json b/packages/esm-react-utils/tsconfig.json index 0404f8a32..274494873 100644 --- a/packages/esm-react-utils/tsconfig.json +++ b/packages/esm-react-utils/tsconfig.json @@ -16,8 +16,7 @@ "es2015", "es2015.promise", "es2016.array.include", - "es2018", - "esnext" + "es2018" ] }, "include": ["src/**/*"]