-
Notifications
You must be signed in to change notification settings - Fork 277
O3-4931: Add ability to evaluate expressions in extensions #1422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Size Change: -163 kB (-2.49%) Total Size: 6.4 MB
ℹ️ View Unchanged
|
a10ab77 to
fb07729
Compare
|
Thank you so much @CynthiaKamau for working on this!! Sorry you have been waiting for weeks for a review - Ian has been super swamped with some very urgent complex projects. Thank you for your patience and again, thank you SO MUCH for contributing this in follow up to Ian's suggestions here. I made a ticket at https://openmrs.atlassian.net/browse/O3-4931 to reflect/track this work plan so it's not just hanging out in the heap of slack :P While we await a review, I have a few questions, please forgive me as I think they're basic, but this will help me understand and communicate this exciting work more widely.
Thank you again!!!! I'm super impressed by your turnaround time in response to Ian's suggestions on Slack 🙏 CCing @denniskigen because I think he'll want to be aware of this important work, and @NethmiRodrigo because this directly relates to Clinical Views (IIUC) which she's been working on / thinking about a lot lately, and we'll want to use this in our HIV Package support work for DRC. |
|
Per Ian's direct request, assigning this to @samuelmale as the primary reviewer. |
ibacher
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @CynthiaKamau! I think this PR as-written does much more than it should. What we should be adding here is a core building block for functionalities that can be properly leveraged by supplying data at sensible points in the application. Additionally, I've had a re-think about how this should be implemented. See my rather long comment on useAssignExtensionExpressionContext().
| if (!evaluateAsBoolean(displayConditionExpression, { session })) { | ||
| // Get patient UUID from URL and add to evaluation context | ||
| const patientUuid = getPatientUuidFromUrl(); | ||
| let evaluationContext: any = { ...extensionExpressionContext, session }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| let evaluationContext: any = { ...extensionExpressionContext, session }; | |
| let evaluationContext = { ...extensionExpressionContext, session }; |
| export interface ExtensionExpressionContextStore { | ||
| [key: string]: any; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export interface ExtensionExpressionContextStore { | |
| [key: string]: any; | |
| } | |
| export type ExtensionExpressionContextStore = Record<string | symbol | number, unknown>; |
| // Automatic patient context management | ||
| let currentPatientUuid: string | null = null; | ||
| let patientDataCache: { [uuid: string]: any } = {}; | ||
|
|
||
| async function fetchPatientData(patientUuid: string) { | ||
| // Check cache first | ||
| if (patientDataCache[patientUuid]) { | ||
| return patientDataCache[patientUuid]; | ||
| } | ||
|
|
||
| try { | ||
| const response = await openmrsFetch(`/ws/rest/v1/patient/${patientUuid}`); | ||
| const patientData = response.data; | ||
|
|
||
| // Cache the result | ||
| patientDataCache[patientUuid] = patientData; | ||
|
|
||
| return patientData; | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function updatePatientContext() { | ||
| const patientUuid = getPatientUuidFromUrl(); | ||
|
|
||
| if (patientUuid !== currentPatientUuid) { | ||
| currentPatientUuid = patientUuid; | ||
|
|
||
| if (patientUuid) { | ||
| // Fetch patient data and update context | ||
| fetchPatientData(patientUuid).then((patientData) => { | ||
| // Only update if we're still on the same patient page | ||
| if (patientData && currentPatientUuid === patientUuid) { | ||
| const currentContext = extensionExpressionContextStore.getState(); | ||
| const newContext: any = { | ||
| ...currentContext, | ||
| patient: patientData, | ||
| patientUuid: patientUuid, | ||
| }; | ||
|
|
||
| extensionExpressionContextStore.setState(newContext); | ||
| } | ||
| }); | ||
| } else { | ||
| // Clear patient context when not on patient page | ||
| const currentContext = extensionExpressionContextStore.getState(); | ||
| const newContext = { ...currentContext }; | ||
| delete newContext.patient; | ||
| delete newContext.patientUuid; | ||
| extensionExpressionContextStore.setState(newContext); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Update patient context on URL changes | ||
| function handleUrlChange() { | ||
| updatePatientContext(); | ||
| updateOutputStoreToCurrent(); | ||
| } | ||
|
|
||
| // Listen for URL changes | ||
| window.addEventListener('popstate', handleUrlChange); | ||
|
|
||
| // Override pushState and replaceState to detect programmatic navigation | ||
| const originalPushState = history.pushState; | ||
| const originalReplaceState = history.replaceState; | ||
|
|
||
| history.pushState = function (...args) { | ||
| originalPushState.apply(history, args); | ||
| setTimeout(handleUrlChange, 0); | ||
| }; | ||
|
|
||
| history.replaceState = function (...args) { | ||
| originalReplaceState.apply(history, args); | ||
| setTimeout(handleUrlChange, 0); | ||
| }; | ||
|
|
||
| // Initial setup | ||
| updatePatientContext(); | ||
|
|
||
| function getPatientUuidFromUrl() { | ||
| const match = /\/patient\/([a-zA-Z0-9\-]+)\/?/.exec(location.pathname); | ||
| return match && match[1]; | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few things on this section of code:
- Automatic patient context is not a concern that should be handled at the level of the esm-extensions. esm-extensions should be left quite generic. It definitely shouldn't be monitoring the URL for changes or trying to parse the patient from that context. Among other things, that URL schema only works in the context of the patient chart, but this would make it something that takes up processing cycles in every context. Instead all of this logic should be in the patient-chart-app.
- Because of the way the patient chart works, we currently generally pump the correct patient into the extension context using the
stateproperty. Maybe we can devise a
| order?: Array<string>; | ||
| configure?: ExtensionSlotConfigureValueObject; | ||
| /** Expression to determine whether this extension slot should be displayed */ | ||
| displayExpression?: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't seem like it should be a property of extension slots. Code rendering extension slots can already make context-specific decisions to render them or not. Instead this should only apply to extensions.
| import { isOnline as isOnlineFn } from '@openmrs/esm-utils'; | ||
| import { isEqual, merge } from 'lodash-es'; | ||
| import { checkStatusFor } from './helpers'; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| let evaluationContext: any = { ...extensionExpressionContext, session }; | ||
|
|
||
| if (patientUuid) { | ||
| evaluationContext = { ...extensionExpressionContext, session, patientUuid }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really see a lot of value to trying to always add a patientUuid field here. What possible expression do we need to support that depends on the patientUuid?
| * | ||
| * @param context Additional context data to provide to extension display expressions | ||
| */ | ||
| export function useAssignExtensionExpressionContext(context: Record<string, any>) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Always prefer unknown where we do not know or care about the type of an object. Using any effectively opts-out of type-checking and makes it too easy to introduce unintended errors.
| export function useAssignExtensionExpressionContext(context: Record<string, any>) { | |
| export function useAssignExtensionExpressionContext(context: Record<string, unknown>) { |
| const currentState = extensionExpressionContextStore.getState(); | ||
| const newState = { ...currentState }; | ||
| Object.keys(context).forEach((key) => { | ||
| delete newState[key]; | ||
| }); | ||
| extensionExpressionContextStore.setState(newState); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than writing things this way, it's better to take advantage of setState() to do:
extensionExpressionContextStore.setState((currentState) => {
const result = {};
Object.entries(currentState).forEach((key, value) => {
if (!Object.hasOwn(context, key)) {
result[key] = value;
}
});
return result;
});There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And elsewhere...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, while I realize this was in my little specification, now that I look at it, I think there's actually a better way to implement extension expression context that will work much better and that's to be enable ExtensionSlot's to pass certain variables to the context via the state. So, when we render an extensionSlot it has a state property that is generally passed down to the extension.
Perhaps what we do is to push this state into the extension system itself, e.g., so when we call useExtensionSlot(), we also pass in the slot state, which can be associated with the slot initially in the call to registerExtensionSlot() and then, with a second useEffect() hook and a new function in esm-extensions that updates the state property of the slot in extensionInternalStore (which doesn't currently exist). That way, we can use the ExtensionSlot state directly in expression evaluation. This would also mean that since most extension slots in the patient chart already pass the patient as part of their state, we'd probably have to do very little work to make this mechanism work for the patient-chart (other than ensuring that the nav-group correctly passes any state to any dashboards underneath it).
It also means that each extension could evaluate rules against slot-specific data, which should help make this more generally useful.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @ibacher , let me make the necessary changes
ibacher
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See my previous comments.
|
| */ | ||
|
|
||
| import { type Session, type SessionStore, sessionStore, userHasAccess } from '@openmrs/esm-api'; | ||
| /* eslint-disable no-console */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like a debugging leftover
| ); | ||
| }; | ||
|
|
||
| export const extensionSlotConfigSchema = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you using this anywhere?
| } catch (e) { | ||
| console.error(`Error while evaluating expression ${displayConditionExpression}`, e); | ||
| // if the expression has an error, we do not display the extension | ||
| } catch (error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's important to log the error to the console for debugging purposes.
| /** | ||
| * If supplied, this is used to determine the display expression for the extension. | ||
| * This is used to determine if the extension is displayed in the UI. | ||
| * If not supplied, the extension will be displayed in the UI by default. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the extension will be displayed in the UI by default
Well, assuming the logged-in user possesses the required privileges.
| extensionConfig?.['Display expression']?.expression ?? | ||
| (typeof extension.displayExpression === 'string' | ||
| ? extension.displayExpression | ||
| : (extension.displayExpression as any)?.expression) ?? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally, extension.displayExpression should be a string property. We won't have any issues if the schema is updated.
1cc660f to
35df8ab
Compare
| "moduleName": "Module name", | ||
| "modulesWithMissingDependenciesWarning": "Some modules have unresolved backend dependencies", | ||
| "numberValidationMessage": "Value must be a number", | ||
| "objectPropertyValidationMessage": "Property {{key}}: {{error}}", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you remove this by mistake?
| getExtensionSlotsConfigStore, | ||
| } from '@openmrs/esm-config'; | ||
| import { evaluateAsBoolean } from '@openmrs/esm-expression-evaluator'; | ||
| import { evaluateAsBoolean, type VariablesMap } from '@openmrs/esm-expression-evaluator'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you using this import?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removing it
| if (!evaluateAsBoolean(displayConditionExpression, { session })) { | ||
| const context = | ||
| slotState && typeof slotState === 'object' ? { session, ...slotState } : { session, slotState }; | ||
| if (!evaluateExpression(displayConditionExpression, context)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you use the framework's built-in expression evaluator?
| slotName, | ||
| state, | ||
| ) => | ||
| extensionInternalStore.setState((slotState) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| extensionInternalStore.setState((slotState) => { | |
| extensionInternalStore.setState((currentState) => { |
| }); | ||
|
|
||
| export function setExtensionSlotState(slotName: string, state: ExtensionSlotCustomState) { | ||
| extensionInternalStore.setState((slotState) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, it's kind of misleading to refer to this as slotState. currentState is a better name.
| if (!evaluateAsBoolean(displayConditionExpression, { session })) { | ||
| const context: VariablesMap = | ||
| slotState && typeof slotState === 'object' ? { session, ...slotState } : { session, slotState }; | ||
| if (!evaluateAsBooleanAsync(displayConditionExpression, context)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hhhmmm, usingevaluateAsBooleanAsync(...) will always return a Promise, hence this condition will always be false unless you resolve the Promise. Can you use evaluateAsBoolean(...) instead?
|
Thanks for your answers above @CynthiaKamau!
When I go to https://ethiohri-dev.globalhealthapp.net/openmrs/spa (logged in), I only see the home page. Where in the code or config or admin does someone configure the logic for what is shown/hidden depending on certain patient variables? |
9875f2a to
1fd0499
Compare
The evaluation expressions were working on the patient chart clinical views, the expressions were being added in the dashboard.meta file, like here |
| const displayConditionExpression = extensionConfig?.['Display conditions']?.expression ?? null; | ||
| if (displayConditionExpression !== null) { | ||
| const displayConditionExpression = | ||
| extensionConfig?.['Display conditions']?.expression ?? extension.expression ?? null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CynthiaKamau So, because you provide an implicit config value of "" for the expression under "Display conditions", this statement will never resolve expressions defined via extension def in the routes file (extension.expression). This is because extensionConfig?.['Display conditions']?.expression will never be null or undefined. You should instead:
| extensionConfig?.['Display conditions']?.expression ?? extension.expression ?? null; | |
| extensionConfig?.['Display conditions']?.expression || extension.expression || null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CynthiaKamau, I've also tested out this feature using the extension config, and it doesn't seem to work. This is because extensionConfig.['Display conditions'] points to the module's config, and not the extension config provided via the slot. Below is my demo config:
{
"@openmrs/esm-appointments-app": {
"appointments-metrics-slot": {
"configure": {
"metrics-card-scheduled-appointments": {
"Display conditions": {
"privileges": [],
"expression": "false"
}
}
}
}
}
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm... that means several other features are broken (see line 345 which should be handling that).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CynthiaKamau, I've also tested out this feature using the extension config, and it doesn't seem to work. This is because
extensionConfig.['Display conditions']points to the module's config, and not the extension config provided via the slot. Below is my demo config:{ "@openmrs/esm-appointments-app": { "appointments-metrics-slot": { "configure": { "metrics-card-scheduled-appointments": { "Display conditions": { "privileges": [], "expression": "false" } } } } } }
I am using the same approach used in handling privileges, which are also part of the display conditions. Since state comes from an extension, if there is no state passed from your extension the evaluation will always be false
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CynthiaKamau have you tested this out at runtime to confirm that it works using extension config? If so, can you show me a sample config?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CynthiaKamau have you tested this out at runtime to confirm that it works using extension config? If so, can you show me a sample config?
{
"@openmrs/esm-patient-chart-app": {
"extensionSlots": {
"patient-info-slot": {
"configure": {
"patient-vitals-info": {
"Display conditions": {
"expression": "patient.person.gender === 'F'"
}
}
}
}
}
}
}
1fd0499 to
ef54a03
Compare
| useEffect(() => { | ||
| if (state) { | ||
| setExtensionSlotState(slotName, state); | ||
| } | ||
| }, [slotName, state]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we have this check when it seems like by accepting null or undefined here, we remove the need for calling updateInternalExtensionSotre() in the ExtensionSlot at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That said, what we actually need here is a guard against running this on initial render, since registerExtensionSlot() already does that. That way we guard against an unnecessary cycle of cascading updates.
This hopefully addresses some timing issues, but is also reflective of the order these things would run in the a real-world scenario.
Might be better to just swap to JestDom?
This will hopefully make things less flaky...
| * Recomputes all configuration derived stores based on current state of input stores. | ||
| * Called whenever any input store (configInternalStore, temporaryConfigStore, configExtensionStore) changes. | ||
| */ | ||
| function recomputeAllConfigs() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be roughly equivalent to what was happening before, albeit with less chaining.
| const newState = { slots: { ...oldState.slots, ...newSlotStoreEntries } }; | ||
| if (!equals(oldState, newState)) { | ||
|
|
||
| if (!equals(oldState.slots, newState.slots)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These comparison guards should mean that we're calling setState() less frequently
| throw `Unknown expression type ${expression}. Expressions must either be a string or pre-compiled string.`; | ||
| } | ||
|
|
||
| if (typeof expression === 'string' && expression.trim().length === 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One issue here is that we didn't prevent attempted evaluation of an empty string
|
|
||
| // Create context once for all extensions in this slot | ||
| const slotState = internalState.slots[slotName]?.state; | ||
| const expressionContext = slotState && typeof slotState === 'object' ? { session, ...slotState } : { session }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using a single expressionContext is more efficient but may enable subtle errors if an expression modifies global state as a side-effect.
Requirements
feat,fix, orchore, among others). See existing PR titles for inspiration.For changes to apps
If applicable
Summary
Add ability to evaluate expressions in extensions
Screenshots
Screen.Recording.2025-07-14.at.13.54.37.mov
Related Issue
Other