Skip to content

Commit

Permalink
(feat) Create an application-wide context for shared state (#976)
Browse files Browse the repository at this point in the history
  • Loading branch information
ibacher authored May 13, 2024
1 parent 5197b42 commit 9693a2b
Show file tree
Hide file tree
Showing 27 changed files with 914 additions and 21 deletions.
3 changes: 3 additions & 0 deletions packages/framework/esm-context/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# openmrs-esm-context

openmrs-esm-context provides the AppContext that is useful for sharing contextual state across the application.
9 changes: 9 additions & 0 deletions packages/framework/esm-context/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
transform: {
'^.+\\.tsx?$': ['@swc/jest'],
},
testEnvironment: 'jsdom',
testEnvironmentOptions: {
url: 'http://localhost/',
},
};
24 changes: 24 additions & 0 deletions packages/framework/esm-context/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const appContext = {};

const nothing = Object();

export function registerContext<T extends {} = {}>(namespace: string, initialValue: T = nothing) {
appContext[namespace] = initialValue ?? {};
}

export function getContext<T extends {} = {}, U extends {} = T>(
namespace: string,
selector: (state: Readonly<T>) => U = (state) => state as unknown as U,
): Readonly<U> | null {
const value = appContext[namespace];

if (!value) {
return null;
}

return Object.freeze(Object.assign({}, selector ? selector(value) : value));
}

export function updateContext<T extends {} = {}>(namespace: string, update: (state: T) => T) {
appContext[namespace] = update(appContext[namespace] ?? {});
}
52 changes: 52 additions & 0 deletions packages/framework/esm-context/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@openmrs/esm-context",
"version": "5.5.0",
"license": "MPL-2.0",
"description": "Utilities for managing the current execution context",
"browser": "dist/openmrs-esm-context.js",
"main": "src/index.ts",
"source": true,
"sideEffects": false,
"scripts": {
"test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color",
"test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color",
"build": "webpack --mode=production",
"build:development": "webpack --mode development",
"analyze": "webpack --mode=production --env analyze=true",
"typescript": "tsc",
"lint": "eslint src --ext ts,tsx"
},
"keywords": [
"openmrs",
"microfrontends"
],
"directories": {
"lib": "dist",
"src": "src"
},
"browserslist": [
"extends browserslist-config-openmrs"
],
"repository": {
"type": "git",
"url": "git+https://github.com/openmrs/openmrs-esm-core.git"
},
"bugs": {
"url": "https://github.com/openmrs/openmrs-esm-core/issues"
},
"homepage": "https://github.com/openmrs/openmrs-esm-core#readme",
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@openmrs/esm-globals": "5.x",
"@openmrs/esm-state": "5.x"
},
"devDependencies": {
"@openmrs/esm-globals": "workspace:*",
"@openmrs/esm-state": "workspace:*"
},
"dependencies": {
"immer": "^10.0.4"
}
}
110 changes: 110 additions & 0 deletions packages/framework/esm-context/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/** @module @category Context */
'use strict';

import { createStore } from 'zustand/vanilla';
import { registerGlobalStore } from '@openmrs/esm-state';

interface OpenmrsAppContext {
[namespace: string]: unknown;
}

/**
* @internal
*
* The application context store, using immer to potentially simplify updates
*/
export const contextStore = createStore<OpenmrsAppContext>()(() => ({}));

registerGlobalStore<OpenmrsAppContext>('openmrs-app-context', contextStore);

const nothing = Object();

/**
* Used by callers to register a new namespace in the application context. Attempting to register
* an already-registered namespace will display a warning and make no modifications to the state.
*
* @param namespace the namespace to register
* @param initialValue the initial value of the namespace
*/
export function registerContext<T extends {} = {}>(namespace: string, initialValue: T = nothing) {
contextStore.setState((state) => {
if (namespace in state) {
throw new Error(
`Attempted to re-register namespace ${namespace} in the app context. Each namespace must be unregistered before the name can be registered again.`,
);
}

state[namespace] = initialValue === nothing ? {} : initialValue;
return state;
});
}

