@openmrs/esm-extensions
Version:
Coordinates extensions and extension points in the OpenMRS Frontend
500 lines (457 loc) • 15.2 kB
text/typescript
/** @module @category Extension */
/*
* We have the following extension modes:
*
* - attached (set via code in form of: attach, detach, ...)
* - configured (set via configuration in form of: added, removed, ...)
* - assigned (computed from attached and configured)
* - connected (computed from assigned using connectivity and online / offline)
*/
import { type Session, type SessionStore, sessionStore, userHasAccess } from '@openmrs/esm-api';
import {
type ExtensionsConfigStore,
type ExtensionSlotConfig,
type ExtensionSlotsConfigStore,
getExtensionConfigFromStore,
getExtensionsConfigStore,
getExtensionSlotConfig,
getExtensionConfigFromExtensionSlotStore,
getExtensionSlotConfigFromStore,
getExtensionSlotsConfigStore,
} from '@openmrs/esm-config';
import { evaluateAsBoolean } from '@openmrs/esm-expression-evaluator';
import { type FeatureFlagsStore, featureFlagsStore } from '@openmrs/esm-feature-flags';
import { subscribeConnectivityChanged } from '@openmrs/esm-globals';
import { isOnline as isOnlineFn } from '@openmrs/esm-utils';
import { isEqual, merge } from 'lodash-es';
import { checkStatusFor } from './helpers';
import {
type AssignedExtension,
type ExtensionRegistration,
type ExtensionSlotInfo,
type ExtensionInternalStore,
type ExtensionSlotState,
getExtensionStore,
getExtensionInternalStore,
updateInternalExtensionStore,
} from './store';
const extensionInternalStore = getExtensionInternalStore();
const extensionStore = getExtensionStore();
const slotsConfigStore = getExtensionSlotsConfigStore();
const extensionsConfigStore = getExtensionsConfigStore();
// Keep the output store updated
function updateExtensionOutputStore(
internalState: ExtensionInternalStore,
extensionSlotConfigs: ExtensionSlotsConfigStore,
extensionsConfigStore: ExtensionsConfigStore,
featureFlagStore: FeatureFlagsStore,
sessionStore: SessionStore,
) {
const slots: Record<string, ExtensionSlotState> = {};
const isOnline = isOnlineFn();
const enabledFeatureFlags = Object.entries(featureFlagStore.flags)
.filter(([, { enabled }]) => enabled)
.map(([name]) => name);
for (let [slotName, slot] of Object.entries(internalState.slots)) {
const { config } = getExtensionSlotConfigFromStore(extensionSlotConfigs, slot.name);
const assignedExtensions = getAssignedExtensionsFromSlotData(
slotName,
internalState,
config,
extensionsConfigStore,
enabledFeatureFlags,
isOnline,
sessionStore.session,
);
slots[slotName] = { moduleName: slot.moduleName, assignedExtensions };
}
if (!isEqual(extensionStore.getState().slots, slots)) {
extensionStore.setState({ slots });
}
}
extensionInternalStore.subscribe((internalStore) => {
updateExtensionOutputStore(
internalStore,
slotsConfigStore.getState(),
extensionsConfigStore.getState(),
featureFlagsStore.getState(),
sessionStore.getState(),
);
});
slotsConfigStore.subscribe((slotConfigs) => {
updateExtensionOutputStore(
extensionInternalStore.getState(),
slotConfigs,
extensionsConfigStore.getState(),
featureFlagsStore.getState(),
sessionStore.getState(),
);
});
extensionsConfigStore.subscribe((extensionConfigs) => {
updateExtensionOutputStore(
extensionInternalStore.getState(),
slotsConfigStore.getState(),
extensionConfigs,
featureFlagsStore.getState(),
sessionStore.getState(),
);
});
featureFlagsStore.subscribe((featureFlagStore) => {
updateExtensionOutputStore(
extensionInternalStore.getState(),
slotsConfigStore.getState(),
extensionsConfigStore.getState(),
featureFlagStore,
sessionStore.getState(),
);
});
sessionStore.subscribe((session) => {
updateExtensionOutputStore(
extensionInternalStore.getState(),
slotsConfigStore.getState(),
extensionsConfigStore.getState(),
featureFlagsStore.getState(),
session,
);
});
function updateOutputStoreToCurrent() {
updateExtensionOutputStore(
extensionInternalStore.getState(),
slotsConfigStore.getState(),
extensionsConfigStore.getState(),
featureFlagsStore.getState(),
sessionStore.getState(),
);
}
updateOutputStoreToCurrent();
subscribeConnectivityChanged(updateOutputStoreToCurrent);
function createNewExtensionSlotInfo(slotName: string, moduleName?: string): ExtensionSlotInfo {
return {
moduleName,
name: slotName,
attachedIds: [],
config: null,
};
}
/**
* Given an extension ID, which is a string uniquely identifying
* an instance of an extension within an extension slot, this
* returns the extension name.
*
* @example
* ```js
* getExtensionNameFromId("foo#bar")
* --> "foo"
* getExtensionNameFromId("baz")
* --> "baz"
* ```
*/
export function getExtensionNameFromId(extensionId: string) {
const [extensionName] = extensionId.split('#');
return extensionName;
}
export function getExtensionRegistrationFrom(
state: ExtensionInternalStore,
extensionId: string,
): ExtensionRegistration | undefined {
const name = getExtensionNameFromId(extensionId);
return state.extensions[name];
}
export function getExtensionRegistration(extensionId: string): ExtensionRegistration | undefined {
const state = extensionInternalStore.getState();
return getExtensionRegistrationFrom(state, extensionId);
}
/**
* Extensions must be registered in order to be rendered.
* This is handled by the app shell, when extensions are provided
* via the `setupOpenMRS` return object.
* @internal
*/
export const registerExtension: (extensionRegistration: ExtensionRegistration) => void = (extensionRegistration) =>
extensionInternalStore.setState((state) => {
state.extensions[extensionRegistration.name] = {
...extensionRegistration,
instances: [],
};
return state;
});
/**
* Attach an extension to an extension slot.
*
* This will cause the extension to be rendered into the specified
* extension slot, unless it is removed by configuration. Using
* `attach` is an alternative to specifying the `slot` or `slots`
* in the extension declaration.
*
* It is particularly useful when creating a slot into which
* you want to render an existing extension. This enables you
* to do so without modifying the extension's declaration, which
* may be impractical or inappropriate, for example if you are
* writing a module for a specific implementation.
*
* @param slotName a name uniquely identifying the slot
* @param extensionId an extension name, with an optional #-suffix
* to distinguish it from other instances of the same extension
* attached to the same slot.
*/
export function attach(slotName: string, extensionId: string) {
updateInternalExtensionStore((state) => {
const existingSlot = state.slots[slotName];
if (!existingSlot) {
return {
...state,
slots: {
...state.slots,
[slotName]: {
...createNewExtensionSlotInfo(slotName),
attachedIds: [extensionId],
},
},
};
} else {
return {
...state,
slots: {
...state.slots,
[slotName]: {
...existingSlot,
attachedIds: [...existingSlot.attachedIds, extensionId],
},
},
};
}
});
}
/**
* @deprecated Avoid using this. Extension attachments should be considered declarative.
*/
export function detach(extensionSlotName: string, extensionId: string) {
updateInternalExtensionStore((state) => {
const existingSlot = state.slots[extensionSlotName];
if (existingSlot && existingSlot.attachedIds.includes(extensionId)) {
return {
...state,
slots: {
...state.slots,
[extensionSlotName]: {
...existingSlot,
attachedIds: existingSlot.attachedIds.filter((id) => id !== extensionId),
},
},
};
} else {
return state;
}
});
}
/**
* @deprecated Avoid using this. Extension attachments should be considered declarative.
*/
export function detachAll(extensionSlotName: string) {
updateInternalExtensionStore((state) => {
const existingSlot = state.slots[extensionSlotName];
if (existingSlot) {
return {
...state,
slots: {
...state.slots,
[extensionSlotName]: {
...existingSlot,
attachedIds: [],
},
},
};
} else {
return state;
}
});
}
/**
* Get an order index for the extension. This will
* come from either its configured order, its registered order
* parameter, or the order in which it happened to be attached.
*/
function getOrder(
extensionId: string,
configuredOrder: Array<string>,
registeredOrderIndex: number | undefined,
attachedOrder: Array<string>,
) {
const configuredIndex = configuredOrder.indexOf(extensionId);
if (configuredIndex !== -1) {
return configuredIndex;
} else if (registeredOrderIndex !== undefined) {
// extensions that don't have a configured order should appear after those that do
return 1000 + registeredOrderIndex;
} else {
const assignedIndex = attachedOrder.indexOf(extensionId);
if (assignedIndex !== -1) {
// extensions that have neither a configured nor registered order should appear
// after all others
return 2000 + assignedIndex;
} else {
return -1;
}
}
}
function getAssignedExtensionsFromSlotData(
slotName: string,
internalState: ExtensionInternalStore,
config: ExtensionSlotConfig,
extensionConfigStoreState: ExtensionsConfigStore,
enabledFeatureFlags: Array<string>,
isOnline: boolean,
session: Session | null,
): Array<AssignedExtension> {
const attachedIds = internalState.slots[slotName].attachedIds;
const assignedIds = calculateAssignedIds(config, attachedIds);
const extensions: Array<AssignedExtension> = [];
for (let id of assignedIds) {
const { config: rawExtensionConfig } = getExtensionConfigFromStore(extensionConfigStoreState, slotName, id);
const rawExtensionSlotExtensionConfig = getExtensionConfigFromExtensionSlotStore(config, slotName, id);
const extensionConfig = merge(rawExtensionConfig, rawExtensionSlotExtensionConfig);
const name = getExtensionNameFromId(id);
const extension = internalState.extensions[name];
// if the extension has not been registered yet, do not include it
if (extension) {
const requiredPrivileges = extensionConfig?.['Display conditions']?.privileges ?? extension.privileges ?? [];
if (
requiredPrivileges &&
(typeof requiredPrivileges === 'string' || (Array.isArray(requiredPrivileges) && requiredPrivileges.length > 0))
) {
if (!session?.user) {
continue;
}
if (!userHasAccess(requiredPrivileges, session.user)) {
continue;
}
}
const displayConditionExpression = extensionConfig?.['Display conditions']?.expression ?? null;
if (displayConditionExpression !== null) {
try {
if (!evaluateAsBoolean(displayConditionExpression, { session })) {
continue;
}
} catch (e) {
console.error(`Error while evaluating expression ${displayConditionExpression}`, e);
// if the expression has an error, we do not display the extension
continue;
}
}
if (extension.featureFlag && !enabledFeatureFlags.includes(extension.featureFlag)) {
continue;
}
if (window.offlineEnabled && !checkStatusFor(isOnline, extension.online, extension.offline)) {
continue;
}
extensions.push({
id,
name,
moduleName: extension.moduleName,
config: extensionConfig,
featureFlag: extension.featureFlag,
meta: extension.meta,
online: extensionConfig?.['Display conditions']?.online ?? extension.online ?? true,
offline: extensionConfig?.['Display conditions']?.offline ?? extension.offline ?? false,
});
}
}
return extensions;
}
/**
* Gets the list of extensions assigned to a given slot
*
* @param slotName The slot to load the assigned extensions for
* @returns An array of extensions assigned to the named slot
*/
export function getAssignedExtensions(slotName: string): Array<AssignedExtension> {
const internalState = extensionInternalStore.getState();
const { config: slotConfig } = getExtensionSlotConfig(slotName);
const extensionStoreState = extensionsConfigStore.getState();
const featureFlagState = featureFlagsStore.getState();
const sessionState = sessionStore.getState();
const isOnline = isOnlineFn();
const enabledFeatureFlags = Object.entries(featureFlagState.flags)
.filter(([, { enabled }]) => enabled)
.map(([name]) => name);
return getAssignedExtensionsFromSlotData(
slotName,
internalState,
slotConfig,
extensionStoreState,
enabledFeatureFlags,
isOnline,
sessionState.session,
);
}
function calculateAssignedIds(config: ExtensionSlotConfig, attachedIds: Array<string>) {
const addedIds = config.add || [];
const removedIds = config.remove || [];
const idOrder = config.order || [];
const { extensions } = extensionInternalStore.getState();
return [...attachedIds, ...addedIds]
.filter((id) => !removedIds.includes(id))
.sort((idA, idB) => {
const ai = getOrder(idA, idOrder, extensions[getExtensionNameFromId(idA)]?.order, attachedIds);
const bi = getOrder(idB, idOrder, extensions[getExtensionNameFromId(idB)]?.order, attachedIds);
if (bi === -1) {
return -1;
} else if (ai === -1) {
return 1;
} else {
return ai - bi;
}
});
}
/**
* Used by by extension slots at mount time.
*
* @param moduleName The name of the module that contains the extension slot
* @param slotName The extension slot name that is actually used
* @internal
*/
export const registerExtensionSlot: (moduleName: string, slotName: string) => void = (moduleName, slotName) =>
extensionInternalStore.setState((state) => {
const existingModuleName = state.slots[slotName]?.moduleName;
if (existingModuleName && existingModuleName != moduleName) {
console.warn(
`An extension slot with the name '${slotName}' already exists. Refusing to register the same slot name twice (in "registerExtensionSlot"). The existing one is from module ${existingModuleName}.`,
);
return state;
}
if (existingModuleName && existingModuleName == moduleName) {
// Re-rendering an existing slot
return state;
}
if (state.slots[slotName]) {
return {
...state,
slots: {
...state.slots,
[slotName]: {
...state.slots[slotName],
moduleName,
},
},
};
}
const slot = createNewExtensionSlotInfo(slotName, moduleName);
return {
...state,
slots: {
...state.slots,
[slotName]: slot,
},
};
});
/**
* @internal
* Just for testing.
*/
export const reset: () => void = () =>
extensionStore.setState(() => {
return {
slots: {},
extensions: {},
};
});