@openmrs/esm-form-engine-lib 3.3.1-pre.2208 → 3.3.1-pre.2219
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/dist/openmrs-esm-form-engine-lib.js +1 -1
- package/package.json +1 -1
- package/src/components/processor-factory/form-processor-factory.component.tsx +7 -3
- package/src/processors/encounter/encounter-form-processor.test.ts +225 -0
- package/src/processors/encounter/encounter-form-processor.ts +50 -0
- package/src/utils/common-expression-helpers.test.ts +81 -2
- package/src/utils/common-expression-helpers.ts +334 -224
|
@@ -1 +1 @@
|
|
|
1
|
-
var _openmrs_esm_form_engine_lib;(()=>{"use strict";var e,r,t,n,o,i,a,l,s,u,f,d,p,c,h,m,v,g,b,y,w,_={78008:(e,r,t)=>{var n={"./start":()=>Promise.all([t.e(177),t.e(435),t.e(
|
|
1
|
+
var _openmrs_esm_form_engine_lib;(()=>{"use strict";var e,r,t,n,o,i,a,l,s,u,f,d,p,c,h,m,v,g,b,y,w,_={78008:(e,r,t)=>{var n={"./start":()=>Promise.all([t.e(177),t.e(435),t.e(926),t.e(72),t.e(286),t.e(449)]).then((()=>()=>t(69449)))},o=(e,r)=>(t.R=r,r=t.o(n,e)?n[e]():Promise.resolve().then((()=>{throw new Error('Module "'+e+'" does not exist in container.')})),t.R=void 0,r),i=(e,r)=>{if(t.S){var n="default",o=t.S[n];if(o&&o!==e)throw new Error("Container initialization failed as it has already been initialized with a different share scope");return t.S[n]=e,t.I(n,r)}};t.d(r,{get:()=>o,init:()=>i})}},j={};function P(e){var r=j[e];if(void 0!==r)return r.exports;var t=j[e]={id:e,loaded:!1,exports:{}};return _[e].call(t.exports,t,t.exports,P),t.loaded=!0,t.exports}P.m=_,P.c=j,P.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return P.d(r,{a:r}),r},r=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,P.t=function(t,n){if(1&n&&(t=this(t)),8&n)return t;if("object"==typeof t&&t){if(4&n&&t.__esModule)return t;if(16&n&&"function"==typeof t.then)return t}var o=Object.create(null);P.r(o);var i={};e=e||[null,r({}),r([]),r(r)];for(var a=2&n&&t;"object"==typeof a&&!~e.indexOf(a);a=r(a))Object.getOwnPropertyNames(a).forEach((e=>i[e]=()=>t[e]));return i.default=()=>t,P.d(o,i),o},P.d=(e,r)=>{for(var t in r)P.o(r,t)&&!P.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},P.f={},P.e=e=>Promise.all(Object.keys(P.f).reduce(((r,t)=>(P.f[t](e,r),r)),[])),P.u=e=>e+".js",P.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),P.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),t={},n="@openmrs/esm-form-engine-lib:",P.l=(e,r,o,i)=>{if(t[e])t[e].push(r);else{var a,l;if(void 0!==o)for(var s=document.getElementsByTagName("script"),u=0;u<s.length;u++){var f=s[u];if(f.getAttribute("src")==e||f.getAttribute("data-webpack")==n+o){a=f;break}}a||(l=!0,(a=document.createElement("script")).charset="utf-8",a.timeout=120,P.nc&&a.setAttribute("nonce",P.nc),a.setAttribute("data-webpack",n+o),a.src=e),t[e]=[r];var d=(r,n)=>{a.onerror=a.onload=null,clearTimeout(p);var o=t[e];if(delete t[e],a.parentNode&&a.parentNode.removeChild(a),o&&o.forEach((e=>e(n))),r)return r(n)},p=setTimeout(d.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=d.bind(null,a.onerror),a.onload=d.bind(null,a.onload),l&&document.head.appendChild(a)}},P.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},P.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),P.j=719,(()=>{P.S={};var e={},r={};P.I=(t,n)=>{n||(n=[]);var o=r[t];if(o||(o=r[t]={}),!(n.indexOf(o)>=0)){if(n.push(o),e[t])return e[t];P.o(P.S,t)||(P.S[t]={});var i=P.S[t],a="@openmrs/esm-form-engine-lib",l=(e,r,t,n)=>{var o=i[e]=i[e]||{},l=o[r];(!l||!l.loaded&&(!n!=!l.eager?n:a>l.from))&&(o[r]={get:t,from:a,eager:!!n})},s=[];return"default"===t&&(l("@openmrs/esm-framework","8.0.1-pre.3511",(()=>Promise.all([P.e(177),P.e(747),P.e(435),P.e(72),P.e(184),P.e(286),P.e(796)]).then((()=>()=>P(16747))))),l("dayjs","1.11.13",(()=>P.e(353).then((()=>()=>P(74353))))),l("i18next","25.6.0",(()=>P.e(635).then((()=>()=>P(72635))))),l("react-i18next","16.0.0",(()=>Promise.all([P.e(255),P.e(72)]).then((()=>()=>P(77255))))),l("react","18.3.1",(()=>P.e(540).then((()=>()=>P(96540))))),l("swr/immutable","2.3.3",(()=>Promise.all([P.e(177),P.e(72),P.e(606)]).then((()=>()=>P(54225))))),l("swr/infinite","2.3.3",(()=>Promise.all([P.e(177),P.e(72),P.e(41)]).then((()=>()=>P(23041)))))),e[t]=s.length?Promise.all(s).then((()=>e[t]=1)):1}}})(),(()=>{var e;P.g.importScripts&&(e=P.g.location+"");var r=P.g.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var n=t.length-1;n>-1&&(!e||!/^http(s?):/.test(e));)e=t[n--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),P.p=e})(),o=e=>{var r=e=>e.split(".").map((e=>+e==e?+e:e)),t=/^([^-+]+)?(?:-([^+]+))?(?:\+(.+))?$/.exec(e),n=t[1]?r(t[1]):[];return t[2]&&(n.length++,n.push.apply(n,r(t[2]))),t[3]&&(n.push([]),n.push.apply(n,r(t[3]))),n},i=(e,r)=>{e=o(e),r=o(r);for(var t=0;;){if(t>=e.length)return t<r.length&&"u"!=(typeof r[t])[0];var n=e[t],i=(typeof n)[0];if(t>=r.length)return"u"==i;var a=r[t],l=(typeof a)[0];if(i!=l)return"o"==i&&"n"==l||"s"==l||"u"==i;if("o"!=i&&"u"!=i&&n!=a)return n<a;t++}},a=e=>{var r=e[0],t="";if(1===e.length)return"*";if(r+.5){t+=0==r?">=":-1==r?"<":1==r?"^":2==r?"~":r>0?"=":"!=";for(var n=1,o=1;o<e.length;o++)n--,t+="u"==(typeof(l=e[o]))[0]?"-":(n>0?".":"")+(n=2,l);return t}var i=[];for(o=1;o<e.length;o++){var l=e[o];i.push(0===l?"not("+s()+")":1===l?"("+s()+" || "+s()+")":2===l?i.pop()+" "+i.pop():a(l))}return s();function s(){return i.pop().replace(/^\((.+)\)$/,"$1")}},l=(e,r)=>{if(0 in e){r=o(r);var t=e[0],n=t<0;n&&(t=-t-1);for(var i=0,a=1,s=!0;;a++,i++){var u,f,d=a<e.length?(typeof e[a])[0]:"";if(i>=r.length||"o"==(f=(typeof(u=r[i]))[0]))return!s||("u"==d?a>t&&!n:""==d!=n);if("u"==f){if(!s||"u"!=d)return!1}else if(s)if(d==f)if(a<=t){if(u!=e[a])return!1}else{if(n?u>e[a]:u<e[a])return!1;u!=e[a]&&(s=!1)}else if("s"!=d&&"n"!=d){if(n||a<=t)return!1;s=!1,a--}else{if(a<=t||f<d!=n)return!1;s=!1}else"s"!=d&&"n"!=d&&(s=!1,a--)}}var p=[],c=p.pop.bind(p);for(i=1;i<e.length;i++){var h=e[i];p.push(1==h?c()|c():2==h?c()&c():h?l(h,r):!c())}return!!c()},s=(e,r)=>e&&P.o(e,r),u=e=>(e.loaded=1,e.get()),f=e=>Object.keys(e).reduce(((r,t)=>(e[t].eager&&(r[t]=e[t]),r)),{}),d=(e,r,t)=>{var n=t?f(e[r]):e[r];return Object.keys(n).reduce(((e,r)=>!e||!n[e].loaded&&i(e,r)?r:e),0)},p=(e,r,t,n)=>"Unsatisfied version "+t+" from "+(t&&e[r][t].from)+" of shared singleton module "+r+" (required "+a(n)+")",c=e=>{throw new Error(e)},h=e=>{"undefined"!=typeof console&&console.warn&&console.warn(e)},m=(e,r,t)=>t?t():((e,r)=>c("Shared module "+r+" doesn't exist in shared scope "+e))(e,r),v=(e=>function(r,t,n,o,i){var a=P.I(r);return a&&a.then&&!n?a.then(e.bind(e,r,P.S[r],t,!1,o,i)):e(r,P.S[r],t,n,o,i)})(((e,r,t,n,o,i)=>{if(!s(r,t))return m(e,t,i);var a=d(r,t,n);return l(o,a)||h(p(r,t,a,o)),u(r[t][a])})),g={},b={16072:()=>v("default","react",!1,[1,18],(()=>P.e(540).then((()=>()=>P(96540))))),56339:()=>v("default","swr/infinite",!1,[1,2],(()=>P.e(422).then((()=>()=>P(23041))))),92232:()=>v("default","i18next",!1,[1,25],(()=>P.e(635).then((()=>()=>P(72635))))),2076:()=>v("default","react-i18next",!1,[1,16],(()=>P.e(255).then((()=>()=>P(77255))))),15847:()=>v("default","@openmrs/esm-framework",!1,[1,8],(()=>Promise.all([P.e(177),P.e(747),P.e(184)]).then((()=>()=>P(16747))))),44209:()=>v("default","swr/immutable",!1,[1,2],(()=>Promise.all([P.e(177),P.e(225)]).then((()=>()=>P(54225))))),70231:()=>v("default","dayjs",!1,[1,1],(()=>P.e(353).then((()=>()=>P(74353)))))},y={72:[16072],184:[56339,92232],286:[2076,15847],449:[44209,70231],796:[44209,70231]},w={},P.f.consumes=(e,r)=>{P.o(y,e)&&y[e].forEach((e=>{if(P.o(g,e))return r.push(g[e]);if(!w[e]){var t=r=>{g[e]=0,P.m[e]=t=>{delete P.c[e],t.exports=r()}};w[e]=!0;var n=r=>{delete g[e],P.m[e]=t=>{throw delete P.c[e],r}};try{var o=b[e]();o.then?r.push(g[e]=o.then(t).catch(n)):t(o)}catch(e){n(e)}}}))},(()=>{var e={719:0};P.f.j=(r,t)=>{var n=P.o(e,r)?e[r]:void 0;if(0!==n)if(n)t.push(n[2]);else if(/^(184|286|72)$/.test(r))e[r]=0;else{var o=new Promise(((t,o)=>n=e[r]=[t,o]));t.push(n[2]=o);var i=P.p+P.u(r),a=new Error;P.l(i,(t=>{if(P.o(e,r)&&(0!==(n=e[r])&&(e[r]=void 0),n)){var o=t&&("load"===t.type?"missing":t.type),i=t&&t.target&&t.target.src;a.message="Loading chunk "+r+" failed.\n("+o+": "+i+")",a.name="ChunkLoadError",a.type=o,a.request=i,n[1](a)}}),"chunk-"+r,r)}};var r=(r,t)=>{var n,o,[i,a,l]=t,s=0;if(i.some((r=>0!==e[r]))){for(n in a)P.o(a,n)&&(P.m[n]=a[n]);l&&l(P)}for(r&&r(t);s<i.length;s++)o=i[s],P.o(e,o)&&e[o]&&e[o][0](),e[o]=0},t=globalThis.webpackChunk_openmrs_esm_form_engine_lib=globalThis.webpackChunk_openmrs_esm_form_engine_lib||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),P.nc=void 0;var S=P(78008);_openmrs_esm_form_engine_lib=S})();
|
package/package.json
CHANGED
|
@@ -31,11 +31,15 @@ const FormProcessorFactory = ({
|
|
|
31
31
|
|
|
32
32
|
const processor = useMemo(() => {
|
|
33
33
|
const ProcessorClass = formProcessors[formJson.processor];
|
|
34
|
+
let processorInstance;
|
|
34
35
|
if (ProcessorClass) {
|
|
35
|
-
|
|
36
|
+
processorInstance = new ProcessorClass(formJson);
|
|
37
|
+
} else {
|
|
38
|
+
console.error(`Form processor ${formJson.processor} not found, defaulting to EncounterFormProcessor`);
|
|
39
|
+
processorInstance = new EncounterFormProcessor(formJson);
|
|
36
40
|
}
|
|
37
|
-
|
|
38
|
-
return
|
|
41
|
+
processorInstance.prepareFormSchema(formJson);
|
|
42
|
+
return processorInstance;
|
|
39
43
|
}, [formProcessors, formJson.processor]);
|
|
40
44
|
|
|
41
45
|
const [processorContext, setProcessorContext] = useState<FormProcessorContextProps>({
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { EncounterFormProcessor } from './encounter-form-processor';
|
|
2
|
+
import { type FormSchema } from '../../types';
|
|
3
|
+
|
|
4
|
+
describe('EncounterFormProcessor', () => {
|
|
5
|
+
describe('prepareFormSchema - validateCalculateExpressions', () => {
|
|
6
|
+
let processor: EncounterFormProcessor;
|
|
7
|
+
let consoleSpy: jest.SpyInstance;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
processor = new EncounterFormProcessor(null);
|
|
11
|
+
consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
consoleSpy.mockRestore();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should warn when a calculateExpression contains a quoted string that matches a field ID', () => {
|
|
19
|
+
const schema: FormSchema = {
|
|
20
|
+
name: 'Test Form',
|
|
21
|
+
pages: [
|
|
22
|
+
{
|
|
23
|
+
label: 'Page 1',
|
|
24
|
+
sections: [
|
|
25
|
+
{
|
|
26
|
+
label: 'Section 1',
|
|
27
|
+
isExpanded: 'true',
|
|
28
|
+
questions: [
|
|
29
|
+
{
|
|
30
|
+
label: 'Last Menstrual Period',
|
|
31
|
+
type: 'obs',
|
|
32
|
+
id: 'lmp',
|
|
33
|
+
questionOptions: {
|
|
34
|
+
rendering: 'date',
|
|
35
|
+
concept: 'test-concept',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: 'Expected Date of Delivery',
|
|
40
|
+
type: 'obs',
|
|
41
|
+
id: 'edd',
|
|
42
|
+
questionOptions: {
|
|
43
|
+
rendering: 'date',
|
|
44
|
+
concept: 'test-concept',
|
|
45
|
+
calculate: {
|
|
46
|
+
calculateExpression: "calcEDD('lmp')",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
processor: 'EncounterFormProcessor',
|
|
56
|
+
encounterType: 'test-encounter-type',
|
|
57
|
+
referencedForms: [],
|
|
58
|
+
uuid: 'test-form-uuid',
|
|
59
|
+
} as unknown as FormSchema;
|
|
60
|
+
|
|
61
|
+
processor.prepareFormSchema(schema);
|
|
62
|
+
|
|
63
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
64
|
+
expect.stringContaining("incorrectly quotes the field ID 'lmp' as a string"),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should not warn when a calculateExpression uses bare variable references', () => {
|
|
69
|
+
const schema: FormSchema = {
|
|
70
|
+
name: 'Test Form',
|
|
71
|
+
pages: [
|
|
72
|
+
{
|
|
73
|
+
label: 'Page 1',
|
|
74
|
+
sections: [
|
|
75
|
+
{
|
|
76
|
+
label: 'Section 1',
|
|
77
|
+
isExpanded: 'true',
|
|
78
|
+
questions: [
|
|
79
|
+
{
|
|
80
|
+
label: 'Last Menstrual Period',
|
|
81
|
+
type: 'obs',
|
|
82
|
+
id: 'lmp',
|
|
83
|
+
questionOptions: {
|
|
84
|
+
rendering: 'date',
|
|
85
|
+
concept: 'test-concept',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: 'Expected Date of Delivery',
|
|
90
|
+
type: 'obs',
|
|
91
|
+
id: 'edd',
|
|
92
|
+
questionOptions: {
|
|
93
|
+
rendering: 'date',
|
|
94
|
+
concept: 'test-concept',
|
|
95
|
+
calculate: {
|
|
96
|
+
calculateExpression: 'calcEDD(lmp)',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
processor: 'EncounterFormProcessor',
|
|
106
|
+
encounterType: 'test-encounter-type',
|
|
107
|
+
referencedForms: [],
|
|
108
|
+
uuid: 'test-form-uuid',
|
|
109
|
+
} as unknown as FormSchema;
|
|
110
|
+
|
|
111
|
+
processor.prepareFormSchema(schema);
|
|
112
|
+
|
|
113
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should not warn when a quoted string does not match any field ID', () => {
|
|
117
|
+
const schema: FormSchema = {
|
|
118
|
+
name: 'Test Form',
|
|
119
|
+
pages: [
|
|
120
|
+
{
|
|
121
|
+
label: 'Page 1',
|
|
122
|
+
sections: [
|
|
123
|
+
{
|
|
124
|
+
label: 'Section 1',
|
|
125
|
+
isExpanded: 'true',
|
|
126
|
+
questions: [
|
|
127
|
+
{
|
|
128
|
+
label: 'Duration',
|
|
129
|
+
type: 'obs',
|
|
130
|
+
id: 'duration',
|
|
131
|
+
questionOptions: {
|
|
132
|
+
rendering: 'number',
|
|
133
|
+
concept: 'test-concept',
|
|
134
|
+
calculate: {
|
|
135
|
+
calculateExpression: "calcTimeDifference(onsetDate, 'd')",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
processor: 'EncounterFormProcessor',
|
|
145
|
+
encounterType: 'test-encounter-type',
|
|
146
|
+
referencedForms: [],
|
|
147
|
+
uuid: 'test-form-uuid',
|
|
148
|
+
} as unknown as FormSchema;
|
|
149
|
+
|
|
150
|
+
processor.prepareFormSchema(schema);
|
|
151
|
+
|
|
152
|
+
// 'd' is not a field ID, so no warning should be issued
|
|
153
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should warn for nested questions in obsGroups', () => {
|
|
157
|
+
const schema: FormSchema = {
|
|
158
|
+
name: 'Test Form',
|
|
159
|
+
pages: [
|
|
160
|
+
{
|
|
161
|
+
label: 'Page 1',
|
|
162
|
+
sections: [
|
|
163
|
+
{
|
|
164
|
+
label: 'Section 1',
|
|
165
|
+
isExpanded: 'true',
|
|
166
|
+
questions: [
|
|
167
|
+
{
|
|
168
|
+
label: 'Height',
|
|
169
|
+
type: 'obs',
|
|
170
|
+
id: 'height',
|
|
171
|
+
questionOptions: {
|
|
172
|
+
rendering: 'number',
|
|
173
|
+
concept: 'test-concept',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: 'Weight',
|
|
178
|
+
type: 'obs',
|
|
179
|
+
id: 'weight',
|
|
180
|
+
questionOptions: {
|
|
181
|
+
rendering: 'number',
|
|
182
|
+
concept: 'test-concept',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
label: 'Vitals Group',
|
|
187
|
+
type: 'obsGroup',
|
|
188
|
+
id: 'vitalsGroup',
|
|
189
|
+
questionOptions: {
|
|
190
|
+
rendering: 'group',
|
|
191
|
+
concept: 'test-concept',
|
|
192
|
+
},
|
|
193
|
+
questions: [
|
|
194
|
+
{
|
|
195
|
+
label: 'BMI',
|
|
196
|
+
type: 'obs',
|
|
197
|
+
id: 'bmi',
|
|
198
|
+
questionOptions: {
|
|
199
|
+
rendering: 'number',
|
|
200
|
+
concept: 'test-concept',
|
|
201
|
+
calculate: {
|
|
202
|
+
calculateExpression: "calcBMI('height', 'weight')",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
processor: 'EncounterFormProcessor',
|
|
214
|
+
encounterType: 'test-encounter-type',
|
|
215
|
+
referencedForms: [],
|
|
216
|
+
uuid: 'test-form-uuid',
|
|
217
|
+
} as unknown as FormSchema;
|
|
218
|
+
|
|
219
|
+
processor.prepareFormSchema(schema);
|
|
220
|
+
|
|
221
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("incorrectly quotes the field ID 'height' as a string"));
|
|
222
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("incorrectly quotes the field ID 'weight' as a string"));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -81,6 +81,8 @@ const contextInitializableTypes = [
|
|
|
81
81
|
|
|
82
82
|
export class EncounterFormProcessor extends FormProcessor {
|
|
83
83
|
prepareFormSchema(schema: FormSchema) {
|
|
84
|
+
const allFieldIds = new Set<string>();
|
|
85
|
+
|
|
84
86
|
schema.pages.forEach((page) => {
|
|
85
87
|
page.sections.forEach((section) => {
|
|
86
88
|
section.questions.forEach((question) => {
|
|
@@ -90,6 +92,11 @@ export class EncounterFormProcessor extends FormProcessor {
|
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
function prepareFormField(field: FormField, section: FormSection, page: FormPage, schema: FormSchema) {
|
|
95
|
+
// Collect field ID
|
|
96
|
+
if (field.id) {
|
|
97
|
+
allFieldIds.add(field.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
// inherit inlineRendering and readonly from parent section and page if not set
|
|
94
101
|
field.inlineRendering =
|
|
95
102
|
field.inlineRendering ?? section.inlineRendering ?? page.inlineRendering ?? schema.inlineRendering;
|
|
@@ -106,6 +113,9 @@ export class EncounterFormProcessor extends FormProcessor {
|
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
// Validate calculate expressions for common mistakes
|
|
117
|
+
validateCalculateExpressions(schema, allFieldIds);
|
|
118
|
+
|
|
109
119
|
return schema;
|
|
110
120
|
}
|
|
111
121
|
|
|
@@ -368,3 +378,43 @@ async function evaluateCalculateExpression(
|
|
|
368
378
|
values[field.id] = value;
|
|
369
379
|
}
|
|
370
380
|
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Validates calculate expressions to warn about common mistakes.
|
|
384
|
+
* Specifically, checks if string literals in expressions match field IDs,
|
|
385
|
+
* which usually indicates the user should use bare variable references instead.
|
|
386
|
+
*
|
|
387
|
+
* For example: calcEDD('lmp') should be calcEDD(lmp)
|
|
388
|
+
*/
|
|
389
|
+
function validateCalculateExpressions(schema: FormSchema, allFieldIds: Set<string>) {
|
|
390
|
+
const stringLiteralPattern = /(['"])([a-zA-Z_][a-zA-Z0-9_]*)\1/g;
|
|
391
|
+
|
|
392
|
+
function checkExpression(expression: string, fieldId: string) {
|
|
393
|
+
for (const match of expression.matchAll(stringLiteralPattern)) {
|
|
394
|
+
const quotedValue = match[2];
|
|
395
|
+
if (allFieldIds.has(quotedValue)) {
|
|
396
|
+
console.error(
|
|
397
|
+
`The calculateExpression for the field '${fieldId}' incorrectly quotes the field ID '${quotedValue}' as a string. ` +
|
|
398
|
+
`Field IDs must be referenced as variables without quotes to access their values. ` +
|
|
399
|
+
`Remove the quotes: use ${quotedValue} instead of '${quotedValue}'.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function processField(field: FormField) {
|
|
406
|
+
if (field.questionOptions?.calculate?.calculateExpression) {
|
|
407
|
+
checkExpression(field.questionOptions.calculate.calculateExpression, field.id);
|
|
408
|
+
}
|
|
409
|
+
// Process nested questions (for obsGroups)
|
|
410
|
+
if (field.questions) {
|
|
411
|
+
field.questions.forEach(processField);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
schema.pages.forEach((page) => {
|
|
416
|
+
page.sections.forEach((section) => {
|
|
417
|
+
section.questions.forEach(processField);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
@@ -104,6 +104,68 @@ describe('CommonExpressionHelpers', () => {
|
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
describe('isDateAfterSimple', () => {
|
|
108
|
+
it('should return true if the left date is after the right date', () => {
|
|
109
|
+
const left = new Date('2021-12-31');
|
|
110
|
+
const right = '2021-01-01';
|
|
111
|
+
expect(helpers.isDateAfterSimple(left, right)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return false if the left date is not after the right date', () => {
|
|
115
|
+
const left = new Date('2021-01-01');
|
|
116
|
+
const right = '2021-12-31';
|
|
117
|
+
expect(helpers.isDateAfterSimple(left, right)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should accept a Date object as the right parameter', () => {
|
|
121
|
+
const left = new Date('2021-12-31');
|
|
122
|
+
const right = new Date('2021-01-01');
|
|
123
|
+
expect(helpers.isDateAfterSimple(left, right)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should use custom format when provided', () => {
|
|
127
|
+
const left = new Date('2021-12-31');
|
|
128
|
+
const right = '31/01/2021';
|
|
129
|
+
expect(helpers.isDateAfterSimple(left, right, 'DD/MM/YYYY')).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('addWeeksToDate', () => {
|
|
134
|
+
it('should add weeks to a date correctly', () => {
|
|
135
|
+
const date = new Date('2021-01-01');
|
|
136
|
+
const result = helpers.addWeeksToDate(date, 2);
|
|
137
|
+
expect(result).toEqual(new Date('2021-01-15'));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should not mutate the original date', () => {
|
|
141
|
+
const date = new Date('2021-01-01');
|
|
142
|
+
const originalTime = date.getTime();
|
|
143
|
+
helpers.addWeeksToDate(date, 2);
|
|
144
|
+
expect(date.getTime()).toBe(originalTime);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('addDaysToDate', () => {
|
|
149
|
+
it('should add days to a date correctly', () => {
|
|
150
|
+
const date = new Date('2021-01-01');
|
|
151
|
+
const result = helpers.addDaysToDate(date, 10);
|
|
152
|
+
expect(result).toEqual(new Date('2021-01-11'));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should not mutate the original date', () => {
|
|
156
|
+
const date = new Date('2021-01-01');
|
|
157
|
+
const originalTime = date.getTime();
|
|
158
|
+
helpers.addDaysToDate(date, 10);
|
|
159
|
+
expect(date.getTime()).toBe(originalTime);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should handle negative days', () => {
|
|
163
|
+
const date = new Date('2021-01-15');
|
|
164
|
+
const result = helpers.addDaysToDate(date, -5);
|
|
165
|
+
expect(result).toEqual(new Date('2021-01-10'));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
107
169
|
describe('useFieldValue', () => {
|
|
108
170
|
it('should return the field value if the key exists', () => {
|
|
109
171
|
helpers.allFieldValues = { question1: 'value1' };
|
|
@@ -155,6 +217,23 @@ describe('CommonExpressionHelpers', () => {
|
|
|
155
217
|
});
|
|
156
218
|
});
|
|
157
219
|
|
|
220
|
+
describe('calcBSA', () => {
|
|
221
|
+
it('should return the correct BSA value using Mosteller formula', () => {
|
|
222
|
+
// BSA = sqrt((height * weight) / 3600)
|
|
223
|
+
// For height=180cm, weight=75kg: sqrt((180 * 75) / 3600) = sqrt(3.75) ≈ 1.94
|
|
224
|
+
const height = 180;
|
|
225
|
+
const weight = 75;
|
|
226
|
+
expect(helpers.calcBSA(height, weight)).toBeCloseTo(1.94, 2);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should return null if height or weight is not provided', () => {
|
|
230
|
+
expect(helpers.calcBSA(null, 75)).toBe(null);
|
|
231
|
+
expect(helpers.calcBSA(180, null)).toBe(null);
|
|
232
|
+
expect(helpers.calcBSA(0, 75)).toBe(null);
|
|
233
|
+
expect(helpers.calcBSA(180, 0)).toBe(null);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
158
237
|
describe('calcEDD', () => {
|
|
159
238
|
it('should return the expected date of delivery', () => {
|
|
160
239
|
const lmp = new Date('2021-01-01');
|
|
@@ -349,8 +428,8 @@ describe('CommonExpressionHelpers', () => {
|
|
|
349
428
|
expect(helpers.calcTimeDifference(obsDate, 'y')).toBe(1);
|
|
350
429
|
});
|
|
351
430
|
|
|
352
|
-
it('should return
|
|
353
|
-
expect(helpers.calcTimeDifference(null, 'd')).toBe(
|
|
431
|
+
it('should return 0 if obsDate is not provided', () => {
|
|
432
|
+
expect(helpers.calcTimeDifference(null, 'd')).toBe(0);
|
|
354
433
|
});
|
|
355
434
|
});
|
|
356
435
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import dayjs from 'dayjs';
|
|
2
|
-
import
|
|
3
|
-
dayjs.extend(
|
|
2
|
+
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
|
3
|
+
dayjs.extend(customParseFormat);
|
|
4
4
|
import findIndex from 'lodash/findIndex';
|
|
5
5
|
import filter from 'lodash/filter';
|
|
6
6
|
import first from 'lodash/first';
|
|
@@ -28,65 +28,168 @@ export class CommonExpressionHelpers {
|
|
|
28
28
|
this.patient = patient;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Shared helper for Z-score calculations. Finds the standard deviation (SD) value
|
|
33
|
+
* by comparing a measurement against WHO growth reference data.
|
|
34
|
+
* @param refSectionObject - Reference data object with SD columns (e.g., '-3SD', '-2SD', etc.)
|
|
35
|
+
* @param measurementValue - The patient's measurement to compare against reference values
|
|
36
|
+
* @returns The SD score as a string (e.g., '-2', '0', '1') or null if no reference data
|
|
37
|
+
*/
|
|
38
|
+
private calculateZScoreFromRef = (
|
|
39
|
+
refSectionObject: Record<string, any> | undefined,
|
|
40
|
+
measurementValue: number,
|
|
41
|
+
): string | null => {
|
|
42
|
+
if (!refSectionObject) {
|
|
43
|
+
console.warn('Z-score calculation: No reference data object provided');
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const refObjectKeys = Object.keys(refSectionObject);
|
|
48
|
+
const refObjectValues = refObjectKeys.map((key) => refSectionObject[key]);
|
|
49
|
+
const minimumValue = refObjectValues[1];
|
|
50
|
+
const minReferencePoint: number[] = [];
|
|
51
|
+
|
|
52
|
+
if (measurementValue < minimumValue) {
|
|
53
|
+
minReferencePoint.push(minimumValue);
|
|
54
|
+
} else {
|
|
55
|
+
forEach(refObjectValues, (value) => {
|
|
56
|
+
if (value <= measurementValue) {
|
|
57
|
+
minReferencePoint.push(value);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lastReferenceValue = last(minReferencePoint);
|
|
63
|
+
const lastValueIndex = findIndex(refObjectValues, (o) => o === lastReferenceValue);
|
|
64
|
+
const SDValue = refObjectKeys[lastValueIndex];
|
|
65
|
+
let formattedSDValue = SDValue?.replace('SD', '');
|
|
66
|
+
|
|
67
|
+
if (formattedSDValue?.includes('neg')) {
|
|
68
|
+
formattedSDValue = '-' + formattedSDValue.substring(0, 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (formattedSDValue === 'S' || formattedSDValue === 'L' || formattedSDValue === 'M' || formattedSDValue === '-5') {
|
|
72
|
+
formattedSDValue = '-4';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return formattedSDValue ?? null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns the current date and time.
|
|
80
|
+
* @returns A new Date object representing the current moment
|
|
81
|
+
*/
|
|
31
82
|
today = () => {
|
|
32
83
|
return new Date();
|
|
33
84
|
};
|
|
34
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Checks if a collection contains a specific value.
|
|
88
|
+
* @param collection - The array to search in
|
|
89
|
+
* @param value - The value to search for
|
|
90
|
+
* @returns true if the collection contains the value, false otherwise
|
|
91
|
+
*/
|
|
35
92
|
includes = <T = any>(collection: T[], value: T) => {
|
|
36
93
|
return collection?.includes(value);
|
|
37
94
|
};
|
|
38
95
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Checks if the left date is before the right date.
|
|
98
|
+
* @param left - The date to check
|
|
99
|
+
* @param right - The date to compare against (can be a Date object or string)
|
|
100
|
+
* @param format - Optional format string for parsing right date (defaults to 'YYYY-MM-DD')
|
|
101
|
+
* @returns true if left is before right
|
|
102
|
+
*/
|
|
103
|
+
isDateBefore = (left: Date, right: string | Date, format?: string): boolean => {
|
|
104
|
+
const otherDate: Date = right instanceof Date ? right : (format ? dayjs(right, format, true).toDate() : dayjs(right, 'YYYY-MM-DD', true).toDate());
|
|
44
105
|
return left?.getTime() < otherDate.getTime();
|
|
45
106
|
};
|
|
46
107
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Checks if selectedDate is on or after baseDate plus a duration offset.
|
|
110
|
+
* @param selectedDate - The date to check
|
|
111
|
+
* @param baseDate - The base date to add the duration to
|
|
112
|
+
* @param duration - The number of time units to add to baseDate
|
|
113
|
+
* @param timePeriod - The time unit: 'days', 'weeks', 'months', or 'years'
|
|
114
|
+
* @returns true if selectedDate >= (baseDate + duration)
|
|
115
|
+
*/
|
|
116
|
+
isDateAfter = (selectedDate: Date, baseDate: Date, duration: number, timePeriod: 'days' | 'weeks' | 'months' | 'years'): boolean => {
|
|
117
|
+
const parsedBaseDate = dayjs(baseDate);
|
|
51
118
|
|
|
119
|
+
let calculatedDate: Date;
|
|
52
120
|
switch (timePeriod) {
|
|
53
121
|
case 'months':
|
|
54
|
-
calculatedDate =
|
|
122
|
+
calculatedDate = parsedBaseDate.add(duration, 'month').toDate();
|
|
55
123
|
break;
|
|
56
124
|
case 'weeks':
|
|
57
|
-
calculatedDate =
|
|
125
|
+
calculatedDate = parsedBaseDate.add(duration, 'week').toDate();
|
|
58
126
|
break;
|
|
59
127
|
case 'days':
|
|
60
|
-
calculatedDate =
|
|
128
|
+
calculatedDate = parsedBaseDate.add(duration, 'day').toDate();
|
|
61
129
|
break;
|
|
62
130
|
case 'years':
|
|
63
|
-
calculatedDate =
|
|
131
|
+
calculatedDate = parsedBaseDate.add(duration, 'year').toDate();
|
|
64
132
|
break;
|
|
65
133
|
default:
|
|
66
|
-
|
|
134
|
+
calculatedDate = new Date(0);
|
|
67
135
|
}
|
|
68
136
|
return selectedDate.getTime() >= calculatedDate.getTime();
|
|
69
137
|
};
|
|
70
138
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Adds weeks to a date without mutating the original.
|
|
141
|
+
* @param date - The starting date
|
|
142
|
+
* @param weeks - Number of weeks to add
|
|
143
|
+
* @returns A new Date object with the weeks added
|
|
144
|
+
*/
|
|
145
|
+
addWeeksToDate = (date: Date, weeks: number): Date => {
|
|
146
|
+
return dayjs(date).add(weeks, 'week').toDate();
|
|
75
147
|
};
|
|
76
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Adds days to a date without mutating the original.
|
|
151
|
+
* @param date - The starting date
|
|
152
|
+
* @param days - Number of days to add
|
|
153
|
+
* @returns A new Date object with the days added
|
|
154
|
+
*/
|
|
77
155
|
addDaysToDate = (date: Date, days: number): Date => {
|
|
78
156
|
return dayjs(date).add(days, 'day').toDate();
|
|
79
157
|
};
|
|
80
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Simple date comparison - checks if left date is strictly after right date.
|
|
161
|
+
* Mirrors the API of isDateBefore for consistency.
|
|
162
|
+
* @param left - The date to check
|
|
163
|
+
* @param right - The date to compare against (string or Date)
|
|
164
|
+
* @param format - Optional format string for parsing right date (defaults to 'YYYY-MM-DD')
|
|
165
|
+
* @returns true if left is after right
|
|
166
|
+
*/
|
|
167
|
+
isDateAfterSimple = (left: Date, right: string | Date, format?: string): boolean => {
|
|
168
|
+
const otherDate: Date = right instanceof Date ? right : (format ? dayjs(right, format, true).toDate() : dayjs(right, 'YYYY-MM-DD', true).toDate());
|
|
169
|
+
return left?.getTime() > otherDate.getTime();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Retrieves the current value of another form field and registers a dependency.
|
|
174
|
+
* When the referenced field changes, expressions using this helper will be re-evaluated.
|
|
175
|
+
* @param questionId - The ID of the field to get the value from
|
|
176
|
+
* @returns The field's current value, or null if not found/set
|
|
177
|
+
*/
|
|
81
178
|
useFieldValue = (questionId: string) => {
|
|
82
179
|
const targetField = this.allFields.find((field) => field.id === questionId);
|
|
83
180
|
if (targetField) {
|
|
84
|
-
// track field dependency
|
|
85
181
|
registerDependency(this.node, targetField);
|
|
86
182
|
}
|
|
87
183
|
return this.allFieldValues[questionId] ?? null;
|
|
88
184
|
};
|
|
89
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Tests if a value does NOT match a regular expression pattern.
|
|
188
|
+
* Returns true for empty/null/undefined values (treated as non-matching).
|
|
189
|
+
* @param regexString - The regular expression pattern to test against
|
|
190
|
+
* @param val - The value to test
|
|
191
|
+
* @returns true if the value does not match the pattern or is empty/null/undefined
|
|
192
|
+
*/
|
|
90
193
|
doesNotMatchExpression = (regexString: string, val: string | null | undefined): boolean => {
|
|
91
194
|
if (!val || ['undefined', 'null', ''].includes(val.toString())) {
|
|
92
195
|
return true;
|
|
@@ -96,27 +199,41 @@ export class CommonExpressionHelpers {
|
|
|
96
199
|
return !pattern.test(val);
|
|
97
200
|
};
|
|
98
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Calculates Body Mass Index (BMI) from height and weight.
|
|
204
|
+
* Formula: weight (kg) / height (m)²
|
|
205
|
+
* @param height - Height in centimeters
|
|
206
|
+
* @param weight - Weight in kilograms
|
|
207
|
+
* @returns BMI rounded to 1 decimal place, or null if inputs are missing
|
|
208
|
+
*/
|
|
99
209
|
calcBMI = (height: number, weight: number) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
r = (weight / (((height / 100) * height) / 100)).toFixed(1);
|
|
210
|
+
if (!height || !weight) {
|
|
211
|
+
return null;
|
|
103
212
|
}
|
|
104
|
-
|
|
213
|
+
const heightInMeters = height / 100;
|
|
214
|
+
const bmi = (weight / (heightInMeters * heightInMeters)).toFixed(1);
|
|
215
|
+
return parseFloat(bmi);
|
|
105
216
|
};
|
|
106
217
|
|
|
107
218
|
/**
|
|
108
|
-
* Expected
|
|
109
|
-
*
|
|
110
|
-
* @
|
|
219
|
+
* Calculates the Expected Date of Delivery (EDD) from the last menstrual period.
|
|
220
|
+
* Uses Naegele's rule: LMP + 280 days (40 weeks).
|
|
221
|
+
* @param lmp - Last menstrual period date
|
|
222
|
+
* @returns Expected delivery date, or null if lmp is not provided
|
|
111
223
|
*/
|
|
112
|
-
calcEDD = (lmp: Date) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
resultEdd = new Date(lmp.getTime() + 280 * 24 * 60 * 60 * 1000);
|
|
224
|
+
calcEDD = (lmp: Date): Date | null => {
|
|
225
|
+
if (!lmp) {
|
|
226
|
+
return null;
|
|
116
227
|
}
|
|
117
|
-
return lmp
|
|
228
|
+
return new Date(lmp.getTime() + 280 * 24 * 60 * 60 * 1000);
|
|
118
229
|
};
|
|
119
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Calculates the number of complete months a patient has been on ART.
|
|
233
|
+
* @param artStartDate - The date when ART treatment started
|
|
234
|
+
* @returns Number of months on ART, 0 if less than 30 days, or null if no start date
|
|
235
|
+
* @throws Error if artStartDate is not a valid Date object
|
|
236
|
+
*/
|
|
120
237
|
calcMonthsOnART = (artStartDate: Date) => {
|
|
121
238
|
if (artStartDate == null) {
|
|
122
239
|
return null;
|
|
@@ -136,6 +253,18 @@ export class CommonExpressionHelpers {
|
|
|
136
253
|
return dayjs(today).diff(artStartDate, 'month');
|
|
137
254
|
};
|
|
138
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Determines viral load suppression status based on the viral load count.
|
|
258
|
+
*
|
|
259
|
+
* WARNING: This function returns hardcoded concept UUIDs that are specific to certain
|
|
260
|
+
* OpenMRS implementations. These UUIDs may not exist or may differ in your system.
|
|
261
|
+
* Consider using form-level configuration or concept mappings instead.
|
|
262
|
+
*
|
|
263
|
+
* @param viralLoadCount - The viral load count (copies/mL)
|
|
264
|
+
* @returns Concept UUID based on suppression threshold (>50 copies/mL), or null if no count
|
|
265
|
+
* @deprecated Consider implementing viral load status logic in form expressions with
|
|
266
|
+
* configurable concept UUIDs instead of using this hardcoded helper.
|
|
267
|
+
*/
|
|
139
268
|
calcViralLoadStatus = (viralLoadCount: number) => {
|
|
140
269
|
let resultViralLoadStatus: string;
|
|
141
270
|
if (viralLoadCount) {
|
|
@@ -148,46 +277,76 @@ export class CommonExpressionHelpers {
|
|
|
148
277
|
return resultViralLoadStatus ?? null;
|
|
149
278
|
};
|
|
150
279
|
|
|
151
|
-
|
|
152
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Calculates the next clinic visit date based on ARV dispensing duration.
|
|
282
|
+
* @param followupDate - The current follow-up/encounter date
|
|
283
|
+
* @param arvDispensedInDays - Number of days of ARV medication dispensed
|
|
284
|
+
* @returns The next visit date (followupDate + arvDispensedInDays), or null if inputs are missing
|
|
285
|
+
*/
|
|
286
|
+
calcNextVisitDate = (followupDate: Date, arvDispensedInDays: number): Date | null => {
|
|
153
287
|
if (followupDate && arvDispensedInDays) {
|
|
154
|
-
|
|
288
|
+
return new Date(followupDate.getTime() + arvDispensedInDays * 24 * 60 * 60 * 1000);
|
|
155
289
|
}
|
|
156
|
-
return
|
|
290
|
+
return null;
|
|
157
291
|
};
|
|
158
292
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Calculates the treatment end date for patients on ART.
|
|
295
|
+
* Adds a 30-day grace period plus the ARV dispensing duration.
|
|
296
|
+
*
|
|
297
|
+
* WARNING: This function checks against a hardcoded concept UUID (160429AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA)
|
|
298
|
+
* for 'Currently in Treatment' status. This UUID may not exist or may differ in your system.
|
|
299
|
+
* Consider implementing this logic in form expressions with configurable concept references instead.
|
|
300
|
+
*
|
|
301
|
+
* @param followupDate - The current follow-up/encounter date
|
|
302
|
+
* @param arvDispensedInDays - Number of days of ARV medication dispensed
|
|
303
|
+
* @param patientStatus - The patient's treatment status UUID (must match hardcoded UUID)
|
|
304
|
+
* @returns Treatment end date (followupDate + 30 + arvDispensedInDays), or null if conditions not met
|
|
305
|
+
* @deprecated Consider implementing treatment end date logic in form expressions with
|
|
306
|
+
* configurable concept UUIDs instead of using this hardcoded helper.
|
|
307
|
+
*/
|
|
308
|
+
calcTreatmentEndDate = (followupDate: Date, arvDispensedInDays: number, patientStatus: string): Date | null => {
|
|
309
|
+
if (!followupDate || !arvDispensedInDays || patientStatus !== '160429AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') {
|
|
310
|
+
return null;
|
|
164
311
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
: null;
|
|
312
|
+
const extraDaysAdded = 30 + arvDispensedInDays;
|
|
313
|
+
return new Date(followupDate.getTime() + extraDaysAdded * 24 * 60 * 60 * 1000);
|
|
168
314
|
};
|
|
169
315
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Calculates the patient's age in years based on a reference date.
|
|
318
|
+
* Note: Uses year-only calculation (ignores month/day), so a patient born in December 1990
|
|
319
|
+
* will be considered 31 years old on January 1, 2021.
|
|
320
|
+
* @param dateValue - The reference date to calculate age at (defaults to today if not provided)
|
|
321
|
+
* @returns Age in years (year difference only, not precise age)
|
|
322
|
+
*/
|
|
323
|
+
calcAgeBasedOnDate = (dateValue?: ConstructorParameters<typeof Date>[0] | null): number => {
|
|
324
|
+
const targetYear = dateValue ? new Date(dateValue).getFullYear() : new Date().getFullYear();
|
|
325
|
+
const birthYear = new Date(this.patient.birthDate).getFullYear();
|
|
326
|
+
return targetYear - birthYear;
|
|
180
327
|
};
|
|
181
328
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Calculates Body Surface Area (BSA) using the Mosteller formula.
|
|
331
|
+
* Formula: √((height × weight) / 3600)
|
|
332
|
+
* @param height - Height in centimeters
|
|
333
|
+
* @param weight - Weight in kilograms
|
|
334
|
+
* @returns BSA in m² rounded to 2 decimal places, or null if inputs are missing
|
|
335
|
+
*/
|
|
336
|
+
calcBSA = (height: number, weight: number): number | null => {
|
|
337
|
+
if (!height || !weight) {
|
|
338
|
+
return null;
|
|
187
339
|
}
|
|
188
|
-
return
|
|
340
|
+
return parseFloat(Math.sqrt((height * weight) / 3600).toFixed(2));
|
|
189
341
|
};
|
|
190
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Checks if an array contains ALL of the specified members.
|
|
345
|
+
* @param array - The array to search in
|
|
346
|
+
* @param members - A single value or array of values that must all be present
|
|
347
|
+
* @returns true if array contains all members, false otherwise.
|
|
348
|
+
* Returns true for empty members array. Returns false for null/non-array input.
|
|
349
|
+
*/
|
|
191
350
|
arrayContains = <T = any>(array: T[], members: T[] | T) => {
|
|
192
351
|
if (!array || !Array.isArray(array)) {
|
|
193
352
|
return false;
|
|
@@ -214,6 +373,13 @@ export class CommonExpressionHelpers {
|
|
|
214
373
|
return true;
|
|
215
374
|
};
|
|
216
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Checks if an array contains ANY of the specified members.
|
|
378
|
+
* @param array - The array to search in
|
|
379
|
+
* @param members - An array of values where at least one must be present
|
|
380
|
+
* @returns true if array contains at least one member, false otherwise.
|
|
381
|
+
* Returns true for empty members array. Returns false for null/non-array input.
|
|
382
|
+
*/
|
|
217
383
|
arrayContainsAny = <T = any>(array: T[], members: T[]) => {
|
|
218
384
|
if (!array || !Array.isArray(array)) {
|
|
219
385
|
return false;
|
|
@@ -240,14 +406,27 @@ export class CommonExpressionHelpers {
|
|
|
240
406
|
return false;
|
|
241
407
|
};
|
|
242
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Parses a date string into a Date object using OpenMRS framework parsing.
|
|
411
|
+
* @param dateString - The date string to parse
|
|
412
|
+
* @returns A Date object
|
|
413
|
+
*/
|
|
243
414
|
parseDate = (dateString: string) => {
|
|
244
415
|
return parseDate(dateString);
|
|
245
416
|
};
|
|
246
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Formats a date value into a string.
|
|
420
|
+
* @param value - The date to format (Date object or value that can be converted to Date)
|
|
421
|
+
* @param format - Optional dayjs format string (e.g., 'YYYY-MM-DD', 'DD/MM/YYYY').
|
|
422
|
+
* If not provided, uses OpenMRS default locale format.
|
|
423
|
+
* @returns Formatted date string
|
|
424
|
+
* @throws Error if the value cannot be converted to a valid date
|
|
425
|
+
*/
|
|
247
426
|
formatDate = (value: ConstructorParameters<typeof Date>[0], format?: string) => {
|
|
248
427
|
if (!(value instanceof Date)) {
|
|
249
428
|
value = new Date(value);
|
|
250
|
-
if (
|
|
429
|
+
if (isNaN(value.getTime())) {
|
|
251
430
|
throw new Error('DateFormatException: value passed is not a valid date');
|
|
252
431
|
}
|
|
253
432
|
}
|
|
@@ -257,6 +436,12 @@ export class CommonExpressionHelpers {
|
|
|
257
436
|
return formatDate(value);
|
|
258
437
|
};
|
|
259
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Extracts values for a specific key from an array of objects (typically repeating group data).
|
|
441
|
+
* @param key - The property key to extract from each object
|
|
442
|
+
* @param array - Array of objects to extract values from
|
|
443
|
+
* @returns Array of values for the specified key
|
|
444
|
+
*/
|
|
260
445
|
extractRepeatingGroupValues = (key: string | number | symbol, array: Record<string | number | symbol, unknown>[]) => {
|
|
261
446
|
const values = array.map(function (item) {
|
|
262
447
|
return item[key];
|
|
@@ -266,20 +451,14 @@ export class CommonExpressionHelpers {
|
|
|
266
451
|
|
|
267
452
|
/**
|
|
268
453
|
* Calculates the gravida (total number of pregnancies) based on term pregnancies and abortions/miscarriages.
|
|
269
|
-
*
|
|
270
|
-
* @param
|
|
271
|
-
* @
|
|
272
|
-
* @
|
|
273
|
-
* @throws {Error} If either input is not a valid number.
|
|
274
|
-
*
|
|
275
|
-
* @example
|
|
276
|
-
* const gravida = calcGravida(2, 1);
|
|
277
|
-
* console.log(gravida); // Output: 3
|
|
454
|
+
* @param parityTerm - The number of term pregnancies (can be number or numeric string)
|
|
455
|
+
* @param parityAbortion - The number of abortions including miscarriages (can be number or numeric string)
|
|
456
|
+
* @returns The total number of pregnancies (gravida)
|
|
457
|
+
* @throws Error if either input is not a valid number
|
|
278
458
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
const abortion = parseInt(parityAbortion, 10);
|
|
459
|
+
calcGravida = (parityTerm: number | string, parityAbortion: number | string): number => {
|
|
460
|
+
const term = typeof parityTerm === 'number' ? parityTerm : parseInt(parityTerm, 10);
|
|
461
|
+
const abortion = typeof parityAbortion === 'number' ? parityAbortion : parseInt(parityAbortion, 10);
|
|
283
462
|
|
|
284
463
|
if (!Number.isInteger(term) || !Number.isInteger(abortion)) {
|
|
285
464
|
throw new Error('Both inputs must be valid numbers.');
|
|
@@ -288,181 +467,106 @@ export class CommonExpressionHelpers {
|
|
|
288
467
|
return term + abortion;
|
|
289
468
|
};
|
|
290
469
|
|
|
291
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Calculates the Weight-for-Height Z-score for pediatric patients using WHO growth standards.
|
|
472
|
+
* Used to assess acute malnutrition (wasting).
|
|
473
|
+
* @param height - Patient's height/length in centimeters (valid range: 45-110 cm)
|
|
474
|
+
* @param weight - Patient's weight in kilograms
|
|
475
|
+
* @returns Z-score as a string (e.g., '-2', '0', '1'), '-4' if out of range, or null if inputs missing
|
|
476
|
+
*/
|
|
477
|
+
calcWeightForHeightZscore = (height: number, weight: number): string | null => {
|
|
478
|
+
if (!height || !weight) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
292
482
|
const birthDate = new Date(this.patient.birthDate);
|
|
293
483
|
const weightForHeightRef = getZRefByGenderAndAge(this.patient.sex, birthDate, new Date()).weightForHeightRef;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (height && weight) {
|
|
297
|
-
height = parseFloat(height).toFixed(1);
|
|
298
|
-
}
|
|
484
|
+
|
|
485
|
+
const formattedHeight = height.toFixed(1);
|
|
299
486
|
const standardHeightMin = 45;
|
|
300
487
|
const standardMaxHeight = 110;
|
|
301
|
-
if (height < standardHeightMin || height > standardMaxHeight) {
|
|
302
|
-
formattedSDValue = -4;
|
|
303
|
-
} else {
|
|
304
|
-
refSection = filter(weightForHeightRef, (refObject) => {
|
|
305
|
-
return parseFloat(refObject['Length']).toFixed(1) === height;
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
488
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const refObjectValues = Object.keys(refSectionObject)
|
|
312
|
-
.map((key) => refSectionObject[key])
|
|
313
|
-
.map((x) => x);
|
|
314
|
-
const refObjectKeys = Object.keys(refSectionObject);
|
|
315
|
-
const minimumValue = refObjectValues[1];
|
|
316
|
-
const minReferencePoint = [];
|
|
317
|
-
if (weight < minimumValue) {
|
|
318
|
-
minReferencePoint.push(minimumValue);
|
|
319
|
-
} else {
|
|
320
|
-
forEach(refObjectValues, (value) => {
|
|
321
|
-
if (value <= weight) {
|
|
322
|
-
minReferencePoint.push(value);
|
|
323
|
-
}
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
const lastReferenceValue = last(minReferencePoint);
|
|
327
|
-
const lastValueIndex = findIndex(refObjectValues, (o) => {
|
|
328
|
-
return o === lastReferenceValue;
|
|
329
|
-
});
|
|
330
|
-
const SDValue = refObjectKeys[lastValueIndex];
|
|
331
|
-
formattedSDValue = SDValue?.replace('SD', '');
|
|
332
|
-
if (formattedSDValue.includes('neg')) {
|
|
333
|
-
formattedSDValue = formattedSDValue.substring(1, 0);
|
|
334
|
-
formattedSDValue = '-' + formattedSDValue;
|
|
335
|
-
}
|
|
336
|
-
if (
|
|
337
|
-
formattedSDValue === 'S' ||
|
|
338
|
-
formattedSDValue === 'L' ||
|
|
339
|
-
formattedSDValue === 'M' ||
|
|
340
|
-
formattedSDValue === '-5'
|
|
341
|
-
) {
|
|
342
|
-
formattedSDValue = '-4';
|
|
343
|
-
}
|
|
489
|
+
if (parseFloat(formattedHeight) < standardHeightMin || parseFloat(formattedHeight) > standardMaxHeight) {
|
|
490
|
+
return '-4';
|
|
344
491
|
}
|
|
345
492
|
|
|
346
|
-
|
|
493
|
+
const refSection = filter(weightForHeightRef, (refObject) => {
|
|
494
|
+
return parseFloat(refObject['Length']).toFixed(1) === formattedHeight;
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const refSectionObject = first(refSection);
|
|
498
|
+
return this.calculateZScoreFromRef(refSectionObject, weight);
|
|
347
499
|
};
|
|
348
500
|
|
|
349
|
-
|
|
501
|
+
/**
|
|
502
|
+
* Calculates the BMI-for-Age Z-score for pediatric patients using WHO growth standards.
|
|
503
|
+
* Used to assess both undernutrition and overweight/obesity.
|
|
504
|
+
* @param height - Patient's height in centimeters
|
|
505
|
+
* @param weight - Patient's weight in kilograms
|
|
506
|
+
* @returns Z-score as a string (e.g., '-2', '0', '1'), or null if inputs missing
|
|
507
|
+
*/
|
|
508
|
+
calcBMIForAgeZscore = (height: number, weight: number): string | null => {
|
|
509
|
+
if (!height || !weight) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
350
513
|
const birthDate = new Date(this.patient.birthDate);
|
|
351
514
|
const bmiForAgeRef = getZRefByGenderAndAge(this.patient.sex, birthDate, new Date()).bmiForAgeRef;
|
|
352
|
-
let bmi;
|
|
353
|
-
const maxAgeInDays = 1856;
|
|
354
|
-
if (height && weight) {
|
|
355
|
-
bmi = (weight / (((height / 100) * height) / 100)).toFixed(1);
|
|
356
|
-
}
|
|
357
|
-
const refSectionObject = first(bmiForAgeRef);
|
|
358
|
-
let formattedSDValue;
|
|
359
|
-
if (refSectionObject) {
|
|
360
|
-
const refObjectValues = Object.keys(refSectionObject)
|
|
361
|
-
.map((key) => refSectionObject[key])
|
|
362
|
-
.map((x) => x);
|
|
363
|
-
const refObjectKeys = Object.keys(refSectionObject);
|
|
364
|
-
const minimumValue = refObjectValues[1];
|
|
365
|
-
const minReferencePoint = [];
|
|
366
|
-
if (bmi < minimumValue) {
|
|
367
|
-
minReferencePoint.push(minimumValue);
|
|
368
|
-
} else {
|
|
369
|
-
forEach(refObjectValues, (value) => {
|
|
370
|
-
if (value <= bmi) {
|
|
371
|
-
minReferencePoint.push(value);
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
const lastReferenceValue = last(minReferencePoint);
|
|
376
|
-
const lastValueIndex = findIndex(refObjectValues, (o) => {
|
|
377
|
-
return o === lastReferenceValue;
|
|
378
|
-
});
|
|
379
|
-
const SDValue = refObjectKeys[lastValueIndex];
|
|
380
|
-
formattedSDValue = SDValue?.replace('SD', '');
|
|
381
|
-
if (formattedSDValue.includes('neg')) {
|
|
382
|
-
formattedSDValue = formattedSDValue.substring(1, 0);
|
|
383
|
-
formattedSDValue = '-' + formattedSDValue;
|
|
384
|
-
}
|
|
385
515
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
formattedSDValue === 'L' ||
|
|
389
|
-
formattedSDValue === 'M' ||
|
|
390
|
-
formattedSDValue === '-5'
|
|
391
|
-
) {
|
|
392
|
-
formattedSDValue = '-4';
|
|
393
|
-
}
|
|
394
|
-
}
|
|
516
|
+
const heightInMeters = height / 100;
|
|
517
|
+
const bmi = parseFloat((weight / (heightInMeters * heightInMeters)).toFixed(1));
|
|
395
518
|
|
|
396
|
-
|
|
519
|
+
const refSectionObject = first(bmiForAgeRef);
|
|
520
|
+
return this.calculateZScoreFromRef(refSectionObject, bmi);
|
|
397
521
|
};
|
|
398
522
|
|
|
399
|
-
|
|
523
|
+
/**
|
|
524
|
+
* Calculates the Height-for-Age Z-score for pediatric patients using WHO growth standards.
|
|
525
|
+
* Used to assess chronic malnutrition (stunting).
|
|
526
|
+
* @param height - Patient's height/length in centimeters
|
|
527
|
+
* @param _weight - Unused parameter kept for backward compatibility
|
|
528
|
+
* @returns Z-score as a string (e.g., '-2', '0', '1'), or null if height is missing
|
|
529
|
+
*/
|
|
530
|
+
calcHeightForAgeZscore = (height: number, _weight?: number): string | null => {
|
|
531
|
+
if (!height) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
|
|
400
535
|
const birthDate = new Date(this.patient.birthDate);
|
|
401
536
|
const heightForAgeRef = getZRefByGenderAndAge(this.patient.sex, birthDate, new Date()).heightForAgeRef;
|
|
402
537
|
const refSectionObject = first(heightForAgeRef);
|
|
403
|
-
let formattedSDValue;
|
|
404
|
-
if (refSectionObject) {
|
|
405
|
-
const refObjectValues = Object.keys(refSectionObject)
|
|
406
|
-
.map((key) => refSectionObject[key])
|
|
407
|
-
.map((x) => x);
|
|
408
|
-
const refObjectKeys = Object.keys(refSectionObject);
|
|
409
|
-
const minimumValue = refObjectValues[1];
|
|
410
|
-
const minReferencePoint = [];
|
|
411
|
-
if (height < minimumValue) {
|
|
412
|
-
minReferencePoint.push(minimumValue);
|
|
413
|
-
} else {
|
|
414
|
-
forEach(refObjectValues, (value) => {
|
|
415
|
-
if (value <= height) {
|
|
416
|
-
minReferencePoint.push(value);
|
|
417
|
-
}
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
const lastReferenceValue = last(minReferencePoint);
|
|
421
|
-
const lastValueIndex = findIndex(refObjectValues, (o) => {
|
|
422
|
-
return o === lastReferenceValue;
|
|
423
|
-
});
|
|
424
|
-
const SDValue = refObjectKeys[lastValueIndex];
|
|
425
|
-
formattedSDValue = SDValue?.replace('SD', '');
|
|
426
|
-
if (formattedSDValue.includes('neg')) {
|
|
427
|
-
formattedSDValue = formattedSDValue.substring(1, 0);
|
|
428
|
-
formattedSDValue = '-' + formattedSDValue;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (
|
|
432
|
-
formattedSDValue === 'S' ||
|
|
433
|
-
formattedSDValue === 'L' ||
|
|
434
|
-
formattedSDValue === 'M' ||
|
|
435
|
-
formattedSDValue === '-5'
|
|
436
|
-
) {
|
|
437
|
-
formattedSDValue = '-4';
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
538
|
|
|
441
|
-
return
|
|
539
|
+
return this.calculateZScoreFromRef(refSectionObject, height);
|
|
442
540
|
};
|
|
443
541
|
|
|
444
|
-
|
|
445
|
-
|
|
542
|
+
/**
|
|
543
|
+
* Calculates the time difference between an observation date and today.
|
|
544
|
+
* @param obsDate - The observation/reference date to compare against today
|
|
545
|
+
* @param timeFrame - The unit of time: 'd' (days), 'w' (weeks), 'm' (months), or 'y' (years)
|
|
546
|
+
* @returns The absolute time difference as a number, or 0 if obsDate is not provided
|
|
547
|
+
*/
|
|
548
|
+
calcTimeDifference = (obsDate: Date | dayjs.Dayjs, timeFrame: 'd' | 'w' | 'm' | 'y'): number => {
|
|
549
|
+
if (!obsDate) {
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
446
553
|
const endDate = dayjs();
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
if (timeFrame == 'y') {
|
|
458
|
-
daySinceLastObs = Math.abs(Math.round(endDate.diff(obsDate, 'year', true)));
|
|
459
|
-
}
|
|
554
|
+
switch (timeFrame) {
|
|
555
|
+
case 'd':
|
|
556
|
+
return Math.abs(Math.round(endDate.diff(obsDate, 'day', true)));
|
|
557
|
+
case 'w':
|
|
558
|
+
return Math.abs(Math.round(endDate.diff(obsDate, 'week', true)));
|
|
559
|
+
case 'm':
|
|
560
|
+
return Math.abs(Math.round(endDate.diff(obsDate, 'month', true)));
|
|
561
|
+
case 'y':
|
|
562
|
+
return Math.abs(Math.round(endDate.diff(obsDate, 'year', true)));
|
|
460
563
|
}
|
|
461
|
-
return daySinceLastObs === '' ? '0' : daySinceLastObs;
|
|
462
564
|
};
|
|
463
565
|
|
|
464
566
|
/**
|
|
465
|
-
*
|
|
567
|
+
* Resolves a Promise and returns its value. Used to await async operations in form expressions.
|
|
568
|
+
* @param lazy - A Promise to resolve
|
|
569
|
+
* @returns A Promise that resolves to the value of the input Promise
|
|
466
570
|
*/
|
|
467
571
|
resolve = (lazy: Promise<unknown>) => {
|
|
468
572
|
return Promise.resolve(lazy);
|
|
@@ -484,6 +588,12 @@ export function simpleHash(str: string) {
|
|
|
484
588
|
return hash;
|
|
485
589
|
}
|
|
486
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Registers a dependency relationship between a form node and a field.
|
|
593
|
+
* When the determinant field's value changes, the dependent node will be re-evaluated.
|
|
594
|
+
* @param node - The dependent node (page, section, or field) that depends on the determinant
|
|
595
|
+
* @param determinant - The field that the node depends on
|
|
596
|
+
*/
|
|
487
597
|
export function registerDependency(node: FormNode, determinant: FormField) {
|
|
488
598
|
if (!node || !determinant) {
|
|
489
599
|
return;
|