/**
* Used by caller to unregister a namespace in the application context. Unregistering a namespace
* will remove the namespace and all associated data.
*/
export function unregisterContext(namespace: string) {
contextStore.setState((state) => {
if (namespace in state) {
delete state[namespace];
}
return state;
});
}

export function getContext<T extends {} = {}>(namespace: string): Readonly<T> | null;
/**
* Returns an _immutable_ version of the state of the namespace as it is currently
*
* @typeParam T The type of the value stored in the namespace
* @typeParam U The return type of this hook which is mostly relevant when using a selector
* @param namespace The namespace to load properties from
* @param selector An optional function which extracts the relevant part of the state
*/
export function getContext<T extends {} = {}, U extends {} = T>(
namespace: string,
selector: (state: Readonly<T>) => U = (state) => state as unknown as U,
): Readonly<U> | null {
const state = contextStore.getState();
if (namespace in state) {
return Object.freeze(Object.assign({}, (selector ? selector(state[namespace] as T) : state[namespace]) as U));
}

return null;
}

/**
* Updates a namespace in the global context. If the namespace does not exist, it is registered.
*/
export function updateContext<T extends {} = {}>(namespace: string, update: (state: T) => T) {
contextStore.setState((state) => {
if (!(namespace in state)) {
state[namespace] = {};
}

state[namespace] = update(state[namespace] as T);
return state;
});
}

export type ContextCallback<T extends {} = {}> = (state: Readonly<T> | null | undefined) => void;

/**
* Subscribes to updates of a given namespace. Note that the returned object is immutable.
*
* @param namespace the namespace to subscribe to
* @param callback a function invoked with the current context whenever
* @returns A function to unsubscribe from the context
*/
export function subscribeToContext<T extends {} = {}>(namespace: string, callback: ContextCallback<T>) {
let previous = getContext<T>(namespace);

return contextStore.subscribe((state) => {
let current: Readonly<T> | null | undefined = namespace in state ? (state[namespace] as T) : null;

if (current !== previous) {
previous = current;
callback(Object.freeze(Object.assign({}, current)));
}
});
}
1 change: 1 addition & 0 deletions packages/framework/esm-context/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './context';
1 change: 1 addition & 0 deletions packages/framework/esm-context/src/public.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './context';
25 changes: 25 additions & 0 deletions packages/framework/esm-context/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"esModuleInterop": true,
"module": "esnext",
"target": "es2015",
"allowSyntheticDefaultImports": true,
"jsx": "react",
"strictNullChecks": true,
"moduleResolution": "node",
"declaration": true,
"declarationDir": "dist",
"emitDeclarationOnly": true,
"lib": [
"dom",
"es5",
"scripthost",
"es2015",
"es2015.promise",
"es2016.array.include",
"es2018",
"esnext"
]
},
"include": ["src/**/*"]
}
42 changes: 42 additions & 0 deletions packages/framework/esm-context/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const { resolve, basename } = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

const { browser, peerDependencies } = require('./package.json');

module.exports = (env) => ({
entry: [resolve(__dirname, 'src/index.ts')],
output: {
filename: basename(browser),
path: resolve(__dirname, 'dist'),
library: { type: 'system' },
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.m?(js|ts|tsx)$/,
exclude: /node_modules/,
use: 'swc-loader',
},
],
},
externals: Object.keys(peerDependencies || {}),
resolve: {
extensions: ['.ts', '.js', '.tsx', '.jsx'],
},
plugins: [
new CleanWebpackPlugin(),
new ForkTsCheckerWebpackPlugin(),
new BundleAnalyzerPlugin({
analyzerMode: env && env.analyze ? 'static' : 'disabled',
}),
],
devServer: {
disableHostCheck: true,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
});
2 changes: 0 additions & 2 deletions packages/framework/esm-dynamic-loading/src/dynamic-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,5 +274,3 @@ function loadScript(
}
}
}

function closureScope() {}
Loading

0 comments on commit 9693a2b

Please sign in to comment.