From 6df4cafdb49a7aa20b3ba5cf56e48bc02c99c6bd Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Sun, 12 Jul 2026 11:20:44 -0500 Subject: [PATCH 01/41] Initilize NewEnketoService --- webapp/src/ts/services/NewEnketoService.ts | 247 +++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 webapp/src/ts/services/NewEnketoService.ts diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts new file mode 100644 index 00000000000..ec750cfddbd --- /dev/null +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -0,0 +1,247 @@ +import { Injectable, NgZone } from '@angular/core'; +import type JQuery from 'jquery'; +import events from 'enketo-core/src/js/event'; +import { DOC_TYPES } from '@medic/constants'; +import { v7 as uuid } from 'uuid'; +import { Xpath } from '@mm-providers/xpath-element-path.provider'; + +@Injectable({ + providedIn: 'root' +}) +export class NewEnketoService { + constructor( + private readonly ngZone: NgZone + ) { } + + async saveContact() { + + } + + // TODO Make sure extractLineageService.extract is called on given contact. + async saveReport(form: EnketoForm, defaultData: Record = {}) { + return this.ngZone.runOutsideAngular(async () => { + await form.validate(); + const reportDoc = this.getReportDoc(form.config.internalId, defaultData); + + + }); + } + + private getReportDoc(form: string, defaultData: Record) { + if (defaultData._id) { + return { ...defaultData }; + } + return { + _id: uuid(), + form, + type: DOC_TYPES.DATA_RECORD, + content_type: 'xml', + reported_date: Date.now(), + from: defaultData.contact?.phone, + ...defaultData + }; + } +} + + + +// TODO Should return direct from xml-forms.service... + +export class EnketoForm { + constructor( + private readonly form: Record, + public readonly config: FormConfig + ) { } + + public async validate() { + const valid = await this.form.validate(); + if (!valid) { + throw new Error('Form is invalid'); + } + this.form.view.html.dispatchEvent(events.BeforeSave()); + } + + public async getOutputDocs(rootDoc: Record) { + const formData = new EnketoDocData(this.form.getDataStr({ irrelevant: false })); + const docElementsById: Record = { + ...formData.getDbDocElementsById(), + [rootDoc._id]: formData.rootElement + }; + const idsByDocElements = new Map( + Object + .entries(docElementsById) + .map(([id, element]) => [element, id]) + ); + + formData.dbDocRefElements.forEach(this.populateDbDocRefElementText(formData, idsByDocElements)); + + // TODO Since we are making docData out of these anyway, maybe we use that to hold id, etc and help with mapping. + // TODO Would it add complication though to NOT have node identity? (If we deserialize each DocData into a different + // xml tree + Object + .entries(docElementsById) + .map(([id, docElement]) => { + const doc = new EnketoDocData(docElement.outerHTML).getDocObject(this.config); + }) + } + + private populateDbDocRefElementText(formData: EnketoDocData, idsByDocElements: Map) { + return (element: Element) => { + const $element = $(element); + const reference = $element.attr('db-doc-ref'); + const referencedNode = formData.getNodeByXpath(element, reference); + const refId = referencedNode && idsByDocElements.get(referencedNode); + if (!refId) { + return; + } + + $element.text(refId); + }; + } + +} + +export class FormConfig { + private readonly $xml: JQuery; + + constructor( + public readonly doc: Record, + html: string, + xml: string, + model: string + ){ + this.$xml = $(xml); + } + + // TODO should a cache/pre-load this? + public getRepeatPaths() { + return this.$xml + .find('repeat[nodeset]') + .map((_, element) => $(element).attr('nodeset')) + .get(); + } + // TODO Figure out how this is used since the imple is not super gernal + // - Only used as part of getClosestPath which is only used to set db-doc-ref + // - What we are actually trying to do is fine the id value to use for the db-doc-ref... + // public getRelativePath = (rawPath: string) => { + // const path = rawPath.trim(); + // const repeatReference = this + // .getRepeatPaths() + // .find(repeatPath => path === repeatPath || path.startsWith(`${repeatPath}/`)); + // if (repeatReference) { + // if (repeatReference === path) { + // // when the path is the repeat element itself, return the repeat element node name + // return path.split('/').slice(-1)[0]; + // } + // + // return path.replace(`${repeatReference}/`, ''); + // } + // + // if (path.startsWith('./')) { + // return path.replace('./', ''); + // } + // }; +} + +class EnketoDocData { + // TODO Can hold its own id and maybe its own root path (useful for remembering + // where it was at and calculating against repeats) + + private readonly dataXml: XMLDocument; + private readonly $rootElement: JQuery; + public readonly rootElement: Element; + private readonly dbDocElements: Element[]; + public readonly dbDocRefElements: Element[]; + + constructor(data: string) { + this.dataXml = $.parseXML(data); // TODO Do we need this as a XMLDocument? + this.$rootElement = $($(this.dataXml).children()[0]); + this.rootElement = this.$rootElement[0]; + this.dbDocElements = this.$rootElement + .find('[db-doc=true]') + .get(); + this.dbDocRefElements = this.$rootElement + .find('[db-doc-ref]') + .get(); + } + + public getDbDocElementsById() { + const getDbDocId = (e: Element) => $(e).children('_id').text() || uuid(); + // TODO Don't cache this uness we use it multiple times + return this.dbDocElements.reduce((acc, element) => acc[getDbDocId(element)] = element, {}); + } + + public getNodeByXpath(contextNode: Node, rawXpath?: string): Node | null { + const xpath = rawXpath?.trim(); + if (!xpath) { + return null; + } + const xpathSegments = xpath + .trim() + .split('/') + .filter(Boolean); + const contextLineage = this.getNodeWithLineage(contextNode); + + // Number of leading segments the target path shares with the context node's lineage. + const firstDivergence = xpathSegments + .findIndex((segment, i) => segment !== contextLineage[i]?.nodeName); + const commonAncestorIndex = firstDivergence === -1 ? xpathSegments.length : firstDivergence; + + // Fall back to contextNode in case of relative xpath + const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; + const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; + const result = this.dataXml.evaluate( + relativePath, + anchor, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); + return result.singleNodeValue; + } + + public getDocObject(formConfig: FormConfig) { + // TODO not repeating the root path logic from `reportRecordToJs`. Not sure what that is doing... + return this.nodesToJs([this.rootElement], formConfig.getRepeatPaths()); + } + + private isElementNode(node: unknown): node is Element { + return node?.['nodeType'] === Node.ELEMENT_NODE; + } + + // TODO Any methods not using actual state could maybe be moved to helper. + private nodesToJs(nodes: Element[], repeatPaths: string[], path = '') { + return nodes + .reduce>((result, node) => { + const nodePath = `${path}/${node.nodeName}`; + const value = this.getJsValueForNode(node, repeatPaths, nodePath); + if (repeatPaths.includes(nodePath)) { + (result[node.nodeName] ??= []).push(value); + } else { + result[node.nodeName] = value; + } + return result; + }, {}); + } + + private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) { + const elements = Array + .from(node.childNodes) + .filter(this.isElementNode); + if (elements.length) { + return this.nodesToJs(elements, repeatPaths, nodePath); + } + // binary values are attached to the doc instead of inlined + return node.getAttribute('type') === 'binary' ? '' : node.textContent; + } + + private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { + if (!this.isElementNode(contextNode)) { + return lineage; + } + lineage.unshift(contextNode); + return this.getNodeWithLineage(contextNode.parentNode, lineage); + } +} + + From 426cc443cba813fff2314f234db42b541e92b474 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Sun, 12 Jul 2026 12:51:26 -0500 Subject: [PATCH 02/41] try to work in contextElement --- webapp/src/ts/services/NewEnketoService.ts | 73 ++++++++++++---------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index ec750cfddbd..e15e5a49fec 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -62,43 +62,41 @@ export class EnketoForm { } public async getOutputDocs(rootDoc: Record) { - const formData = new EnketoDocData(this.form.getDataStr({ irrelevant: false })); - const docElementsById: Record = { - ...formData.getDbDocElementsById(), - [rootDoc._id]: formData.rootElement - }; - const idsByDocElements = new Map( - Object - .entries(docElementsById) - .map(([id, element]) => [element, id]) - ); + const docData = new EnketoDocData(this.form.getDataStr({ irrelevant: false }), rootDoc._id); + const allDocs = [ + docData, + ...docData.getDbDocs() + ]; - formData.dbDocRefElements.forEach(this.populateDbDocRefElementText(formData, idsByDocElements)); + this.populateDbDocRefElements(allDocs); // TODO Since we are making docData out of these anyway, maybe we use that to hold id, etc and help with mapping. // TODO Would it add complication though to NOT have node identity? (If we deserialize each DocData into a different // xml tree - Object - .entries(docElementsById) - .map(([id, docElement]) => { - const doc = new EnketoDocData(docElement.outerHTML).getDocObject(this.config); - }) + } - private populateDbDocRefElementText(formData: EnketoDocData, idsByDocElements: Map) { - return (element: Element) => { + private populateDbDocRefElements(allDocs: EnketoDocData[]) { + const [rootDoc] = allDocs; + rootDoc.dbDocRefElements.forEach(element => { const $element = $(element); const reference = $element.attr('db-doc-ref'); - const referencedNode = formData.getNodeByXpath(element, reference); - const refId = referencedNode && idsByDocElements.get(referencedNode); + const referencedNode = rootDoc.getNodeByXpath(element, reference); + if (!referencedNode) { + return; + } + const refId = allDocs + .find(({ contextElement }) => contextElement === referencedNode) + ?.id; if (!refId) { return; } + // TODO if this is on a DB doc, it will not be populated.... + // TODO Can we run this for _each_ of allDocs? $element.text(refId); - }; + }); } - } export class FormConfig { @@ -149,26 +147,37 @@ class EnketoDocData { private readonly dataXml: XMLDocument; private readonly $rootElement: JQuery; + public readonly contextElement: Element; public readonly rootElement: Element; - private readonly dbDocElements: Element[]; - public readonly dbDocRefElements: Element[]; - constructor(data: string) { + constructor( + data: string, + public readonly id: string, + contextElement?: Element + ) { this.dataXml = $.parseXML(data); // TODO Do we need this as a XMLDocument? this.$rootElement = $($(this.dataXml).children()[0]); this.rootElement = this.$rootElement[0]; - this.dbDocElements = this.$rootElement - .find('[db-doc=true]') - .get(); - this.dbDocRefElements = this.$rootElement + this.contextElement = contextElement || this.rootElement; + } + + public getDbDocRefElements() { + this.$rootElement .find('[db-doc-ref]') + .filter((_, el) => !$(el).parents('[db-doc=true]').not(this.rootElement).length) .get(); } - public getDbDocElementsById() { + public getDbDocs() { const getDbDocId = (e: Element) => $(e).children('_id').text() || uuid(); - // TODO Don't cache this uness we use it multiple times - return this.dbDocElements.reduce((acc, element) => acc[getDbDocId(element)] = element, {}); + const dbDocElements = this.$rootElement + .find('[db-doc=true]') + .get(); + return dbDocElements.map(dbDoc => new EnketoDocData( + dbDoc.outerHTML, + getDbDocId(dbDoc), + dbDoc + )); } public getNodeByXpath(contextNode: Node, rawXpath?: string): Node | null { From df13baf81ef1cb4ca3fcf3f33ae40899918084b6 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Sun, 12 Jul 2026 16:35:37 -0500 Subject: [PATCH 03/41] Initial complete --- webapp/src/ts/services/NewEnketoService.ts | 315 ++++++++++++--------- 1 file changed, 179 insertions(+), 136 deletions(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index e15e5a49fec..94dd9263afc 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -4,6 +4,7 @@ import events from 'enketo-core/src/js/event'; import { DOC_TYPES } from '@medic/constants'; import { v7 as uuid } from 'uuid'; import { Xpath } from '@mm-providers/xpath-element-path.provider'; +import * as FileManager from '../../js/enketo/file-manager'; @Injectable({ providedIn: 'root' @@ -13,17 +14,58 @@ export class NewEnketoService { private readonly ngZone: NgZone ) { } + public async validate(form: Record) { + const valid = await form.validate(); + if (!valid) { + throw new Error('Form is invalid'); + } + form.view.html.dispatchEvent(events.BeforeSave()); + } + + public getEnketoDoc(form: Record, docId: string) { + const formString = form.getDataStr({ irrelevant: false }); + const formDoc = new DOMParser().parseFromString(formString, 'text/xml'); + return new EnketoRootDoc(formDoc, docId); + } + async saveContact() { } // TODO Make sure extractLineageService.extract is called on given contact. - async saveReport(form: EnketoForm, defaultData: Record = {}) { + async saveReport(config: FormConfig, form: Record, defaultData: Record = {}) { return this.ngZone.runOutsideAngular(async () => { - await form.validate(); - const reportDoc = this.getReportDoc(form.config.internalId, defaultData); - - + await this.validate(form); + const reportDoc: Record = this.getReportDoc(config.doc.internalId, defaultData); + const formDocData = this.getEnketoDoc(form, reportDoc._id); + + const subDocs = formDocData.getDbDocs(); + + this.populateDbDocRefElements(formDocData, [formDocData, ...subDocs]); + const binaryAttachments = this.populateBinaryAttachmentElements(formDocData, config.doc.internalId); + const fileAttachments = FileManager + .getCurrentFiles() + .reduce((acc, file) => acc[`user-file-${file.name}`] = { + content_type: file.type, + data: new Blob([ file ], { type: file.type }) + }, {}); + + const rootOutputDoc = { + ...reportDoc, + form_version: config.doc.xmlVersion, + hidden_fields: [ + ...config.getHiddenFields(), + ...subDocs.map(({ rootElement }) => rootElement.tagName) + ], + fields: formDocData.getCouchDoc(config), + _attachments: { + ...reportDoc._attachments, + ...fileAttachments, + ...binaryAttachments + } + }; + const dbDocObjects = subDocs.map(docData => docData.getCouchDoc(config)); + return [rootOutputDoc, ...dbDocObjects]; }); } @@ -41,43 +83,8 @@ export class NewEnketoService { ...defaultData }; } -} - - - -// TODO Should return direct from xml-forms.service... - -export class EnketoForm { - constructor( - private readonly form: Record, - public readonly config: FormConfig - ) { } - - public async validate() { - const valid = await this.form.validate(); - if (!valid) { - throw new Error('Form is invalid'); - } - this.form.view.html.dispatchEvent(events.BeforeSave()); - } - public async getOutputDocs(rootDoc: Record) { - const docData = new EnketoDocData(this.form.getDataStr({ irrelevant: false }), rootDoc._id); - const allDocs = [ - docData, - ...docData.getDbDocs() - ]; - - this.populateDbDocRefElements(allDocs); - - // TODO Since we are making docData out of these anyway, maybe we use that to hold id, etc and help with mapping. - // TODO Would it add complication though to NOT have node identity? (If we deserialize each DocData into a different - // xml tree - - } - - private populateDbDocRefElements(allDocs: EnketoDocData[]) { - const [rootDoc] = allDocs; + private populateDbDocRefElements(rootDoc: EnketoRootDoc, allDocs: EnketoDoc[]) { rootDoc.dbDocRefElements.forEach(element => { const $element = $(element); const reference = $element.attr('db-doc-ref'); @@ -85,22 +92,32 @@ export class EnketoForm { if (!referencedNode) { return; } - const refId = allDocs - .find(({ contextElement }) => contextElement === referencedNode) - ?.id; - if (!refId) { - return; + const refDoc = allDocs.find(({ rootElement }) => rootElement === referencedNode); + if (refDoc) { + $element.text(refDoc.id); } - - // TODO if this is on a DB doc, it will not be populated.... - // TODO Can we run this for _each_ of allDocs? - $element.text(refId); }); } + + private populateBinaryAttachmentElements(rootDocData: EnketoRootDoc, form: string) { + return rootDocData.binaryTypeElements + .filter(({ textContent }) => textContent) + .map(element => { + const xpath = Xpath.getElementTreeXPath(element); + const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`); + const filename = `user-file${formXpath}`; + const data = element.textContent; + element.textContent = ''; + return { filename, data }; + }) + .reduce((acc, { filename, data }) => acc[filename] = { data, content_type: 'image/png' }, {}); + } } +// TODO Should return direct from xml-forms.service... export class FormConfig { - private readonly $xml: JQuery; + private readonly modelDoc: XMLDocument; + public readonly repeatPaths: string[]; constructor( public readonly doc: Record, @@ -108,75 +125,127 @@ export class FormConfig { xml: string, model: string ){ - this.$xml = $(xml); + const domParser = new DOMParser(); + this.modelDoc = domParser.parseFromString(model, 'text/xml'); + const xmlDoc = domParser.parseFromString(xml, 'text/xml'); + this.repeatPaths = Array + .from(xmlDoc.querySelectorAll('repeat[nodeset]')) + .map(el => el.getAttribute('nodeset')!) + .filter(Boolean); } - // TODO should a cache/pre-load this? - public getRepeatPaths() { - return this.$xml - .find('repeat[nodeset]') - .map((_, element) => $(element).attr('nodeset')) - .get(); + public getHiddenFields(modelNode = this.modelDoc.firstChild, prefix = '', current = new Set()) { + if (!modelNode) { + return current; + } + this + .getChildElements(modelNode) + .forEach(node => { + const path = `${prefix}${node.nodeName}`; + const attr = node.attributes.getNamedItem('tag'); + if (attr?.value?.toLowerCase() === 'hidden') { + current.add(path); + } else { + this.getHiddenFields(node, `${path}.`, current); + } + }); + return current; } - // TODO Figure out how this is used since the imple is not super gernal - // - Only used as part of getClosestPath which is only used to set db-doc-ref - // - What we are actually trying to do is fine the id value to use for the db-doc-ref... - // public getRelativePath = (rawPath: string) => { - // const path = rawPath.trim(); - // const repeatReference = this - // .getRepeatPaths() - // .find(repeatPath => path === repeatPath || path.startsWith(`${repeatPath}/`)); - // if (repeatReference) { - // if (repeatReference === path) { - // // when the path is the repeat element itself, return the repeat element node name - // return path.split('/').slice(-1)[0]; - // } - // - // return path.replace(`${repeatReference}/`, ''); - // } - // - // if (path.startsWith('./')) { - // return path.replace('./', ''); - // } - // }; -} -class EnketoDocData { - // TODO Can hold its own id and maybe its own root path (useful for remembering - // where it was at and calculating against repeats) + // TODO duplicated + private isElementNode(node: unknown): node is Element { + return node?.['nodeType'] === Node.ELEMENT_NODE; + } - private readonly dataXml: XMLDocument; - private readonly $rootElement: JQuery; - public readonly contextElement: Element; - public readonly rootElement: Element; + private getChildElements(node: Node) { + return Array + .from(node.childNodes) + .filter(this.isElementNode); + } +} + +class EnketoDoc { + protected readonly $rootElement: JQuery; constructor( - data: string, + public readonly rootElement: Element, public readonly id: string, - contextElement?: Element ) { - this.dataXml = $.parseXML(data); // TODO Do we need this as a XMLDocument? - this.$rootElement = $($(this.dataXml).children()[0]); - this.rootElement = this.$rootElement[0]; - this.contextElement = contextElement || this.rootElement; + this.$rootElement = $(rootElement); + } + + public getCouchDoc(formConfig: FormConfig): Record { + const path = Xpath.getElementTreeXPath(this.rootElement); + return { + ...this.nodesToJs(this.getChildElements(this.rootElement), formConfig.repeatPaths, path), + _id: this.id, + reported_data: Date.now() + }; + } + + protected isElementNode(node: unknown): node is Element { + return node?.['nodeType'] === Node.ELEMENT_NODE; + } + + protected getChildElements(node: Node) { + return Array + .from(node.childNodes) + .filter(this.isElementNode); + } + + // TODO Any methods not using actual state could maybe be moved to helper. + protected nodesToJs(nodes: Element[], repeatPaths: string[], path: string) { + return nodes + .reduce>((result, node) => { + const nodePath = `${path}/${node.nodeName}`; + const value = this.getJsValueForNode(node, repeatPaths, nodePath); + if (repeatPaths.includes(nodePath)) { + (result[node.nodeName] ??= []).push(value); + } else { + result[node.nodeName] = value; + } + return result; + }, {}); + } + + private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) { + const elements = Array + .from(node.childNodes) + .filter(this.isElementNode); + if (elements.length) { + return this.nodesToJs(elements, repeatPaths, nodePath); + } + return node.textContent; } +} - public getDbDocRefElements() { - this.$rootElement +// TODO Consider making this a ReportDoc and nesting all the dbDoc data stuff inside. +class EnketoRootDoc extends EnketoDoc { + private readonly dbDocElements: Element[]; + public readonly dbDocRefElements: Element[]; + public readonly binaryTypeElements: Element[]; + + constructor( + private readonly xmlDoc: XMLDocument, + id: string, + ) { + super(xmlDoc.documentElement, id); + this. dbDocElements = this.$rootElement + .find('[db-doc=true]') + .get(); + this.dbDocRefElements = this.$rootElement .find('[db-doc-ref]') - .filter((_, el) => !$(el).parents('[db-doc=true]').not(this.rootElement).length) + .get(); + this.binaryTypeElements = this.$rootElement + .find('[type=binary]') .get(); } public getDbDocs() { const getDbDocId = (e: Element) => $(e).children('_id').text() || uuid(); - const dbDocElements = this.$rootElement - .find('[db-doc=true]') - .get(); - return dbDocElements.map(dbDoc => new EnketoDocData( - dbDoc.outerHTML, + return this.dbDocElements.map(dbDoc => new EnketoDoc( + dbDoc, getDbDocId(dbDoc), - dbDoc )); } @@ -199,7 +268,7 @@ class EnketoDocData { // Fall back to contextNode in case of relative xpath const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; - const result = this.dataXml.evaluate( + const result = this.xmlDoc.evaluate( relativePath, anchor, null, @@ -209,39 +278,13 @@ class EnketoDocData { return result.singleNodeValue; } - public getDocObject(formConfig: FormConfig) { - // TODO not repeating the root path logic from `reportRecordToJs`. Not sure what that is doing... - return this.nodesToJs([this.rootElement], formConfig.getRepeatPaths()); - } - - private isElementNode(node: unknown): node is Element { - return node?.['nodeType'] === Node.ELEMENT_NODE; - } - - // TODO Any methods not using actual state could maybe be moved to helper. - private nodesToJs(nodes: Element[], repeatPaths: string[], path = '') { - return nodes - .reduce>((result, node) => { - const nodePath = `${path}/${node.nodeName}`; - const value = this.getJsValueForNode(node, repeatPaths, nodePath); - if (repeatPaths.includes(nodePath)) { - (result[node.nodeName] ??= []).push(value); - } else { - result[node.nodeName] = value; - } - return result; - }, {}); - } - - private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) { - const elements = Array - .from(node.childNodes) - .filter(this.isElementNode); - if (elements.length) { - return this.nodesToJs(elements, repeatPaths, nodePath); - } - // binary values are attached to the doc instead of inlined - return node.getAttribute('type') === 'binary' ? '' : node.textContent; + public getCouchDoc(formConfig: FormConfig): Record { + // TODO not exactly sure we should do the path thing here, but it matches OG. + return this.nodesToJs( + this.getChildElements(this.rootElement), + formConfig.repeatPaths, + `/${this.rootElement.nodeName}` + ); } private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { From 2cefd19ed498fa957ceace51a78747363133f4cd Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 13 Jul 2026 13:53:07 -0500 Subject: [PATCH 04/41] Creating new reports working --- .../providers/xpath-element-path.provider.ts | 5 + webapp/src/ts/services/NewEnketoService.ts | 182 ++++++++---------- webapp/src/ts/services/form.service.ts | 13 +- 3 files changed, 94 insertions(+), 106 deletions(-) diff --git a/webapp/src/ts/providers/xpath-element-path.provider.ts b/webapp/src/ts/providers/xpath-element-path.provider.ts index 0562c016bd8..706e4731c1e 100644 --- a/webapp/src/ts/providers/xpath-element-path.provider.ts +++ b/webapp/src/ts/providers/xpath-element-path.provider.ts @@ -57,3 +57,8 @@ Xpath.getElementTreeXPath = function(element) return paths.length ? "/" + paths.join("/") : null; }; + +Xpath.getElementRawXPath = function(element){ + const path = Xpath.getElementTreeXPath(element); + return path?.replace(/\[\d+\]/g, ''); +}; diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index 94dd9263afc..1ade650b09f 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -1,5 +1,4 @@ import { Injectable, NgZone } from '@angular/core'; -import type JQuery from 'jquery'; import events from 'enketo-core/src/js/event'; import { DOC_TYPES } from '@medic/constants'; import { v7 as uuid } from 'uuid'; @@ -14,26 +13,11 @@ export class NewEnketoService { private readonly ngZone: NgZone ) { } - public async validate(form: Record) { - const valid = await form.validate(); - if (!valid) { - throw new Error('Form is invalid'); - } - form.view.html.dispatchEvent(events.BeforeSave()); - } - - public getEnketoDoc(form: Record, docId: string) { - const formString = form.getDataStr({ irrelevant: false }); - const formDoc = new DOMParser().parseFromString(formString, 'text/xml'); - return new EnketoRootDoc(formDoc, docId); - } - - async saveContact() { - - } - - // TODO Make sure extractLineageService.extract is called on given contact. - async saveReport(config: FormConfig, form: Record, defaultData: Record = {}) { + async saveReport( + config: FormConfig, + form: Record, + defaultData: Record = {} + ): Promise> { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); const reportDoc: Record = this.getReportDoc(config.doc.internalId, defaultData); @@ -45,56 +29,88 @@ export class NewEnketoService { const binaryAttachments = this.populateBinaryAttachmentElements(formDocData, config.doc.internalId); const fileAttachments = FileManager .getCurrentFiles() - .reduce((acc, file) => acc[`user-file-${file.name}`] = { - content_type: file.type, - data: new Blob([ file ], { type: file.type }) - }, {}); + .reduce((acc, file) => ({ + ...acc, + [`user-file-${file.name}`]: { content_type: file.type, data: new Blob([ file ], { type: file.type }) } + }), {}); const rootOutputDoc = { ...reportDoc, form_version: config.doc.xmlVersion, - hidden_fields: [ - ...config.getHiddenFields(), - ...subDocs.map(({ rootElement }) => rootElement.tagName) - ], - fields: formDocData.getCouchDoc(config), + hidden_fields: this.getHiddenFields([ + ...formDocData.hiddenElements, + ...subDocs.map(({ rootElement }) => rootElement) + ]), + fields: formDocData.deserialize(config), _attachments: { ...reportDoc._attachments, ...fileAttachments, ...binaryAttachments } }; - const dbDocObjects = subDocs.map(docData => docData.getCouchDoc(config)); + + const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserialize(config), docData.id)); return [rootOutputDoc, ...dbDocObjects]; }); } - private getReportDoc(form: string, defaultData: Record) { - if (defaultData._id) { - return { ...defaultData }; + private async validate(form: Record) { + const valid = await form.validate(); + if (!valid) { + throw new Error('Form is invalid'); } + form.view.html.dispatchEvent(events.BeforeSave()); + } + + private getEnketoDoc(form: Record, docId: string) { + const formString = form.getDataStr({ irrelevant: false }); + const formDoc = new DOMParser().parseFromString(formString, 'text/xml'); + return new EnketoRootDoc(formDoc, docId); + } + + private getHiddenFields(elements: Element[]) { + const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementRawXPath(element))); + const hasHiddenAncestor = ( + segments: string[] + ) => (_: string, i: number) => i > 0 && hiddenXpaths.has(segments.slice(0, i).join('/')); + return [...hiddenXpaths] + .map(xpath => xpath.split('/')) + .filter(segments => !segments.some(hasHiddenAncestor(segments))) + .map(segments => segments + .filter(Boolean) + .slice(1) + .join('.')); + } + + private initializeDoc(defaultData: Record, _id = uuid()) { + return { + _id, + reported_date: Date.now(), + ...defaultData + }; + } + + private getReportDoc(form: string, defaultData: Record) { return { - _id: uuid(), form, type: DOC_TYPES.DATA_RECORD, content_type: 'xml', - reported_date: Date.now(), from: defaultData.contact?.phone, + ...this.initializeDoc(defaultData, defaultData._id), ...defaultData }; } private populateDbDocRefElements(rootDoc: EnketoRootDoc, allDocs: EnketoDoc[]) { rootDoc.dbDocRefElements.forEach(element => { - const $element = $(element); - const reference = $element.attr('db-doc-ref'); + const reference = element.getAttribute('db-doc-ref'); const referencedNode = rootDoc.getNodeByXpath(element, reference); if (!referencedNode) { return; } const refDoc = allDocs.find(({ rootElement }) => rootElement === referencedNode); if (refDoc) { - $element.text(refDoc.id); + element.textContent = refDoc.id; } }); } @@ -110,77 +126,42 @@ export class NewEnketoService { element.textContent = ''; return { filename, data }; }) - .reduce((acc, { filename, data }) => acc[filename] = { data, content_type: 'image/png' }, {}); + .reduce((acc, { filename, data }) => ({ + ...acc, + [filename]: { data, content_type: 'image/png' } + }), {}); } } // TODO Should return direct from xml-forms.service... export class FormConfig { - private readonly modelDoc: XMLDocument; public readonly repeatPaths: string[]; constructor( public readonly doc: Record, - html: string, xml: string, - model: string ){ - const domParser = new DOMParser(); - this.modelDoc = domParser.parseFromString(model, 'text/xml'); - const xmlDoc = domParser.parseFromString(xml, 'text/xml'); + const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); this.repeatPaths = Array .from(xmlDoc.querySelectorAll('repeat[nodeset]')) .map(el => el.getAttribute('nodeset')!) .filter(Boolean); } - - public getHiddenFields(modelNode = this.modelDoc.firstChild, prefix = '', current = new Set()) { - if (!modelNode) { - return current; - } - this - .getChildElements(modelNode) - .forEach(node => { - const path = `${prefix}${node.nodeName}`; - const attr = node.attributes.getNamedItem('tag'); - if (attr?.value?.toLowerCase() === 'hidden') { - current.add(path); - } else { - this.getHiddenFields(node, `${path}.`, current); - } - }); - return current; - } - - // TODO duplicated - private isElementNode(node: unknown): node is Element { - return node?.['nodeType'] === Node.ELEMENT_NODE; - } - - private getChildElements(node: Node) { - return Array - .from(node.childNodes) - .filter(this.isElementNode); - } } class EnketoDoc { - protected readonly $rootElement: JQuery; - constructor( public readonly rootElement: Element, public readonly id: string, ) { - this.$rootElement = $(rootElement); } - public getCouchDoc(formConfig: FormConfig): Record { - const path = Xpath.getElementTreeXPath(this.rootElement); - return { - ...this.nodesToJs(this.getChildElements(this.rootElement), formConfig.repeatPaths, path), - _id: this.id, - reported_data: Date.now() - }; + public deserialize(formConfig: FormConfig): Record { + return this.nodesToJs( + this.getChildElements(this.rootElement), + formConfig.repeatPaths, + Xpath.getElementRawXPath(this.rootElement) + ); } protected isElementNode(node: unknown): node is Element { @@ -222,6 +203,7 @@ class EnketoDoc { // TODO Consider making this a ReportDoc and nesting all the dbDoc data stuff inside. class EnketoRootDoc extends EnketoDoc { private readonly dbDocElements: Element[]; + public readonly hiddenElements: Element[]; public readonly dbDocRefElements: Element[]; public readonly binaryTypeElements: Element[]; @@ -230,26 +212,20 @@ class EnketoRootDoc extends EnketoDoc { id: string, ) { super(xmlDoc.documentElement, id); - this. dbDocElements = this.$rootElement - .find('[db-doc=true]') - .get(); - this.dbDocRefElements = this.$rootElement - .find('[db-doc-ref]') - .get(); - this.binaryTypeElements = this.$rootElement - .find('[type=binary]') - .get(); + this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]')); + this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]')); + this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]')); + this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); } public getDbDocs() { - const getDbDocId = (e: Element) => $(e).children('_id').text() || uuid(); return this.dbDocElements.map(dbDoc => new EnketoDoc( dbDoc, - getDbDocId(dbDoc), + this.getDocId(dbDoc), )); } - public getNodeByXpath(contextNode: Node, rawXpath?: string): Node | null { + public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { const xpath = rawXpath?.trim(); if (!xpath) { return null; @@ -278,13 +254,11 @@ class EnketoRootDoc extends EnketoDoc { return result.singleNodeValue; } - public getCouchDoc(formConfig: FormConfig): Record { - // TODO not exactly sure we should do the path thing here, but it matches OG. - return this.nodesToJs( - this.getChildElements(this.rootElement), - formConfig.repeatPaths, - `/${this.rootElement.nodeName}` - ); + private getDocId(element: Element) { + return Array + .from(element.children) + .find(child => child.tagName === '_id') + ?.textContent || uuid(); } private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index f07f0040fd4..b7c41ffb11a 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -31,6 +31,8 @@ import { Nullable, Person, Contact } from '@medic/cht-datasource'; import { DeduplicateService, DuplicateCheck } from '@mm-services/deduplicate.service'; import { ContactsService } from '@mm-services/contacts.service'; import { PerformanceService } from '@mm-services/performance.service'; +import { FormConfig, NewEnketoService } from '@mm-services/NewEnketoService'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; /** * Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the @@ -61,6 +63,8 @@ export class FormService { private ngZone: NgZone, private chtDatasourceService: CHTDatasourceService, private enketoService: EnketoService, + private newEnketoService: NewEnketoService, + private extractLineageService: ExtractLineageService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, private readonly deduplicateService: DeduplicateService, @@ -314,8 +318,13 @@ export class FormService { } const isTrainingCardForm = this.trainingCardsService.isTrainingCardForm(formInternalId); - const contact = await this.getUserContact(!isTrainingCardForm); - const docs = await this.enketoService.completeNewReport(formInternalId, form, formDoc, contact); + const contact = this.extractLineageService.extract(await this.getUserContact(!isTrainingCardForm)); + + const formConfig = new FormConfig(formDoc.doc, formDoc.xml); + const docs = await this.newEnketoService.saveReport(formConfig, form, { contact }); + console.log('hello'); + + // const docs = await this.enketoService.completeNewReport(formInternalId, form, formDoc, contact); if (!docId && isTrainingCardForm) { docs[0]._id = this.trainingCardsService.getTrainingCardDocId(); } From 04dc95648538b7c6a627645f7f202fab36da93a3 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 13 Jul 2026 14:08:54 -0500 Subject: [PATCH 05/41] Edit reports seems to also work --- webapp/src/ts/services/form.service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index b7c41ffb11a..f3fad166d77 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -27,7 +27,7 @@ import { reduce as _reduce } from 'lodash-es'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; -import { Nullable, Person, Contact } from '@medic/cht-datasource'; +import { Nullable, Person, Contact, Report, Qualifier } from '@medic/cht-datasource'; import { DeduplicateService, DuplicateCheck } from '@mm-services/deduplicate.service'; import { ContactsService } from '@mm-services/contacts.service'; import { PerformanceService } from '@mm-services/performance.service'; @@ -75,8 +75,10 @@ export class FormService { this.inited = this.init(); this.globalActions = new GlobalActions(store); this.servicesActions = new ServicesActions(this.store); + this.getReport = chtDatasourceService.bind(Report.v1.get); } + private readonly getReport: ReturnType; private globalActions: GlobalActions; private servicesActions: ServicesActions; @@ -313,17 +315,17 @@ export class FormService { private async completeReport(formInternalId, form, docId?) { const formDoc = await this.ngZone .runOutsideAngular(() => this.xmlFormsService.getDocAndFormAttachment(formInternalId)); + const formConfig = new FormConfig(formDoc.doc, formDoc.xml); if (docId) { - return this.enketoService.completeExistingReport(form, formDoc, docId); + const doc = await this.getReport(Qualifier.byUuid(docId)); + return this.newEnketoService.saveReport(formConfig, form, doc!); + // return this.enketoService.completeExistingReport(form, formDoc, docId); } const isTrainingCardForm = this.trainingCardsService.isTrainingCardForm(formInternalId); const contact = this.extractLineageService.extract(await this.getUserContact(!isTrainingCardForm)); - const formConfig = new FormConfig(formDoc.doc, formDoc.xml); const docs = await this.newEnketoService.saveReport(formConfig, form, { contact }); - console.log('hello'); - // const docs = await this.enketoService.completeNewReport(formInternalId, form, formDoc, contact); if (!docId && isTrainingCardForm) { docs[0]._id = this.trainingCardsService.getTrainingCardDocId(); From 8aac455b78ba15913a0d894183bcb2b287d59a74 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Wed, 15 Jul 2026 20:20:18 -0500 Subject: [PATCH 06/41] Add support for contacts --- .../contacts/contacts-edit.component.ts | 15 +- webapp/src/ts/services/NewEnketoService.ts | 248 +++++++++++++++--- webapp/src/ts/services/form.service.ts | 17 +- 3 files changed, 226 insertions(+), 54 deletions(-) diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 13105654cd8..334c380b78d 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -18,11 +18,11 @@ import { MatAccordion } from '@angular/material/expansion'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; import { DuplicateContactsComponent } from '@mm-components/duplicate-contacts/duplicate-contacts.component'; -import { DuplicateCheck } from '@mm-services/deduplicate.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { TelemetryService } from '@mm-services/telemetry.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import events from 'enketo-core/src/js/event'; +import { FormConfig } from '@mm-services/NewEnketoService'; @Component({ templateUrl: './contacts-edit.component.html', @@ -51,7 +51,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { subscription = new Subscription(); translationsLoadedSubscription; private globalActions; - private xmlVersion; + private formConfig?: FormConfig; private readonly getContactFromDatasource: ReturnType; enketoStatus; @@ -72,7 +72,6 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private trackSave; private trackMetadata = { action: '', form: '' }; - private duplicateCheck?: DuplicateCheck; duplicatesAcknowledged = false; duplicates: Contact.v1.Contact[] = []; @@ -324,8 +323,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private async renderForm(formId: string, titleKey: string) { const formDoc = await this.dbService.get().get(formId); - this.xmlVersion = formDoc.xmlVersion; - this.duplicateCheck = formDoc.duplicate_check; + this.formConfig = new FormConfig(formDoc, ''); this.globalActions.setEnketoEditedStatus(false); @@ -435,7 +433,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.globalActions.setEnketoError(null); return Promise - .resolve(form.validate()) + .resolve(form.validate())// TODO this is moving down. Make sure that is okay .then((valid) => { if (!valid) { throw new Error('Validation failed.'); @@ -452,8 +450,9 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { return this.formService .saveContact( - { docId, type: this.enketoContact.type }, - { form, xmlVersion: this.xmlVersion, duplicateCheck: this.duplicateCheck}, + { docId, type: this.enketoContact.type }, + form, + this.formConfig!, this.duplicatesAcknowledged ) .then((result) => { diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index 1ade650b09f..afa7fbdf98e 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -4,39 +4,95 @@ import { DOC_TYPES } from '@medic/constants'; import { v7 as uuid } from 'uuid'; import { Xpath } from '@mm-providers/xpath-element-path.provider'; import * as FileManager from '../../js/enketo/file-manager'; +import { ExtractLineageService } from '@mm-services/extract-lineage.service'; +import { Contact, Qualifier } from '@medic/cht-datasource'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' }) export class NewEnketoService { + private readonly getContactFromDatasource: ReturnType; + constructor( - private readonly ngZone: NgZone - ) { } + private readonly extractLineageService:ExtractLineageService, + private readonly ngZone: NgZone, + chtDatasourceService: CHTDatasourceService, + ) { + this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); + } - async saveReport( + // TODO re-fetch contact on update to ensure latest data + public async saveContact( config: FormConfig, form: Record, - defaultData: Record = {} - ): Promise> { + defaultData: Record + ) { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); - const reportDoc: Record = this.getReportDoc(config.doc.internalId, defaultData); - const formDocData = this.getEnketoDoc(form, reportDoc._id); + const contactDoc = this.initializeDoc(defaultData); + const formDocData = new EnektoContactRootDoc( + this.getFormDataXml(form), + contactDoc._id, + contactDoc.contact_type || contactDoc.type + ); + + const formAttachments = this.processFormAttachments(formDocData, config.doc.internalId); + + const rootOutputDoc: Record = { + ...contactDoc, + ...formDocData.deserializeDoc(config), + type: contactDoc.type, + contact_type: contactDoc.contact_type, + _attachments: { + ...contactDoc._attachments, + ...formAttachments, + } + }; + + const siblings = EnektoContactRootDoc.SIBLING_FIELD_NAMES + .map(fieldName => ({ fieldName, doc: formDocData.getSiblingDoc(fieldName)?.deserializeDoc(config) })) + .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); + await Promise.all(siblings.map(async ({ fieldName, doc }) => { + rootOutputDoc[fieldName] = await this.getContactSiblingValue( + doc, rootOutputDoc[fieldName], defaultData[fieldName] + ); + })); + const outputSiblings = siblings + .filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName]._id === 'NEW') + .map(({ doc }) => doc) + .filter(doc => !!doc); + const childDocs = formDocData + .getChildDocs() + .map(doc => this.initializeDoc(doc.deserializeDoc(config))) + .map(doc => ({ ...doc, parent: { _id: rootOutputDoc._id }})); + + return { + docId: rootOutputDoc._id, + preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs] + .map(doc => this.dehydrateContactLineage(doc)) + }; + }); + } + + public async saveReport( + config: FormConfig, + form: Record, + defaultData: Record + ): Promise> { + return this.ngZone.runOutsideAngular(async () => { + await this.validate(form); + const { internalId, xmlVersion } = config.doc; + const reportDoc: Record = this.initializeReportDoc(internalId, xmlVersion, defaultData); + const formDocData = new EnketoReportRootDoc(this.getFormDataXml(form), reportDoc._id); const subDocs = formDocData.getDbDocs(); this.populateDbDocRefElements(formDocData, [formDocData, ...subDocs]); - const binaryAttachments = this.populateBinaryAttachmentElements(formDocData, config.doc.internalId); - const fileAttachments = FileManager - .getCurrentFiles() - .reduce((acc, file) => ({ - ...acc, - [`user-file-${file.name}`]: { content_type: file.type, data: new Blob([ file ], { type: file.type }) } - }), {}); + const formAttachments = this.processFormAttachments(formDocData, config.doc.internalId); const rootOutputDoc = { ...reportDoc, - form_version: config.doc.xmlVersion, hidden_fields: this.getHiddenFields([ ...formDocData.hiddenElements, ...subDocs.map(({ rootElement }) => rootElement) @@ -44,12 +100,11 @@ export class NewEnketoService { fields: formDocData.deserialize(config), _attachments: { ...reportDoc._attachments, - ...fileAttachments, - ...binaryAttachments + ...formAttachments, } }; - const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserialize(config), docData.id)); + const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserializeDoc(config))); return [rootOutputDoc, ...dbDocObjects]; }); } @@ -62,10 +117,45 @@ export class NewEnketoService { form.view.html.dispatchEvent(events.BeforeSave()); } - private getEnketoDoc(form: Record, docId: string) { + private getFormDataXml(form: Record) { const formString = form.getDataStr({ irrelevant: false }); - const formDoc = new DOMParser().parseFromString(formString, 'text/xml'); - return new EnketoRootDoc(formDoc, docId); + return new DOMParser().parseFromString(formString, 'text/xml'); + } + + private dehydrateContactLineage(contactDoc: Record) { + return { + ...contactDoc, + parent: this.extractLineageService.extract(contactDoc.parent), + contact: this.extractLineageService.extract(contactDoc.contact) + }; + } + + private initializeContactSibling( + rootContactDoc: Record, + rawSibling?: Record + ): Record | undefined { + if (!rawSibling) { + return; + } + return { + type: 'person', // legacy support - form data should override this + ...this.initializeDoc(rawSibling), + parent: rawSibling.parent === 'PARENT' ? rootContactDoc : rawSibling.parent + }; + } + + private async getContactSiblingValue( + sibling?: Record, + currentValue?: Record, + defaultValue?: Record + ) { + if (currentValue?._id === 'NEW') { + return sibling; + } else if (currentValue?._id && currentValue?._id !== defaultValue?._id) { + return await this.getContactFromDatasource(Qualifier.byUuid(currentValue?._id)); + } + + return currentValue; } private getHiddenFields(elements: Element[]) { @@ -82,26 +172,27 @@ export class NewEnketoService { .join('.')); } - private initializeDoc(defaultData: Record, _id = uuid()) { + private initializeDoc(defaultData: Record): Record { return { - _id, + _id: defaultData._id || uuid(), reported_date: Date.now(), ...defaultData }; } - private getReportDoc(form: string, defaultData: Record) { + private initializeReportDoc(form: string, formVersion: string, defaultData: Record) { return { form, type: DOC_TYPES.DATA_RECORD, content_type: 'xml', from: defaultData.contact?.phone, - ...this.initializeDoc(defaultData, defaultData._id), - ...defaultData + ...this.initializeDoc(defaultData), + ...defaultData, + form_version: formVersion }; } - private populateDbDocRefElements(rootDoc: EnketoRootDoc, allDocs: EnketoDoc[]) { + private populateDbDocRefElements(rootDoc: EnketoReportRootDoc, allDocs: EnketoDoc[]) { rootDoc.dbDocRefElements.forEach(element => { const reference = element.getAttribute('db-doc-ref'); const referencedNode = rootDoc.getNodeByXpath(element, reference); @@ -115,8 +206,8 @@ export class NewEnketoService { }); } - private populateBinaryAttachmentElements(rootDocData: EnketoRootDoc, form: string) { - return rootDocData.binaryTypeElements + private processFormAttachments(rootDocData: EnketoRootDoc, form: string) { + const binaryAttachments = rootDocData.binaryTypeElements .filter(({ textContent }) => textContent) .map(element => { const xpath = Xpath.getElementTreeXPath(element); @@ -130,6 +221,16 @@ export class NewEnketoService { ...acc, [filename]: { data, content_type: 'image/png' } }), {}); + const fileAttachments = FileManager + .getCurrentFiles() + .reduce((acc, file) => ({ + ...acc, + [`user-file-${file.name}`]: { content_type: file.type, data: new Blob([ file ], { type: file.type }) } + }), {}); + return { + ...fileAttachments, + ...binaryAttachments + }; } } @@ -164,6 +265,14 @@ class EnketoDoc { ); } + public deserializeDoc(formConfig: FormConfig): Record { + return { + _id: this.id, + form_version: formConfig.doc.xmlVersion, + ...this.deserialize(formConfig) + }; + } + protected isElementNode(node: unknown): node is Element { return node?.['nodeType'] === Node.ELEMENT_NODE; } @@ -181,7 +290,8 @@ class EnketoDoc { const nodePath = `${path}/${node.nodeName}`; const value = this.getJsValueForNode(node, repeatPaths, nodePath); if (repeatPaths.includes(nodePath)) { - (result[node.nodeName] ??= []).push(value); + result[node.nodeName] ??= []; + result[node.nodeName].push(value); } else { result[node.nodeName] = value; } @@ -198,10 +308,79 @@ class EnketoDoc { } return node.textContent; } + + protected findChildNode(element: Element, tagName: string) { + return Array + .from(element.children) + .find(child => child.tagName === tagName); + } + + protected getDocId(element: Element) { + return this.findChildNode(element, '_id')?.textContent || uuid(); + } } -// TODO Consider making this a ReportDoc and nesting all the dbDoc data stuff inside. +// TODO not sure there is much value here. class EnketoRootDoc extends EnketoDoc { + public readonly binaryTypeElements: Element[]; + + constructor( + rootElement: Element, + id: string, + ) { + super(rootElement, id); + this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); + } +} + +class EnektoContactRootDoc extends EnketoRootDoc { + public static readonly SIBLING_FIELD_NAMES = ['parent', 'contact'] as const; + private readonly childElements: Element[]; + private readonly rootContactElement: Element; + + constructor( + xmlDoc: XMLDocument, + id: string, + type: string, + ) { + super(xmlDoc.documentElement, id); + this.childElements = Array.from(this.rootElement.querySelectorAll(':scope > repeat > child')); + const elementForType = this.findChildNode(this.rootElement, type); + if (!elementForType) { + // Fail loudly here because previous save logic was very "flexible" around the naming of this group. However, the + // contact form documentation and the prepopulation logic when rendering the form both clearly intend for the + // contact's data to be loaded from the group with the name of the contact type. + throw new Error( + `Failed to save contact form because the data for the contact is not contained in the ${type} group.` + ); + } + this.rootContactElement = elementForType; + } + + public deserializeDoc(formConfig: FormConfig): Record { + const rootDoc = new EnketoDoc(this.rootContactElement, this.id).deserializeDoc(formConfig); + const liftIdValue = (idValue: unknown) => typeof idValue === 'string' ? { _id: idValue } : idValue; + return { + ...rootDoc, + parent: liftIdValue(rootDoc.parent), + contact: liftIdValue(rootDoc.contact) + }; + } + + public getChildDocs() { + return this.childElements.map(dbDoc => new EnketoDoc( + dbDoc, + this.getDocId(dbDoc), + )); + } + + public getSiblingDoc(fieldName: typeof EnektoContactRootDoc.SIBLING_FIELD_NAMES[number]) { + const element = this.findChildNode(this.rootElement, fieldName); + return element ? new EnketoDoc(element, this.getDocId(element)) : null; + } +} + +class EnketoReportRootDoc extends EnketoDoc { private readonly dbDocElements: Element[]; public readonly hiddenElements: Element[]; public readonly dbDocRefElements: Element[]; @@ -254,13 +433,6 @@ class EnketoRootDoc extends EnketoDoc { return result.singleNodeValue; } - private getDocId(element: Element) { - return Array - .from(element.children) - .find(child => child.tagName === '_id') - ?.textContent || uuid(); - } - private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { if (!this.isElementNode(contextNode)) { return lineage; diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index f3fad166d77..fdef5e1f597 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -76,9 +76,11 @@ export class FormService { this.globalActions = new GlobalActions(store); this.servicesActions = new ServicesActions(this.store); this.getReport = chtDatasourceService.bind(Report.v1.get); + this.getContact = chtDatasourceService.bind(Contact.v1.get); } private readonly getReport: ReturnType; + private readonly getContact: ReturnType; private globalActions: GlobalActions; private servicesActions: ServicesActions; @@ -400,20 +402,19 @@ export class FormService { docId: string | undefined; type: string; }, - formInfo: { - form: any; - xmlVersion: string | undefined; - duplicateCheck?: DuplicateCheck - }, + form: Record, + formConfig: FormConfig, duplicatesAcknowledged: boolean, ) { const { docId, type } = contactInfo; - const { form, xmlVersion, duplicateCheck } = formInfo; const typeFields = this.contactTypesService.isHardcodedType(type) ? { type } : { type: 'contact', contact_type: type }; - const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion); + // const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion); + const defaultData = docId ? await this.getContact(Qualifier.byUuid(docId)) : typeFields; + const docs = await this.newEnketoService.saveContact(formConfig, form, defaultData!); + const preparedDocs = await this.applyTransitions(docs); const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); @@ -422,7 +423,7 @@ export class FormService { primaryDoc ?? preparedDocs.preparedDocs[0], type, duplicatesAcknowledged, - duplicateCheck + formConfig.doc.duplicate_check ); if (duplicates?.length) { throw new DuplicatesFoundError('Duplicates found', duplicates); From e2c6fad7e3e02bb612b086d38daa13b2166e7dbc Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Wed, 15 Jul 2026 21:08:27 -0500 Subject: [PATCH 07/41] Fix some bugs --- webapp/src/ts/services/NewEnketoService.ts | 19 +++++++++++-------- webapp/src/ts/services/form.service.ts | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index afa7fbdf98e..c5cc7c6c10c 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -59,14 +59,14 @@ export class NewEnketoService { ); })); const outputSiblings = siblings - .filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName]._id === 'NEW') + .filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName] === doc) .map(({ doc }) => doc) .filter(doc => !!doc); const childDocs = formDocData .getChildDocs() .map(doc => this.initializeDoc(doc.deserializeDoc(config))) - .map(doc => ({ ...doc, parent: { _id: rootOutputDoc._id }})); + .map(doc => ({ ...doc, parent: rootOutputDoc })); return { docId: rootOutputDoc._id, @@ -140,7 +140,7 @@ export class NewEnketoService { return { type: 'person', // legacy support - form data should override this ...this.initializeDoc(rawSibling), - parent: rawSibling.parent === 'PARENT' ? rootContactDoc : rawSibling.parent + parent: rawSibling.parent === 'PARENT' ? { ...rootContactDoc } : rawSibling.parent }; } @@ -149,13 +149,16 @@ export class NewEnketoService { currentValue?: Record, defaultValue?: Record ) { - if (currentValue?._id === 'NEW') { + if (!currentValue?._id) { + return undefined; + } + if (currentValue._id === 'NEW') { return sibling; - } else if (currentValue?._id && currentValue?._id !== defaultValue?._id) { - return await this.getContactFromDatasource(Qualifier.byUuid(currentValue?._id)); + } else if (currentValue?._id === defaultValue?._id) { + return defaultValue; } - return currentValue; + return await this.getContactFromDatasource(Qualifier.byUuid(currentValue?._id)); } private getHiddenFields(elements: Element[]) { @@ -187,7 +190,7 @@ export class NewEnketoService { content_type: 'xml', from: defaultData.contact?.phone, ...this.initializeDoc(defaultData), - ...defaultData, + contact: this.extractLineageService.extract(defaultData.contact), form_version: formVersion }; } diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index fdef5e1f597..d3bb7bc7760 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -325,7 +325,7 @@ export class FormService { } const isTrainingCardForm = this.trainingCardsService.isTrainingCardForm(formInternalId); - const contact = this.extractLineageService.extract(await this.getUserContact(!isTrainingCardForm)); + const contact = await this.getUserContact(!isTrainingCardForm); const docs = await this.newEnketoService.saveReport(formConfig, form, { contact }); // const docs = await this.enketoService.completeNewReport(formInternalId, form, formDoc, contact); From c8572c893dfa86b930904cff6826add478b5da77 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Wed, 15 Jul 2026 21:14:09 -0500 Subject: [PATCH 08/41] Narrow the CI checks for now --- .github/workflows/build.yml | 28 +++------------------- webapp/src/ts/services/NewEnketoService.ts | 1 - 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8e13858a08e..7abd3390830 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,8 +78,8 @@ jobs: - run: npm ci - name: Use system shellcheck binary run: cp /usr/bin/shellcheck node_modules/shellcheck/bin/shellcheck - - name: Compile - run: npm run ci-compile + - name: Compile (skip unit tests) + run: node scripts/ci/check-versions.js && node scripts/build/cli npmCiModules && npm run lint && npm run build && npm run integration-api - name: Setup QEMU if: ${{ env.INTERNAL_CONTRIBUTOR }} uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 @@ -128,28 +128,6 @@ jobs: - name: Run Tests run: npm run ${{ matrix.cmd }} - test-cht-form: - needs: build - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # zizmor: ignore[cache-poisoning] - with: - node-version: ${{ env.NODE_VERSION }} - - run: | - sudo apt-get update - sudo apt-get install -y xsltproc - - run: npm ci - - name: Build cht-form Web Component - run: npm run build-cht-form - - name: Run Tests - run: npm run integration-cht-form - tests-k3d: needs: build name: ${{ matrix.cmd }} @@ -431,7 +409,7 @@ jobs: if: ${{ failure() }} publish: - needs: [tests, config-tests, test-cht-form, build-generated-docs] + needs: [tests, config-tests, build-generated-docs] name: Publish branch build runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index c5cc7c6c10c..a5748d025ba 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -22,7 +22,6 @@ export class NewEnketoService { this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); } - // TODO re-fetch contact on update to ensure latest data public async saveContact( config: FormConfig, form: Record, From ce42f0fe16b4988c7ffcc46ad99bb9308895d29c Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 10:15:42 -0500 Subject: [PATCH 09/41] Update service to remove legacy `content`. --- webapp/src/ts/services/NewEnketoService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index a5748d025ba..b1fc15e750a 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -7,6 +7,7 @@ import * as FileManager from '../../js/enketo/file-manager'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; +import { REPORT_ATTACHMENT_NAME } from '@mm-services/get-report-content.service'; @Injectable({ providedIn: 'root' @@ -100,7 +101,10 @@ export class NewEnketoService { _attachments: { ...reportDoc._attachments, ...formAttachments, - } + // Remove the legacy XML content field and attachment (no longer stored since #7596 - 4.0.0) + [REPORT_ATTACHMENT_NAME]: undefined + }, + [REPORT_ATTACHMENT_NAME]: undefined, }; const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserializeDoc(config))); From 56e5eb09e6b4758ef7a39b31e24580d7d1570a60 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 10:44:31 -0500 Subject: [PATCH 10/41] Avoid writing empty attachments object --- webapp/src/ts/services/NewEnketoService.ts | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index b1fc15e750a..ea8464af84a 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -44,10 +44,7 @@ export class NewEnketoService { ...formDocData.deserializeDoc(config), type: contactDoc.type, contact_type: contactDoc.contact_type, - _attachments: { - ...contactDoc._attachments, - ...formAttachments, - } + _attachments: this.getDocAttachments(formAttachments, contactDoc._attachments) }; const siblings = EnektoContactRootDoc.SIBLING_FIELD_NAMES @@ -91,20 +88,18 @@ export class NewEnketoService { this.populateDbDocRefElements(formDocData, [formDocData, ...subDocs]); const formAttachments = this.processFormAttachments(formDocData, config.doc.internalId); - const rootOutputDoc = { + // Remove the legacy XML content field and attachment (no longer stored since #7596 - 4.0.0) + delete reportDoc[REPORT_ATTACHMENT_NAME]; + delete reportDoc._attachments?.[REPORT_ATTACHMENT_NAME]; + + const rootOutputDoc: Record = { ...reportDoc, hidden_fields: this.getHiddenFields([ ...formDocData.hiddenElements, ...subDocs.map(({ rootElement }) => rootElement) ]), fields: formDocData.deserialize(config), - _attachments: { - ...reportDoc._attachments, - ...formAttachments, - // Remove the legacy XML content field and attachment (no longer stored since #7596 - 4.0.0) - [REPORT_ATTACHMENT_NAME]: undefined - }, - [REPORT_ATTACHMENT_NAME]: undefined, + _attachments: this.getDocAttachments(formAttachments, reportDoc._attachments) }; const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserializeDoc(config))); @@ -112,6 +107,14 @@ export class NewEnketoService { }); } + private getDocAttachments(formAttachments: Record, currentDocAttachments?: Record) { + const attachments = { + ...currentDocAttachments, + ...formAttachments, + }; + return Object.keys(attachments).length ? attachments : undefined; + } + private async validate(form: Record) { const valid = await form.validate(); if (!valid) { From ac02e3d392f3a1c8327dcf9a0b374ad6db64d7fe Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 15:00:13 -0500 Subject: [PATCH 11/41] Fix attachment handling on edits --- webapp/src/ts/services/NewEnketoService.ts | 87 +++++++++++++--------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index ea8464af84a..61b6164a548 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -7,12 +7,12 @@ import * as FileManager from '../../js/enketo/file-manager'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import { REPORT_ATTACHMENT_NAME } from '@mm-services/get-report-content.service'; @Injectable({ providedIn: 'root' }) export class NewEnketoService { + private readonly USER_FILE_ATTACHMENT_PREFIX = 'user-file-'; private readonly getContactFromDatasource: ReturnType; constructor( @@ -37,14 +37,14 @@ export class NewEnketoService { contactDoc.contact_type || contactDoc.type ); - const formAttachments = this.processFormAttachments(formDocData, config.doc.internalId); + const formAttachments = this.processFormAttachments(config.doc.internalId, formDocData, contactDoc._attachments); const rootOutputDoc: Record = { ...contactDoc, ...formDocData.deserializeDoc(config), type: contactDoc.type, contact_type: contactDoc.contact_type, - _attachments: this.getDocAttachments(formAttachments, contactDoc._attachments) + _attachments: formAttachments }; const siblings = EnektoContactRootDoc.SIBLING_FIELD_NAMES @@ -86,11 +86,7 @@ export class NewEnketoService { const subDocs = formDocData.getDbDocs(); this.populateDbDocRefElements(formDocData, [formDocData, ...subDocs]); - const formAttachments = this.processFormAttachments(formDocData, config.doc.internalId); - - // Remove the legacy XML content field and attachment (no longer stored since #7596 - 4.0.0) - delete reportDoc[REPORT_ATTACHMENT_NAME]; - delete reportDoc._attachments?.[REPORT_ATTACHMENT_NAME]; + const attachments = this.processFormAttachments(config.doc.internalId, formDocData, reportDoc._attachments); const rootOutputDoc: Record = { ...reportDoc, @@ -99,7 +95,7 @@ export class NewEnketoService { ...subDocs.map(({ rootElement }) => rootElement) ]), fields: formDocData.deserialize(config), - _attachments: this.getDocAttachments(formAttachments, reportDoc._attachments) + _attachments: attachments }; const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserializeDoc(config))); @@ -107,14 +103,6 @@ export class NewEnketoService { }); } - private getDocAttachments(formAttachments: Record, currentDocAttachments?: Record) { - const attachments = { - ...currentDocAttachments, - ...formAttachments, - }; - return Object.keys(attachments).length ? attachments : undefined; - } - private async validate(form: Record) { const valid = await form.validate(); if (!valid) { @@ -215,31 +203,48 @@ export class NewEnketoService { }); } - private processFormAttachments(rootDocData: EnketoRootDoc, form: string) { + private processFormAttachments( + form: string, + rootDocData: EnketoRootDoc, + originalAttachments: Record = {} + ) { const binaryAttachments = rootDocData.binaryTypeElements - .filter(({ textContent }) => textContent) .map(element => { const xpath = Xpath.getElementTreeXPath(element); const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`); const filename = `user-file${formXpath}`; const data = element.textContent; element.textContent = ''; - return { filename, data }; + return { + filename, + // Currently do not support loading binary attachment data into edit form. So, keep existing value. + attachment: data ? { data, content_type: 'image/png' } : originalAttachments[filename] + }; }) - .reduce((acc, { filename, data }) => ({ - ...acc, - [filename]: { data, content_type: 'image/png' } - }), {}); - const fileAttachments = FileManager + .filter(({ attachment }) => attachment) + .reduce((acc, { filename, attachment }) => ({ ...acc, [filename]: attachment }), {}); + const newFileAttachments = FileManager .getCurrentFiles() .reduce((acc, file) => ({ ...acc, - [`user-file-${file.name}`]: { content_type: file.type, data: new Blob([ file ], { type: file.type }) } + [`${this.USER_FILE_ATTACHMENT_PREFIX}${file.name}`]: { + content_type: file.type, + data: new Blob([ file ], { type: file.type }) + } }), {}); - return { - ...fileAttachments, + const existingFileAttachments = Object + .entries(originalAttachments) + .filter(([key]) => key.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)) + // Keep existing file attachments still referenced by a field + .filter(([key]) => rootDocData.findNodeWithTextContent(key.slice(this.USER_FILE_ATTACHMENT_PREFIX.length))) + .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); + + const attachments = { + ...existingFileAttachments, + ...newFileAttachments, ...binaryAttachments }; + return Object.keys(attachments).length ? attachments : undefined; } } @@ -329,17 +334,27 @@ class EnketoDoc { } } -// TODO not sure there is much value here. class EnketoRootDoc extends EnketoDoc { public readonly binaryTypeElements: Element[]; constructor( - rootElement: Element, + protected readonly xmlDoc: XMLDocument, id: string, ) { - super(rootElement, id); + super(xmlDoc.documentElement, id); this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); } + + public findNodeWithTextContent(textContent: string) { + const result = this.xmlDoc.evaluate( + `.//*[text()=${JSON.stringify(textContent)}]`, + this.rootElement, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); + return result.singleNodeValue; + } } class EnektoContactRootDoc extends EnketoRootDoc { @@ -352,7 +367,7 @@ class EnektoContactRootDoc extends EnketoRootDoc { id: string, type: string, ) { - super(xmlDoc.documentElement, id); + super(xmlDoc, id); this.childElements = Array.from(this.rootElement.querySelectorAll(':scope > repeat > child')); const elementForType = this.findChildNode(this.rootElement, type); if (!elementForType) { @@ -389,21 +404,19 @@ class EnektoContactRootDoc extends EnketoRootDoc { } } -class EnketoReportRootDoc extends EnketoDoc { +class EnketoReportRootDoc extends EnketoRootDoc { private readonly dbDocElements: Element[]; public readonly hiddenElements: Element[]; public readonly dbDocRefElements: Element[]; - public readonly binaryTypeElements: Element[]; constructor( - private readonly xmlDoc: XMLDocument, + xmlDoc: XMLDocument, id: string, ) { - super(xmlDoc.documentElement, id); + super(xmlDoc, id); this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]')); this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]')); this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]')); - this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); } public getDbDocs() { From eeeb217fffb527603982fe78745a2b120506a2d8 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 19:36:54 -0500 Subject: [PATCH 12/41] Update whole flow to use FormConfig --- .../training-cards-form.component.ts | 16 +-- .../contacts/contacts-edit.component.ts | 107 ++++++++---------- .../contacts/contacts-report.component.ts | 16 +-- .../modules/reports/reports-add.component.ts | 15 ++- .../modules/tasks/tasks-content.component.ts | 41 ++++--- webapp/src/ts/services/NewEnketoService.ts | 26 ++--- webapp/src/ts/services/enketo.service.ts | 43 ++++--- webapp/src/ts/services/form.service.ts | 69 +++++------ webapp/src/ts/services/xml-forms.service.ts | 66 ++++++++--- .../cht-form/src/app.component.ts | 70 +++++------- 10 files changed, 225 insertions(+), 244 deletions(-) diff --git a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts index 86f55cb74b8..3271034cdd5 100644 --- a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts +++ b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro import { Store } from '@ngrx/store'; import { combineLatest, Subscription } from 'rxjs'; -import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; import { FormService, WebappEnketoFormContext } from '@mm-services/form.service'; import { Selectors } from '@mm-selectors/index'; import { GlobalActions } from '@mm-actions/global'; @@ -112,23 +112,23 @@ export class TrainingCardsFormComponent implements OnInit, OnDestroy { this.loadingContent = true; this.geoHandle?.cancel(); this.geoHandle = this.geolocationService.init(); - const form = await this.xmlFormsService.get(this.trainingCardFormId); - await this.ngZone.run(() => this.renderForm(form)); + const formConfig = await this.xmlFormsService.getFormConfig('training-card', this.trainingCardFormId!); + await this.ngZone.run(() => this.renderForm(formConfig)); } catch (error) { this.setError(error); console.error('TrainingCardsFormComponent :: Error fetching form.', error); } } - private async renderForm(formDoc) { + private async renderForm(formConfig: FormConfig) { try { - const formContext = new WebappEnketoFormContext(`#${this.FORM_WRAPPER_ID}`, 'training-card', formDoc); + const formContext = new WebappEnketoFormContext(`#${this.FORM_WRAPPER_ID}`, formConfig); formContext.isFormInModal = !this.isEmbedded; formContext.valuechangeListener = this.resetFormError.bind(this); this.form = await this.formService.render(formContext); - this.formNoTitle = !formDoc?.title; - this.setNavigationTitle(formDoc); + this.formNoTitle = !formConfig.doc.title; + this.setNavigationTitle(formConfig.doc); this.showContent(); this.recordPerformancePostRender(); } catch (error) { @@ -195,7 +195,7 @@ export class TrainingCardsFormComponent implements OnInit, OnDestroy { this.resetFormError(); try { - const docs = await this.formService.save(this.trainingCardFormId, this.form, this.geoHandle); + const docs = await this.formService.save(this.form, this.geoHandle); console.debug('Saved form and associated docs', docs); const snackText = await this.translateService.get('training_cards.form.saved'); this.globalActions.setSnackbarContent(snackText); diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 334c380b78d..0da5613b743 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -22,7 +22,7 @@ import { Contact, Qualifier } from '@medic/cht-datasource'; import { TelemetryService } from '@mm-services/telemetry.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import events from 'enketo-core/src/js/event'; -import { FormConfig } from '@mm-services/NewEnketoService'; +import { XmlFormsService } from '@mm-services/xml-forms.service'; @Component({ templateUrl: './contacts-edit.component.html', @@ -37,6 +37,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private readonly formService: FormService, private readonly contactTypesService: ContactTypesService, private readonly dbService: DbService, + private readonly xmlFormsService: XmlFormsService, private readonly performanceService: PerformanceService, private readonly telemetryService: TelemetryService, readonly chtDatasourceService: CHTDatasourceService, @@ -51,7 +52,6 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { subscription = new Subscription(); translationsLoadedSubscription; private globalActions; - private formConfig?: FormConfig; private readonly getContactFromDatasource: ReturnType; enketoStatus; @@ -322,12 +322,10 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { } private async renderForm(formId: string, titleKey: string) { - const formDoc = await this.dbService.get().get(formId); - this.formConfig = new FormConfig(formDoc, ''); - + const formConfig = await this.xmlFormsService.getFormConfig('contact', formId); this.globalActions.setEnketoEditedStatus(false); - const formContext = new WebappEnketoFormContext('#contact-form', 'contact', formDoc, this.getFormInstanceData()); + const formContext = new WebappEnketoFormContext('#contact-form', formConfig, this.getFormInstanceData()); formContext.editedListener = this.markFormEdited.bind(this); formContext.valuechangeListener = this.resetFormError.bind(this); formContext.titleKey = titleKey; @@ -432,65 +430,56 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.globalActions.setEnketoSavingStatus(true); this.globalActions.setEnketoError(null); - return Promise - .resolve(form.validate())// TODO this is moving down. Make sure that is okay - .then((valid) => { - if (!valid) { - throw new Error('Validation failed.'); + if (this.duplicatesAcknowledged && this.duplicates.length) { + this.telemetryService.record( + ['enketo', 'contacts', this.enketoContact.type, 'duplicates_acknowledged'].join(':') + ); + } + + return this.formService + .saveContact( + { docId, type: this.enketoContact.type }, + form, + this.duplicatesAcknowledged + ) + .catch(() => { + // validation messages will be displayed for individual fields. + // That's all we want, really. + this.globalActions.setEnketoSavingStatus(false); + }) + .then((result) => { + if (!result) { + return; } + console.debug('saved contact', result); - // Updating fields before save. Ref: #6670. - form.view.html.dispatchEvent(events.BeforeSave()); + this.globalActions.setEnketoSavingStatus(false); + this.globalActions.setEnketoEditedStatus(false); - if (this.duplicatesAcknowledged && this.duplicates.length) { - this.telemetryService.record( - ['enketo', 'contacts', this.enketoContact.type, 'duplicates_acknowledged'].join(':') - ); - } + this.trackSave?.stop({ + name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'save'].join(':'), + recordApdex: true, + }); + + this.translateService + .get(docId ? 'contact.updated' : 'contact.created') + .then(snackBarContent => this.globalActions.setSnackbarContent(snackBarContent)); - return this.formService - .saveContact( - { docId, type: this.enketoContact.type }, - form, - this.formConfig!, - this.duplicatesAcknowledged - ) - .then((result) => { - console.debug('saved contact', result); - - this.globalActions.setEnketoSavingStatus(false); - this.globalActions.setEnketoEditedStatus(false); - - this.trackSave?.stop({ - name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'save'].join(':'), - recordApdex: true, - }); - - this.translateService - .get(docId ? 'contact.updated' : 'contact.created') - .then(snackBarContent => this.globalActions.setSnackbarContent(snackBarContent)); - - this.router.navigate(['/contacts', result.docId]); - }) - .catch((err) => { - if (err instanceof DuplicatesFoundError) { - this.duplicates = err.duplicates; - } else { - this.duplicates = []; - } - - console.error('Error submitting form data', err); - - this.globalActions.setEnketoSavingStatus(false); - return this.translateService - .get('Error updating contact') - .then(error => this.globalActions.setEnketoError(error)); - }); + this.router.navigate(['/contacts', result.docId]); }) - .catch(() => { - // validation messages will be displayed for individual fields. - // That's all we want, really. + .catch((err) => { + if (err instanceof DuplicatesFoundError) { + this.duplicates = err.duplicates; + } else { + this.duplicates = []; + } + + console.error('Error submitting form data', err); + this.globalActions.setEnketoSavingStatus(false); + return this.translateService + .get('Error updating contact') + .then(error => this.globalActions.setEnketoError(error)); }); } diff --git a/webapp/src/ts/modules/contacts/contacts-report.component.ts b/webapp/src/ts/modules/contacts/contacts-report.component.ts index cab84771e93..f9c1cfdd872 100644 --- a/webapp/src/ts/modules/contacts/contacts-report.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-report.component.ts @@ -16,6 +16,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; +import { EnketoForm } from '@mm-services/NewEnketoService'; @Component({ templateUrl: './contacts-report.component.html', @@ -35,7 +36,7 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit enketoStatus; enketoSaving; enketoError; - form; + form?: EnketoForm; loadingForm; errorTranslationKey; contentError; @@ -64,7 +65,7 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit this.geoHandle = this.geolocationService.init(); this.resetFormError(); - this.form = null; + this.form = undefined; this.loadingForm = true; this.globalActions.setShowContent(true); this.setCancelCallback(); @@ -115,7 +116,7 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit return Promise .all([ this.getContact(), - this.xmlFormsService.get(this.routeSnapshot.params?.formId), + this.xmlFormsService.getFormConfig('report', this.routeSnapshot.params?.formId), ]); } @@ -129,15 +130,14 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit return this .getContactAndForm() - .then(([ contact, formDoc ]) => { + .then(([ contact, formConfig ]) => { this.globalActions.setEnketoEditedStatus(false); - this.globalActions.setTitle(this.translateFromService.get(formDoc.title)); + this.globalActions.setTitle(this.translateFromService.get(formConfig.doc.title)); this.setCancelCallback(); const formContext = new WebappEnketoFormContext( '#contact-report', - 'report', - formDoc, + formConfig, { source: 'contact', contact } ); formContext.editedListener = this.markFormEdited.bind(this); @@ -228,7 +228,7 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit this.globalActions.setEnketoSavingStatus(true); this.resetFormError(); this.formService - .save(this.routeSnapshot.params.formId, this.form, this.geoHandle) + .save(this.form!, this.geoHandle) .then((docs) => { console.debug('saved report and associated docs', docs); this.globalActions.setEnketoSavingStatus(false); diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index ac15fdf13dd..fd624e51112 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -8,7 +8,7 @@ import { DbService } from '@mm-services/db.service'; import { FileReaderService } from '@mm-services/file-reader.service'; import { GetReportContentService } from '@mm-services/get-report-content.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; import { Selectors } from '@mm-selectors/index'; import { GeolocationService } from '@mm-services/geolocation.service'; import { GlobalActions } from '@mm-actions/global'; @@ -160,12 +160,12 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { return Promise .all([ this.getReportContentService.getReportContent(model?.doc), - this.xmlFormsService.get(model?.formInternalId) + this.xmlFormsService.getFormConfig('report', model.formInternalId) ]) - .then(([ reportContent, form ]) => { + .then(([ reportContent, formConfig ]) => { this.globalActions.setEnketoEditedStatus(false); - return this.ngZone.run(() => this.renderForm(form, reportContent, model)); + return this.ngZone.run(() => this.renderForm(formConfig, reportContent, model)); }); }) .catch((err) => { @@ -235,8 +235,8 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { }))); } - private async renderForm(formDoc, reportContent, model) { - const formContext = new WebappEnketoFormContext('#report-form', 'report', formDoc, reportContent); + private async renderForm(formConfig: FormConfig, reportContent, model) { + const formContext = new WebappEnketoFormContext('#report-form', formConfig, reportContent); formContext.editing = !!reportContent; formContext.editedListener = this.markFormEdited.bind(this); formContext.valuechangeListener = this.resetFormError.bind(this); @@ -332,10 +332,9 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { this.globalActions.setEnketoSavingStatus(true); this.resetFormError(); const reportId = this.selectedReport?.doc?._id; - const formInternalId = this.selectedReport?.formInternalId; return this.formService - .save(formInternalId, this.form, this.geoHandle, reportId) + .save(this.form, this.geoHandle, reportId) .then((docs) => { console.debug('saved report and associated docs', docs); this.globalActions.setEnketoSavingStatus(false); diff --git a/webapp/src/ts/modules/tasks/tasks-content.component.ts b/webapp/src/ts/modules/tasks/tasks-content.component.ts index eab48e6f896..3e75d0d74c7 100644 --- a/webapp/src/ts/modules/tasks/tasks-content.component.ts +++ b/webapp/src/ts/modules/tasks/tasks-content.component.ts @@ -6,7 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { FormService, WebappEnketoFormContext } from '@mm-services/form.service'; import { PerformanceService } from '@mm-services/performance.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; -import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; import { GlobalActions } from '@mm-actions/global'; import { TasksActions } from '@mm-actions/tasks'; import { Selectors } from '@mm-selectors/index'; @@ -21,6 +21,7 @@ import { SimpleDatePipe } from '@mm-pipes/date.pipe'; import { TranslateFromPipe } from '@mm-pipes/translate-from.pipe'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; +import { EnketoForm } from '@mm-services/NewEnketoService'; @Component({ templateUrl: './tasks-content.component.html', @@ -55,10 +56,9 @@ export class TasksContentComponent implements OnInit, OnDestroy { private enketoEdited; loadingContent; selectedTask: any = null; - form; + form?: EnketoForm; loadingForm; contentError; - formId; private cancelCallback; errorTranslationKey; private tasksList; @@ -76,8 +76,7 @@ export class TasksContentComponent implements OnInit, OnDestroy { this.subscribeToStore(); this.subscribeToRouteParams(); - this.form = null; - this.formId = null; + this.form = undefined; this.resetFormError(); this.tasksActions.setLastSubmittedTask(null); @@ -223,10 +222,10 @@ export class TasksContentComponent implements OnInit, OnDestroy { } } - private renderForm(action, formDoc) { + private renderForm(action, formConfig: FormConfig) { this.globalActions.setEnketoEditedStatus(false); - const formContext = new WebappEnketoFormContext('#task-report', 'task', formDoc, action.content); + const formContext = new WebappEnketoFormContext('#task-report', formConfig, action.content); formContext.editedListener = this.markFormEdited.bind(this); formContext.valuechangeListener = this.resetFormError.bind(this); @@ -235,10 +234,10 @@ export class TasksContentComponent implements OnInit, OnDestroy { .then((formInstance) => { this.form = formInstance; this.loadingForm = false; - if (formDoc?.translation_key) { - this.globalActions.setTitle(this.translateService.instant(formDoc.translation_key)); + if (formConfig.doc.translation_key) { + this.globalActions.setTitle(this.translateService.instant(formConfig.doc.translation_key)); } else { - this.globalActions.setTitle(this.translateFromService.get(formDoc?.title)); + this.globalActions.setTitle(this.translateFromService.get(formConfig.doc.title)); } }); } @@ -272,7 +271,7 @@ export class TasksContentComponent implements OnInit, OnDestroy { this.interactionTrackingService.record('task:back'); this.tasksActions.setSelectedTask(null); this.formService.unload(this.form); - this.form = null; + this.form = undefined; this.loadingForm = false; this.contentError = false; this.globalActions.clearNavigation(); @@ -286,14 +285,13 @@ export class TasksContentComponent implements OnInit, OnDestroy { if (action.type === 'report') { this.interactionTrackingService.record('task:form_open', action.form); this.loadingForm = true; - this.formId = action.form; return this.xmlFormsService - .get(action.form) - .then((formDoc) => this.renderForm(action, formDoc)) + .getFormConfig('task', action.form) + .then((formConfig) => this.renderForm(action, formConfig)) .then(() => { this.trackMetadata.action = action.content.doc ? 'edit' : 'add'; this.trackRender?.stop({ - name: [ 'enketo', 'tasks', this.formId, this.trackMetadata.action, 'render' ].join(':'), + name: [ 'enketo', 'tasks', action.form, this.trackMetadata.action, 'render' ].join(':'), recordApdex: true, }); this.trackEditDuration = this.performanceService.track(); @@ -326,10 +324,11 @@ export class TasksContentComponent implements OnInit, OnDestroy { return; } - this.interactionTrackingService.record('task:form_save', this.formId); + const formId = this.form?.config.doc.internalId; + this.interactionTrackingService.record('task:form_save', formId); this.trackEditDuration?.stop({ - name: [ 'enketo', 'tasks', this.formId, this.trackMetadata.action, 'user_edit_time' ].join(':'), + name: [ 'enketo', 'tasks', formId, this.trackMetadata.action, 'user_edit_time' ].join(':'), }); this.trackSave = this.performanceService.track(); @@ -337,7 +336,7 @@ export class TasksContentComponent implements OnInit, OnDestroy { this.resetFormError(); return this.formService - .save(this.formId, this.form, this.geoHandle) + .save(this.form!, this.geoHandle) .then((docs) => { console.debug('saved report and associated docs', docs); this.globalActions.setSnackbarContent(this.translateService.instant('report.created')); @@ -349,12 +348,12 @@ export class TasksContentComponent implements OnInit, OnDestroy { this.globalActions.unsetSelected(); this.globalActions.clearNavigation(); - this.interactionTrackingService.record('task:complete', this.formId); + this.interactionTrackingService.record('task:complete', formId); this.router.navigate(['/tasks', 'group']); }) .then(() => { this.trackSave?.stop({ - name: [ 'enketo', 'tasks', this.formId, this.trackMetadata.action, 'save' ].join(':'), + name: [ 'enketo', 'tasks', formId, this.trackMetadata.action, 'save' ].join(':'), recordApdex: true, }); }) @@ -366,7 +365,7 @@ export class TasksContentComponent implements OnInit, OnDestroy { } navigationCancel() { - this.interactionTrackingService.record('task:cancel', this.formId); + this.interactionTrackingService.record('task:cancel', this.form?.config.doc.internalId); this.globalActions.navigationCancel(); } diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index 61b6164a548..22063b3ab12 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -7,6 +7,7 @@ import * as FileManager from '../../js/enketo/file-manager'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; +import { FormConfig } from '@mm-services/xml-forms.service'; @Injectable({ providedIn: 'root' @@ -24,8 +25,7 @@ export class NewEnketoService { } public async saveContact( - config: FormConfig, - form: Record, + { config, form }: EnketoForm, defaultData: Record ) { return this.ngZone.runOutsideAngular(async () => { @@ -74,10 +74,9 @@ export class NewEnketoService { } public async saveReport( - config: FormConfig, - form: Record, + { config, form }: EnketoForm, defaultData: Record - ): Promise> { + ) { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); const { internalId, xmlVersion } = config.doc; @@ -248,20 +247,9 @@ export class NewEnketoService { } } -// TODO Should return direct from xml-forms.service... -export class FormConfig { - public readonly repeatPaths: string[]; - - constructor( - public readonly doc: Record, - xml: string, - ){ - const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); - this.repeatPaths = Array - .from(xmlDoc.querySelectorAll('repeat[nodeset]')) - .map(el => el.getAttribute('nodeset')!) - .filter(Boolean); - } +export interface EnketoForm { + form: Record + config: FormConfig } class EnketoDoc { diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index 2a219d00e0f..e08c78d056c 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -17,6 +17,8 @@ import { TranslateService } from '@mm-services/translate.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Qualifier, Report } from '@medic/cht-datasource'; import { DOC_TYPES } from '@medic/constants'; +import { FormConfig } from '@mm-services/xml-forms.service'; +import { EnketoForm } from '@mm-services/NewEnketoService'; /** * Service for interacting with Enketo forms. This code is intended for displaying forms in the CHT as well as being @@ -173,11 +175,11 @@ export class EnketoService { private async getEnketoForm(xmlFormContext:XmlFormContext, userSettings) { const instanceStr = this.enketoPrepopulationDataService.get( userSettings, - xmlFormContext.doc.model, + xmlFormContext.formConfig.model, xmlFormContext.instanceData ); const options: EnketoOptions = { - modelStr: xmlFormContext.doc.model, + modelStr: xmlFormContext.formConfig.model, instanceStr: instanceStr, external: this.convertContactSummaryToXML([xmlFormContext.contactSummary, xmlFormContext.userContactSummary]), }; @@ -186,10 +188,10 @@ export class EnketoService { } private renderFromXmls(xmlFormContext: XmlFormContext, userSettings) { - const { doc, titleKey, wrapper, isFormInModal } = xmlFormContext; + const { formConfig, titleKey, wrapper, isFormInModal } = xmlFormContext; const formContainer = wrapper.find('.container').first(); - formContainer.html(doc.html.get(0)!); + formContainer.html($(formConfig.html).get(0)!); return this .getEnketoForm(xmlFormContext, userSettings) @@ -200,7 +202,7 @@ export class EnketoService { return Promise.reject(new Error(JSON.stringify(loadErrors))); } }) - .then(() => this.getFormTitle(titleKey, doc)) + .then(() => this.getFormTitle(titleKey, formConfig.doc)) .then((title) => { this.setFormTitle(wrapper, title); wrapper.show(); @@ -327,26 +329,27 @@ export class EnketoService { } private registerEnketoListeners($selector, form, formContext: EnketoFormContext) { - this.registerAddrepeatListener($selector, formContext.formDoc); + this.registerAddrepeatListener($selector, formContext.formConfig.doc); this.registerEditedListener($selector, formContext.editedListener); this.registerValuechangeListener($selector, formContext.valuechangeListener); this.registerValuechangeListener($selector, () => this.setupNavButtons($selector, form.pages._getCurrentIndex())); } - public async renderForm(formContext: EnketoFormContext, doc, userSettings) { + public async renderForm(formContext: EnketoFormContext, userSettings): Promise { const { - formDoc, + formConfig, instanceData, selector, titleKey, isFormInModal, } = formContext; - this.replaceDataI18nTranslations(doc.html); - this.replaceJavarosaMediaWithLoaders(doc.html); + const $html = $(formConfig.html); + this.replaceDataI18nTranslations($html); + this.replaceJavarosaMediaWithLoaders($html); const xmlFormContext: XmlFormContext = { - doc, + formConfig, wrapper: $(selector), instanceData, titleKey, @@ -356,10 +359,10 @@ export class EnketoService { }; const form = await this.renderFromXmls(xmlFormContext, userSettings); const formContainer = xmlFormContext.wrapper.find('.container').first(); - this.replaceMediaLoaders(formContainer, formDoc); + this.replaceMediaLoaders(formContainer, formConfig.doc); this.registerEnketoListeners(xmlFormContext.wrapper, form, formContext); window.CHTCore.debugFormModel = () => form.model.getStr(); - return form; + return { form, config: formConfig }; } private xmlToDocs(doc, formXml, xmlVersion, record) { @@ -593,7 +596,8 @@ export class EnketoService { return this.xmlToDocs(doc, formDoc.xml, formDoc.doc.xmlVersion, dataString); } - unload(form) { + unload(enketoForm?: EnketoForm) { + const form = enketoForm?.form; if (form !== this.currentForm) { return; } @@ -627,11 +631,7 @@ interface EnketoOptions { } interface XmlFormContext { - doc: { - html: JQuery; - model: string; - title: string; - }; + formConfig: FormConfig; wrapper: JQuery; instanceData?: string | Record; // String for report forms, Record<> for contact forms. titleKey?: string; @@ -640,12 +640,9 @@ interface XmlFormContext { userContactSummary?: ContactSummary; } -export type FormType = 'contact' | 'report' | 'task' | 'training-card'; - export interface EnketoFormContext { readonly selector: string; - readonly formDoc: Record; - readonly type: FormType; + readonly formConfig: FormConfig; readonly instanceData?: string | Record; readonly editedListener?: () => void; readonly valuechangeListener?: () => void; diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index d3bb7bc7760..f099e235252 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -10,7 +10,7 @@ import { FileReaderService } from '@mm-services/file-reader.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { SubmitFormBySmsService } from '@mm-services/submit-form-by-sms.service'; import { UserContactService } from '@mm-services/user-contact.service'; -import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; import { ZScoreService } from '@mm-services/z-score.service'; import { ServicesActions } from '@mm-actions/services'; import { ContactSummaryService } from '@mm-services/contact-summary.service'; @@ -20,7 +20,7 @@ import { TransitionsService } from '@mm-services/transitions.service'; import { GlobalActions } from '@mm-actions/global'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; -import { ContactSummary, EnketoFormContext, EnketoService, FormType } from '@mm-services/enketo.service'; +import { ContactSummary, EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; import { ContactSaveService } from '@mm-services/contact-save.service'; import { reduce as _reduce } from 'lodash-es'; @@ -31,7 +31,7 @@ import { Nullable, Person, Contact, Report, Qualifier } from '@medic/cht-datasou import { DeduplicateService, DuplicateCheck } from '@mm-services/deduplicate.service'; import { ContactsService } from '@mm-services/contacts.service'; import { PerformanceService } from '@mm-services/performance.service'; -import { FormConfig, NewEnketoService } from '@mm-services/NewEnketoService'; +import { EnketoForm, NewEnketoService } from '@mm-services/NewEnketoService'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; /** @@ -163,10 +163,10 @@ export class FormService { return await this.contactSummaryService.get(contact, reports, lineage, targetDocs); } - private async getContactSummary(formDoc, instanceData):Promise { + private async getContactSummary(formConfig: FormConfig, instanceData):Promise { const instanceId = 'contact-summary'; const contact = instanceData?.contact; - if (!this.modelHasInstance(formDoc.model, instanceId) || !contact) { + if (!this.modelHasInstance(formConfig.model, instanceId) || !contact) { return; } @@ -176,9 +176,9 @@ export class FormService { }; } - private async getUserContactSummary(formDoc):Promise { + private async getUserContactSummary(formConfig: FormConfig):Promise { const instanceId = 'user-contact-summary'; - if (!this.modelHasInstance(formDoc.model, instanceId)) { + if (!this.modelHasInstance(formConfig.model, instanceId)) { return; } @@ -190,7 +190,7 @@ export class FormService { private canAccessForm(formContext: WebappEnketoFormContext) { return this.xmlFormsService.canAccessForm( - formContext.formDoc, + formContext.formConfig.doc, formContext.userContact, formContext.userContactSummary?.context, { @@ -202,26 +202,23 @@ export class FormService { } private async renderForm(formContext: WebappEnketoFormContext) { - const { formDoc, instanceData } = formContext; + const { formConfig, instanceData } = formContext; try { this.unload(this.enketoService.getCurrentForm()); - const [doc, userSettings] = await Promise.all([ - this.transformXml(formDoc), - this.userSettingsService.getWithLanguage() - ]); - formContext.contactSummary = await this.getContactSummary(doc, instanceData); - formContext.userContactSummary = await this.getUserContactSummary(doc); + const userSettings = await this.userSettingsService.getWithLanguage(); + formContext.contactSummary = await this.getContactSummary(formConfig, instanceData); + formContext.userContactSummary = await this.getUserContactSummary(formConfig); if (!await this.canAccessForm(formContext)) { throw { translationKey: 'error.loading.form.no_authorized' }; } - return await this.enketoService.renderForm(formContext, doc, userSettings); + return await this.enketoService.renderForm(formContext, userSettings); } catch (error) { if (error.translationKey) { throw error; } - const errorMessage = `Failed during the form "${formDoc.internalId}" rendering : `; + const errorMessage = `Failed during the form "${formConfig.doc.internalId}" rendering : `; throw new Error(errorMessage + error.message); } } @@ -314,20 +311,17 @@ export class FormService { return docs; } - private async completeReport(formInternalId, form, docId?) { - const formDoc = await this.ngZone - .runOutsideAngular(() => this.xmlFormsService.getDocAndFormAttachment(formInternalId)); - const formConfig = new FormConfig(formDoc.doc, formDoc.xml); + private async completeReport(enketoForm: EnketoForm, docId?) { if (docId) { const doc = await this.getReport(Qualifier.byUuid(docId)); - return this.newEnketoService.saveReport(formConfig, form, doc!); + return this.newEnketoService.saveReport(enketoForm, doc!); // return this.enketoService.completeExistingReport(form, formDoc, docId); } - const isTrainingCardForm = this.trainingCardsService.isTrainingCardForm(formInternalId); + const isTrainingCardForm = this.trainingCardsService.isTrainingCardForm(enketoForm.config.doc.internalId); const contact = await this.getUserContact(!isTrainingCardForm); - const docs = await this.newEnketoService.saveReport(formConfig, form, { contact }); + const docs = await this.newEnketoService.saveReport(enketoForm, { contact }); // const docs = await this.enketoService.completeNewReport(formInternalId, form, formDoc, contact); if (!docId && isTrainingCardForm) { docs[0]._id = this.trainingCardsService.getTrainingCardDocId(); @@ -335,8 +329,8 @@ export class FormService { return docs; } - async save(formInternalId, form, geoHandle, docId?) { - const docs = await this.completeReport(formInternalId, form, docId); + async save(enketoForm: EnketoForm, geoHandle, docId?) { + const docs = await this.completeReport(enketoForm, docId); return this.ngZone.runOutsideAngular(() => this._save(docs, geoHandle)); } @@ -402,8 +396,7 @@ export class FormService { docId: string | undefined; type: string; }, - form: Record, - formConfig: FormConfig, + enketoForm: EnketoForm, duplicatesAcknowledged: boolean, ) { const { docId, type } = contactInfo; @@ -413,7 +406,7 @@ export class FormService { // const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion); const defaultData = docId ? await this.getContact(Qualifier.byUuid(docId)) : typeFields; - const docs = await this.newEnketoService.saveContact(formConfig, form, defaultData!); + const docs = await this.newEnketoService.saveContact(enketoForm, defaultData!); const preparedDocs = await this.applyTransitions(docs); @@ -423,7 +416,7 @@ export class FormService { primaryDoc ?? preparedDocs.preparedDocs[0], type, duplicatesAcknowledged, - formConfig.doc.duplicate_check + enketoForm.config.doc.duplicate_check ); if (duplicates?.length) { throw new DuplicatesFoundError('Duplicates found', duplicates); @@ -440,7 +433,7 @@ export class FormService { return { docId: preparedDocs.docId, bulkDocsResult }; } - unload(form) { + unload(form?: EnketoForm) { this.enketoService.unload(form); } } @@ -456,8 +449,7 @@ export class DuplicatesFoundError extends Error { export class WebappEnketoFormContext implements EnketoFormContext { readonly selector: string; - readonly formDoc: Record; - readonly type: FormType; + readonly formConfig: FormConfig; readonly instanceData?: string | Record; editedListener?: () => void; valuechangeListener?: () => void; @@ -469,19 +461,18 @@ export class WebappEnketoFormContext implements EnketoFormContext { editing?: boolean; userContact?: Nullable; - constructor(selector: string, type: FormType, formDoc: Record, instanceData?) { + constructor(selector: string, formConfig: FormConfig, instanceData?) { this.selector = selector; - this.type = type; - this.formDoc = formDoc; + this.formConfig = formConfig; this.instanceData = instanceData; } shouldEvaluateExpression() { - if (this.type === 'task') { + if (this.formConfig.type === 'task') { return false; } - if (this.type === 'report' && this.editing) { + if (this.formConfig.type === 'report' && this.editing) { return false; } return true; @@ -489,6 +480,6 @@ export class WebappEnketoFormContext implements EnketoFormContext { requiresContact() { // Users can access contact forms even when they don't have a contact associated. - return this.type !== 'contact' && this.type !== 'training-card'; + return this.formConfig.type !== 'contact' && this.formConfig.type !== 'training-card'; } } diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index fc9bab98aee..173bee4263d 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -346,7 +346,7 @@ export class XmlFormsService { * @returns {Promise} Resolves a doc containing an xform with the given * internal identifier if the user is allowed to see it. */ - get(internalId) { + private get(internalId) { return this .getById(internalId) .catch(err => { @@ -368,23 +368,35 @@ export class XmlFormsService { }); } - getDocAndFormAttachment(internalId) { - return this.get(internalId) - .then(doc => { - const attachmentName = this.findXFormAttachmentName(doc); - return this.dbService.get().getAttachment(doc._id, attachmentName) - .then(blob => this.fileReaderService.utf8(blob)) - .then(xml => ({ doc, xml })) + private getAttachment(id: string, name: string) { + return this.dbService + .get() + .getAttachment(id, name) + .then(blob => this.fileReaderService.utf8(blob)); + } + + async getFormConfig(formType: FormType, id: string) { + return this.ngZone.runOutsideAngular(async () => { + // contact_types config stores full _id value. All other forms are referenced by internalId + const formDoc = await (formType === 'contact' + ? this.dbService.get().get(id) + : this.get(id)); + const xmlAttachmentName = this.findXFormAttachmentName(formDoc); + const [xml, html, model] = await Promise.all([ + this.getAttachment(formDoc._id, xmlAttachmentName) .catch(err => { - const errorTitle = 'Error in XMLFormService : getDocAndFormAttachment : '; - let errorMessage = `Failed to get the form "${internalId}" xform attachment`; - if (err.status === 404) { - errorMessage = `The form "${internalId}" doesn't have an xform attachment`; - } - console.error(errorTitle, errorMessage); - return Promise.reject(new Error(errorTitle + errorMessage)); - }); - }); + const errorDetails = err.status === 404 + ? `The form "${id}" doesn't have an xform attachment` + : `Failed to get the form "${id}" xform attachment`; + const errorMessage = `Error in XMLFormService : getDocAndFormAttachment : ${errorDetails}`; + console.error(errorMessage); + throw new Error(errorMessage); + }), + this.getAttachment(formDoc._id, this.HTML_ATTACHMENT_NAME), + this.getAttachment(formDoc._id, this.MODEL_ATTACHMENT_NAME) + ]); + return new FormConfig(formDoc, formType, xml, html, model); + }); } /** @@ -397,3 +409,23 @@ export class XmlFormsService { return this.init.then(forms => this.filterAll(forms, options || {})); } } + +export type FormType = 'contact' | 'report' | 'task' | 'training-card'; + +export class FormConfig { + public readonly repeatPaths: string[]; + + constructor( + public readonly doc: Record, + public type: string, + xml: string, + public readonly html: string, + public readonly model: string + ){ + const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); + this.repeatPaths = Array + .from(xmlDoc.querySelectorAll('repeat[nodeset]')) + .map(el => el.getAttribute('nodeset')!) + .filter(Boolean); + } +} diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index 20315879169..b25f1abcce4 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -4,12 +4,13 @@ import * as medicXpathExtensions from '../../../src/js/enketo/medic-xpath-extens import moment from 'moment'; import { toBik_text } from 'bikram-sambat'; import { TranslateService } from '@mm-services/translate.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; import { NgIf, DOCUMENT } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { CHTDatasourceService as CHTDatasourceServiceStub } from './stubs/cht-datasource.service'; import { CONTACT_TYPES } from '@medic/constants'; +import { FormConfig } from '@mm-services/xml-forms.service'; +import { EnketoForm, NewEnketoService } from '@mm-services/NewEnketoService'; const DEFAULT_FORM_ID = 'cht-form-id'; @@ -32,12 +33,9 @@ export class AppComponent { private readonly chtDataSourceService: CHTDatasourceServiceStub; private formContext = new ChtFormEnketoFormContext(); - - private _formXml?: string; - private _formModel?: string; - private _formHtml?: string; private _user: typeof this.DEFAULT_USER & Record = this.DEFAULT_USER; + private enketoForm?: EnketoForm; private currentRender?: Promise; private reRenderForm = false; @@ -47,8 +45,8 @@ export class AppComponent { constructor( chtDatasourceService: CHTDatasourceService, - private readonly contactSaveService: ContactSaveService, private readonly enketoService: EnketoService, + private readonly newEnketoService: NewEnketoService, private readonly translateService: TranslateService, @Inject(DOCUMENT) private readonly document: Document, ) { @@ -66,17 +64,17 @@ export class AppComponent { } @Input() set formHtml(value: string | undefined) { - this._formHtml = value; + this.formContext.formHtml = value; this.queueRenderForm(); } @Input() set formModel(value: string | undefined) { - this._formModel = value; + this.formContext.formModel = value; this.queueRenderForm(); } @Input() set formXml(value: string | undefined) { - this._formXml = value; + this.formContext.formXml = value; this.queueRenderForm(); } @@ -152,25 +150,17 @@ export class AppComponent { } private async getDocsFromForm() { - const currentForm = this.enketoService.getCurrentForm(); const { contactType } = this.formContext; if (contactType) { const typeFields = this.HARDCODED_TYPES.includes(contactType) ? { type: contactType } : { type: 'contact', contact_type: contactType }; - const { preparedDocs } = await this.contactSaveService.save(currentForm, null, typeFields, null); + const { preparedDocs } = await this.newEnketoService.saveContact(this.enketoForm!, typeFields); return preparedDocs; } - - const formDoc = { - xml: this._formXml, - doc: {} - }; - return this.enketoService.completeNewReport( - this.formContext.formId, - currentForm, - formDoc, - this.formContext.content?.contact + return this.newEnketoService.saveReport( + this.enketoForm!, + { contact: this.formContext.content?.contact } ); } @@ -193,7 +183,7 @@ export class AppComponent { private async renderForm() { this.unloadForm(); - if (!this._formHtml || !this._formModel || !this._formXml) { + if (!this.formContext.formHtml || !this.formContext.formModel || !this.formContext.formXml) { return; } @@ -204,15 +194,14 @@ export class AppComponent { .then(() => this.renderForm()); } - const formDetails = this.getFormDetails(); - await this.enketoService.renderForm(this.formContext, formDetails, this._user); + this.enketoForm = await this.enketoService.renderForm(this.formContext, this._user); this.onRender.emit(); } private unloadForm() { - const currentForm = this.enketoService.getCurrentForm(); - if (currentForm) { - this.enketoService.unload(currentForm); + if (this.enketoForm) { + this.enketoService.unload(this.enketoForm); + this.enketoForm = undefined; $(`${this.formContext.selector} .container.pages`).empty(); } } @@ -232,18 +221,6 @@ export class AppComponent { }); } - private getFormDetails() { - const $html = $(this._formHtml!); - const hasContactSummary = $(this._formModel!) - .find('> instance[id="contact-summary"]') - .length === 1; - return { - html: $html, - model: this._formModel, - hasContactSummary: hasContactSummary - }; - } - private tearDownForm() { this.unloadForm(); this.formContext = new ChtFormEnketoFormContext(); @@ -288,12 +265,21 @@ class ChtFormEnketoFormContext implements EnketoFormContext { }; formId = DEFAULT_FORM_ID; + formXml?: string; + formHtml?: string; + formModel?: string; contactType?: string; content?: Record; editing = false; - get formDoc() { - return { _id: this.formId }; + get formConfig() { + return new FormConfig( + { _id: this.formId }, + this.type, + this.formXml || 'xml', + this.formHtml || '', + this.formModel || '' + ); } get instanceData() { @@ -301,7 +287,7 @@ class ChtFormEnketoFormContext implements EnketoFormContext { } get selector() { - return `#${this.formId}`; + return `#${this.formConfig.doc._id}`; } get type() { From b7c6cb8ea5d3fc2b47d1b785fb79de8e1d57b337 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 19:43:35 -0500 Subject: [PATCH 13/41] Fix minor issues --- webapp/src/ts/modules/contacts/contacts-edit.component.ts | 1 - webapp/src/ts/modules/reports/reports-add.component.ts | 2 +- webapp/src/ts/services/xml-forms.service.ts | 2 +- webapp/web-components/cht-form/src/app.component.ts | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 0da5613b743..93775079c5b 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -21,7 +21,6 @@ import { DuplicateContactsComponent } from '@mm-components/duplicate-contacts/du import { Contact, Qualifier } from '@medic/cht-datasource'; import { TelemetryService } from '@mm-services/telemetry.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import events from 'enketo-core/src/js/event'; import { XmlFormsService } from '@mm-services/xml-forms.service'; @Component({ diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index fd624e51112..8a145a99979 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -159,7 +159,7 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { return Promise .all([ - this.getReportContentService.getReportContent(model?.doc), + this.getReportContentService.getReportContent(model.doc), this.xmlFormsService.getFormConfig('report', model.formInternalId) ]) .then(([ reportContent, formConfig ]) => { diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index 173bee4263d..4dc5e909ce0 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -417,7 +417,7 @@ export class FormConfig { constructor( public readonly doc: Record, - public type: string, + public type: FormType, xml: string, public readonly html: string, public readonly model: string diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index b25f1abcce4..48a23e20f97 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -287,7 +287,7 @@ class ChtFormEnketoFormContext implements EnketoFormContext { } get selector() { - return `#${this.formConfig.doc._id}`; + return `#${this.formId}`; } get type() { From b0097752961e8db1a8e5f7b51ca746622cdb35d3 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 19:55:15 -0500 Subject: [PATCH 14/41] Fix issue with form validation --- .../contacts/contacts-edit.component.ts | 18 +++++++++--------- webapp/src/ts/services/NewEnketoService.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 93775079c5b..59a2618461e 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -6,6 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { DuplicatesFoundError, FormService, WebappEnketoFormContext } from '@mm-services/form.service'; +import { FormValidationError } from '@mm-services/NewEnketoService'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; import { Selectors } from '@mm-selectors/index'; @@ -441,15 +442,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { form, this.duplicatesAcknowledged ) - .catch(() => { - // validation messages will be displayed for individual fields. - // That's all we want, really. - this.globalActions.setEnketoSavingStatus(false); - }) .then((result) => { - if (!result) { - return; - } console.debug('saved contact', result); this.globalActions.setEnketoSavingStatus(false); @@ -467,6 +460,14 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.router.navigate(['/contacts', result.docId]); }) .catch((err) => { + this.globalActions.setEnketoSavingStatus(false); + + if (err instanceof FormValidationError) { + // validation messages will be displayed for individual fields. + // That's all we want, really. + return; + } + if (err instanceof DuplicatesFoundError) { this.duplicates = err.duplicates; } else { @@ -475,7 +476,6 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { console.error('Error submitting form data', err); - this.globalActions.setEnketoSavingStatus(false); return this.translateService .get('Error updating contact') .then(error => this.globalActions.setEnketoError(error)); diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index 22063b3ab12..ef0e79738f7 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -105,7 +105,7 @@ export class NewEnketoService { private async validate(form: Record) { const valid = await form.validate(); if (!valid) { - throw new Error('Form is invalid'); + throw new FormValidationError(); } form.view.html.dispatchEvent(events.BeforeSave()); } @@ -252,6 +252,15 @@ export interface EnketoForm { config: FormConfig } +// Thrown when the form fails Enketo validation. Enketo displays the per-field validation messages itself, +// so callers should handle this silently (unlike other save errors). +export class FormValidationError extends Error { + constructor(message = 'Form is invalid') { + super(message); + this.name = 'FormValidationError'; + } +} + class EnketoDoc { constructor( public readonly rootElement: Element, From 171a25d416a1de028d67a196e01f289801db065d Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 20:37:38 -0500 Subject: [PATCH 15/41] Fix contact type issue --- webapp/src/ts/services/NewEnketoService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index ef0e79738f7..972ce66b94c 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -8,6 +8,7 @@ import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { FormConfig } from '@mm-services/xml-forms.service'; +import { isHardcodedType } from '@medic/contact-types-utils'; @Injectable({ providedIn: 'root' @@ -34,7 +35,7 @@ export class NewEnketoService { const formDocData = new EnektoContactRootDoc( this.getFormDataXml(form), contactDoc._id, - contactDoc.contact_type || contactDoc.type + isHardcodedType(contactDoc.type) ? contactDoc.type : contactDoc.contact_type ); const formAttachments = this.processFormAttachments(config.doc.internalId, formDocData, contactDoc._attachments); From e533acdcb013615c05e0d0af9b086e8129b6dbdc Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 20:37:51 -0500 Subject: [PATCH 16/41] Diable transpiling webapp tests --- webapp/tsconfig.json | 7 ++++++- webapp/tsconfig.spec.json | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index 2eb008088a9..e4c250a018d 100755 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -43,5 +43,10 @@ "strictInjectionParameters": true, "preserveWhitespaces": true, "skipTemplateCodegen": false - } + }, + // TEMPORARY: skip type-checking the karma specs during `ngc` (npm run compile) so e2e can run + // before the unit tests are updated. tsconfig.spec.json overrides this so karma still compiles + // them. Revert once the webapp specs transpile again. + // NOTE: specifying "exclude" replaces TS's defaults, so node_modules/outDir must be re-listed. + "exclude": ["node_modules", "dist", "tests/**"] } diff --git a/webapp/tsconfig.spec.json b/webapp/tsconfig.spec.json index 23301bd3920..fdab2641a77 100755 --- a/webapp/tsconfig.spec.json +++ b/webapp/tsconfig.spec.json @@ -17,5 +17,7 @@ "include": [ "tests/karma/**/*.spec.ts", "tests/karma/**/*.d.ts" - ] + ], + // Override the temporary "tests/**" exclusion in tsconfig.json so karma still compiles the specs. + "exclude": [] } From ed3024dd8101785a67a29a08f96e059fb048f2bc Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 20:56:45 -0500 Subject: [PATCH 17/41] Fix FormConfig import to not leak into cht-form --- .../training-cards-form.component.ts | 3 ++- .../modules/reports/reports-add.component.ts | 3 ++- .../modules/tasks/tasks-content.component.ts | 3 ++- webapp/src/ts/services/NewEnketoService.ts | 7 ++----- webapp/src/ts/services/enketo.service.ts | 21 ++++++++++++++++++- webapp/src/ts/services/form.service.ts | 4 ++-- webapp/src/ts/services/xml-forms.service.ts | 20 +----------------- .../cht-form/src/app.component.ts | 3 +-- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts index 3271034cdd5..e3dc9d9abb1 100644 --- a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts +++ b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro import { Store } from '@ngrx/store'; import { combineLatest, Subscription } from 'rxjs'; -import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; +import { XmlFormsService } from '@mm-services/xml-forms.service'; import { FormService, WebappEnketoFormContext } from '@mm-services/form.service'; import { Selectors } from '@mm-selectors/index'; import { GlobalActions } from '@mm-actions/global'; @@ -13,6 +13,7 @@ import { TranslateFromService } from '@mm-services/translate-from.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; +import { FormConfig } from '@mm-services/enketo.service'; @Component({ selector: 'training-cards-form', diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index 8a145a99979..7a01410246f 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -8,7 +8,7 @@ import { DbService } from '@mm-services/db.service'; import { FileReaderService } from '@mm-services/file-reader.service'; import { GetReportContentService } from '@mm-services/get-report-content.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; +import { XmlFormsService } from '@mm-services/xml-forms.service'; import { Selectors } from '@mm-selectors/index'; import { GeolocationService } from '@mm-services/geolocation.service'; import { GlobalActions } from '@mm-actions/global'; @@ -19,6 +19,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; +import { FormConfig } from '@mm-services/enketo.service'; @Component({ templateUrl: './reports-add.component.html', diff --git a/webapp/src/ts/modules/tasks/tasks-content.component.ts b/webapp/src/ts/modules/tasks/tasks-content.component.ts index 3e75d0d74c7..24893dd847a 100644 --- a/webapp/src/ts/modules/tasks/tasks-content.component.ts +++ b/webapp/src/ts/modules/tasks/tasks-content.component.ts @@ -6,7 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { FormService, WebappEnketoFormContext } from '@mm-services/form.service'; import { PerformanceService } from '@mm-services/performance.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; -import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; +import { XmlFormsService } from '@mm-services/xml-forms.service'; import { GlobalActions } from '@mm-actions/global'; import { TasksActions } from '@mm-actions/tasks'; import { Selectors } from '@mm-selectors/index'; @@ -22,6 +22,7 @@ import { TranslateFromPipe } from '@mm-pipes/translate-from.pipe'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { EnketoForm } from '@mm-services/NewEnketoService'; +import { FormConfig } from '@mm-services/enketo.service'; @Component({ templateUrl: './tasks-content.component.html', diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index 972ce66b94c..b45cc8d7eb6 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -7,8 +7,8 @@ import * as FileManager from '../../js/enketo/file-manager'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import { FormConfig } from '@mm-services/xml-forms.service'; import { isHardcodedType } from '@medic/contact-types-utils'; +import { FormConfig } from '@mm-services/enketo.service'; @Injectable({ providedIn: 'root' @@ -124,10 +124,7 @@ export class NewEnketoService { }; } - private initializeContactSibling( - rootContactDoc: Record, - rawSibling?: Record - ): Record | undefined { + private initializeContactSibling(rootContactDoc: Record, rawSibling?: Record) { if (!rawSibling) { return; } diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index e08c78d056c..76ed058869b 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -17,7 +17,6 @@ import { TranslateService } from '@mm-services/translate.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Qualifier, Report } from '@medic/cht-datasource'; import { DOC_TYPES } from '@medic/constants'; -import { FormConfig } from '@mm-services/xml-forms.service'; import { EnketoForm } from '@mm-services/NewEnketoService'; /** @@ -640,6 +639,8 @@ interface XmlFormContext { userContactSummary?: ContactSummary; } +export type FormType = 'contact' | 'report' | 'task' | 'training-card'; + export interface EnketoFormContext { readonly selector: string; readonly formConfig: FormConfig; @@ -651,3 +652,21 @@ export interface EnketoFormContext { readonly contactSummary?: ContactSummary; readonly userContactSummary?: ContactSummary; } + +export class FormConfig { + public readonly repeatPaths: string[]; + + constructor( + public readonly doc: Record, + public type: FormType, + xml: string, + public readonly html: string, + public readonly model: string + ) { + const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); + this.repeatPaths = Array + .from(xmlDoc.querySelectorAll('repeat[nodeset]')) + .map(el => el.getAttribute('nodeset')!) + .filter(Boolean); + } +} diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index f099e235252..cdc100d6354 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -10,7 +10,7 @@ import { FileReaderService } from '@mm-services/file-reader.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { SubmitFormBySmsService } from '@mm-services/submit-form-by-sms.service'; import { UserContactService } from '@mm-services/user-contact.service'; -import { FormConfig, XmlFormsService } from '@mm-services/xml-forms.service'; +import { XmlFormsService } from '@mm-services/xml-forms.service'; import { ZScoreService } from '@mm-services/z-score.service'; import { ServicesActions } from '@mm-actions/services'; import { ContactSummaryService } from '@mm-services/contact-summary.service'; @@ -20,7 +20,7 @@ import { TransitionsService } from '@mm-services/transitions.service'; import { GlobalActions } from '@mm-actions/global'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; -import { ContactSummary, EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; +import { ContactSummary, EnketoFormContext, EnketoService, FormConfig } from '@mm-services/enketo.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; import { ContactSaveService } from '@mm-services/contact-save.service'; import { reduce as _reduce } from 'lodash-es'; diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index 4dc5e909ce0..cbfd55f591f 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -11,6 +11,7 @@ import { UserContactService } from '@mm-services/user-contact.service'; import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; import { ParseProvider } from '@mm-providers/parse.provider'; import { UserContactSummaryService } from '@mm-services/user-contact-summary.service'; +import { FormConfig, FormType } from '@mm-services/enketo.service'; export const TRAINING_FORM_ID_PREFIX: string = `${PREFIXES.FORM}training:`; export const CONTACT_FORM_ID_PREFIX: string = `${PREFIXES.FORM}contact:`; @@ -410,22 +411,3 @@ export class XmlFormsService { } } -export type FormType = 'contact' | 'report' | 'task' | 'training-card'; - -export class FormConfig { - public readonly repeatPaths: string[]; - - constructor( - public readonly doc: Record, - public type: FormType, - xml: string, - public readonly html: string, - public readonly model: string - ){ - const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); - this.repeatPaths = Array - .from(xmlDoc.querySelectorAll('repeat[nodeset]')) - .map(el => el.getAttribute('nodeset')!) - .filter(Boolean); - } -} diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index 48a23e20f97..56d23e7bef9 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Inject, Input, Output } from '@angular/core'; -import { EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; +import { EnketoFormContext, EnketoService, FormConfig } from '@mm-services/enketo.service'; import * as medicXpathExtensions from '../../../src/js/enketo/medic-xpath-extensions'; import moment from 'moment'; import { toBik_text } from 'bikram-sambat'; @@ -9,7 +9,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { CHTDatasourceService as CHTDatasourceServiceStub } from './stubs/cht-datasource.service'; import { CONTACT_TYPES } from '@medic/constants'; -import { FormConfig } from '@mm-services/xml-forms.service'; import { EnketoForm, NewEnketoService } from '@mm-services/NewEnketoService'; const DEFAULT_FORM_ID = 'cht-form-id'; From bf5286eecd0da2aa906f273b9d4bab68149faa1c Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Thu, 16 Jul 2026 22:03:25 -0500 Subject: [PATCH 18/41] Final clean up for NewEnketoService --- webapp/src/ts/services/NewEnketoService.ts | 274 ++++++++++----------- 1 file changed, 127 insertions(+), 147 deletions(-) diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts index b45cc8d7eb6..d264089e34b 100644 --- a/webapp/src/ts/services/NewEnketoService.ts +++ b/webapp/src/ts/services/NewEnketoService.ts @@ -14,7 +14,8 @@ import { FormConfig } from '@mm-services/enketo.service'; providedIn: 'root' }) export class NewEnketoService { - private readonly USER_FILE_ATTACHMENT_PREFIX = 'user-file-'; + private readonly USER_BINARY_ATTACHMENT_PREFIX = 'user-file'; + private readonly USER_FILE_ATTACHMENT_PREFIX = `${this.USER_BINARY_ATTACHMENT_PREFIX}-`; private readonly getContactFromDatasource: ReturnType; constructor( @@ -25,31 +26,28 @@ export class NewEnketoService { this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); } - public async saveContact( - { config, form }: EnketoForm, - defaultData: Record - ) { + public async saveContact({ config, form }: EnketoForm, defaultData: Record) { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); const contactDoc = this.initializeDoc(defaultData); - const formDocData = new EnektoContactRootDoc( + const formData = new EnektoContactData( this.getFormDataXml(form), contactDoc._id, isHardcodedType(contactDoc.type) ? contactDoc.type : contactDoc.contact_type ); - const formAttachments = this.processFormAttachments(config.doc.internalId, formDocData, contactDoc._attachments); + const formAttachments = this.processFormAttachments(config.doc.internalId, formData, contactDoc._attachments); const rootOutputDoc: Record = { ...contactDoc, - ...formDocData.deserializeDoc(config), + ...formData.deserializeDoc(config), type: contactDoc.type, contact_type: contactDoc.contact_type, _attachments: formAttachments }; - const siblings = EnektoContactRootDoc.SIBLING_FIELD_NAMES - .map(fieldName => ({ fieldName, doc: formDocData.getSiblingDoc(fieldName)?.deserializeDoc(config) })) + const siblings = EnektoContactData.SIBLING_FIELD_NAMES + .map(fieldName => ({ fieldName, doc: formData.getSiblingData(fieldName)?.deserializeDoc(config) })) .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); await Promise.all(siblings.map(async ({ fieldName, doc }) => { rootOutputDoc[fieldName] = await this.getContactSiblingValue( @@ -61,44 +59,43 @@ export class NewEnketoService { .map(({ doc }) => doc) .filter(doc => !!doc); - const childDocs = formDocData - .getChildDocs() + const childDocs = formData + .getChildData() .map(doc => this.initializeDoc(doc.deserializeDoc(config))) .map(doc => ({ ...doc, parent: rootOutputDoc })); return { docId: rootOutputDoc._id, - preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs] - .map(doc => this.dehydrateContactLineage(doc)) + preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs].map(doc => this.dehydrateContactLineage(doc)) }; }); } - public async saveReport( - { config, form }: EnketoForm, - defaultData: Record - ) { + public async saveReport({ config, form }: EnketoForm, defaultData: Record) { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); const { internalId, xmlVersion } = config.doc; const reportDoc: Record = this.initializeReportDoc(internalId, xmlVersion, defaultData); - const formDocData = new EnketoReportRootDoc(this.getFormDataXml(form), reportDoc._id); - const subDocs = formDocData.getDbDocs(); + const formData = new EnketoReportData(this.getFormDataXml(form), reportDoc._id); + const subDocsData = formData.getDbDocData(); - this.populateDbDocRefElements(formDocData, [formDocData, ...subDocs]); - const attachments = this.processFormAttachments(config.doc.internalId, formDocData, reportDoc._attachments); + this.populateDbDocRefElements(formData, [formData, ...subDocsData]); + const attachments = this.processFormAttachments(config.doc.internalId, formData, reportDoc._attachments); + const hiddenFields = this.getHiddenFields([ + ...formData.hiddenElements, + ...subDocsData.map(({ rootElement }) => rootElement) + ]); const rootOutputDoc: Record = { ...reportDoc, - hidden_fields: this.getHiddenFields([ - ...formDocData.hiddenElements, - ...subDocs.map(({ rootElement }) => rootElement) - ]), - fields: formDocData.deserialize(config), + hidden_fields: hiddenFields, + fields: formData.deserialize(config), _attachments: attachments }; - const dbDocObjects = subDocs.map(docData => this.initializeDoc(docData.deserializeDoc(config))); + const dbDocObjects = subDocsData + .map(docData => docData.deserializeDoc(config)) + .map(doc => this.initializeDoc(doc)); return [rootOutputDoc, ...dbDocObjects]; }); } @@ -116,14 +113,6 @@ export class NewEnketoService { return new DOMParser().parseFromString(formString, 'text/xml'); } - private dehydrateContactLineage(contactDoc: Record) { - return { - ...contactDoc, - parent: this.extractLineageService.extract(contactDoc.parent), - contact: this.extractLineageService.extract(contactDoc.contact) - }; - } - private initializeContactSibling(rootContactDoc: Record, rawSibling?: Record) { if (!rawSibling) { return; @@ -145,15 +134,24 @@ export class NewEnketoService { } if (currentValue._id === 'NEW') { return sibling; - } else if (currentValue?._id === defaultValue?._id) { + } + if (currentValue._id === defaultValue?._id) { return defaultValue; } - return await this.getContactFromDatasource(Qualifier.byUuid(currentValue?._id)); + return await this.getContactFromDatasource(Qualifier.byUuid(currentValue._id)); + } + + private dehydrateContactLineage(contactDoc: Record) { + return { + ...contactDoc, + parent: this.extractLineageService.extract(contactDoc.parent), + contact: this.extractLineageService.extract(contactDoc.contact) + }; } private getHiddenFields(elements: Element[]) { - const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementRawXPath(element))); + const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementRawXPath(element))); const hasHiddenAncestor = ( segments: string[] ) => (_: string, i: number) => i > 0 && hiddenXpaths.has(segments.slice(0, i).join('/')); @@ -186,54 +184,55 @@ export class NewEnketoService { }; } - private populateDbDocRefElements(rootDoc: EnketoReportRootDoc, allDocs: EnketoDoc[]) { - rootDoc.dbDocRefElements.forEach(element => { + private populateDbDocRefElements(formData: EnketoReportData, allData: EnketoData[]) { + formData.dbDocRefElements.forEach(element => { const reference = element.getAttribute('db-doc-ref'); - const referencedNode = rootDoc.getNodeByXpath(element, reference); + const referencedNode = formData.getNodeByXpath(element, reference); if (!referencedNode) { return; } - const refDoc = allDocs.find(({ rootElement }) => rootElement === referencedNode); + const refDoc = allData.find(({ rootElement }) => rootElement === referencedNode); if (refDoc) { element.textContent = refDoc.id; } }); } + private buildBinaryAttachmentData(form: string, originalAttachments: Record, element: Element) { + const xpath = Xpath.getElementTreeXPath(element); + const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`); + const filename = `${this.USER_BINARY_ATTACHMENT_PREFIX}${formXpath}`; + const data = element.textContent; + element.textContent = ''; + return { + filename, + // Currently do not support loading binary attachment data into edit form. So, keep existing value. + attachment: data ? { data, content_type: 'image/png' } : originalAttachments[filename] + }; + } + private processFormAttachments( form: string, - rootDocData: EnketoRootDoc, + rootData: EnketoRootData, originalAttachments: Record = {} ) { - const binaryAttachments = rootDocData.binaryTypeElements - .map(element => { - const xpath = Xpath.getElementTreeXPath(element); - const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`); - const filename = `user-file${formXpath}`; - const data = element.textContent; - element.textContent = ''; - return { - filename, - // Currently do not support loading binary attachment data into edit form. So, keep existing value. - attachment: data ? { data, content_type: 'image/png' } : originalAttachments[filename] - }; - }) + const binaryAttachments = rootData.binaryTypeElements + .map(element => this.buildBinaryAttachmentData(form, originalAttachments, element)) .filter(({ attachment }) => attachment) .reduce((acc, { filename, attachment }) => ({ ...acc, [filename]: attachment }), {}); const newFileAttachments = FileManager .getCurrentFiles() - .reduce((acc, file) => ({ - ...acc, - [`${this.USER_FILE_ATTACHMENT_PREFIX}${file.name}`]: { - content_type: file.type, - data: new Blob([ file ], { type: file.type }) - } - }), {}); + .map(file => ({ + name: `${this.USER_FILE_ATTACHMENT_PREFIX}${file.name}`, + content_type: file.type, + data: new Blob([ file ], { type: file.type }) + })) + .reduce((acc, { name, content_type, data }) => ({ ...acc, [name]: { content_type, data } }), {}); const existingFileAttachments = Object .entries(originalAttachments) .filter(([key]) => key.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)) // Keep existing file attachments still referenced by a field - .filter(([key]) => rootDocData.findNodeWithTextContent(key.slice(this.USER_FILE_ATTACHMENT_PREFIX.length))) + .filter(([key]) => rootData.findNodeWithTextContent(key.slice(this.USER_FILE_ATTACHMENT_PREFIX.length))) .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); const attachments = { @@ -250,8 +249,6 @@ export interface EnketoForm { config: FormConfig } -// Thrown when the form fails Enketo validation. Enketo displays the per-field validation messages itself, -// so callers should handle this silently (unlike other save errors). export class FormValidationError extends Error { constructor(message = 'Form is invalid') { super(message); @@ -259,12 +256,11 @@ export class FormValidationError extends Error { } } -class EnketoDoc { +class EnketoData { constructor( public readonly rootElement: Element, public readonly id: string, - ) { - } + ) {} public deserialize(formConfig: FormConfig): Record { return this.nodesToJs( @@ -292,30 +288,24 @@ class EnketoDoc { .filter(this.isElementNode); } - // TODO Any methods not using actual state could maybe be moved to helper. protected nodesToJs(nodes: Element[], repeatPaths: string[], path: string) { return nodes - .reduce>((result, node) => { - const nodePath = `${path}/${node.nodeName}`; - const value = this.getJsValueForNode(node, repeatPaths, nodePath); + .map(node => ({ node, nodePath: `${path}/${node.nodeName}`})) + .map(({ node, nodePath }) => ({ node, nodePath, value: this.getJsValueForNode(node, repeatPaths, nodePath) })) + .reduce((acc, { node, nodePath, value }) => { if (repeatPaths.includes(nodePath)) { - result[node.nodeName] ??= []; - result[node.nodeName].push(value); + acc[node.nodeName] ??= []; + acc[node.nodeName].push(value); } else { - result[node.nodeName] = value; + acc[node.nodeName] = value; } - return result; + return acc; }, {}); } private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) { - const elements = Array - .from(node.childNodes) - .filter(this.isElementNode); - if (elements.length) { - return this.nodesToJs(elements, repeatPaths, nodePath); - } - return node.textContent; + const elements = this.getChildElements(node); + return elements.length ? this.nodesToJs(elements, repeatPaths, nodePath) : node.textContent; } protected findChildNode(element: Element, tagName: string) { @@ -329,11 +319,11 @@ class EnketoDoc { } } -class EnketoRootDoc extends EnketoDoc { +abstract class EnketoRootData extends EnketoData { public readonly binaryTypeElements: Element[]; - constructor( - protected readonly xmlDoc: XMLDocument, + protected constructor( + private readonly xmlDoc: XMLDocument, id: string, ) { super(xmlDoc.documentElement, id); @@ -350,18 +340,51 @@ class EnketoRootDoc extends EnketoDoc { ); return result.singleNodeValue; } + + public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { + const xpath = rawXpath?.trim(); + if (!xpath) { + return null; + } + const xpathSegments = xpath + .trim() + .split('/') + .filter(Boolean); + const contextLineage = this.getNodeWithLineage(contextNode); + + // Number of leading segments the target path shares with the context node's lineage. + const firstDivergence = xpathSegments + .findIndex((segment, i) => segment !== contextLineage[i]?.nodeName); + const commonAncestorIndex = firstDivergence === -1 ? xpathSegments.length : firstDivergence; + + // Fall back to contextNode in case of relative xpath + const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; + const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; + const result = this.xmlDoc.evaluate( + relativePath, + anchor, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); + return result.singleNodeValue; + } + + private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { + if (!this.isElementNode(contextNode)) { + return lineage; + } + lineage.unshift(contextNode); + return this.getNodeWithLineage(contextNode.parentNode, lineage); + } } -class EnektoContactRootDoc extends EnketoRootDoc { +class EnektoContactData extends EnketoRootData { public static readonly SIBLING_FIELD_NAMES = ['parent', 'contact'] as const; private readonly childElements: Element[]; private readonly rootContactElement: Element; - constructor( - xmlDoc: XMLDocument, - id: string, - type: string, - ) { + constructor(xmlDoc: XMLDocument, id: string, type: string) { super(xmlDoc, id); this.childElements = Array.from(this.rootElement.querySelectorAll(':scope > repeat > child')); const elementForType = this.findChildNode(this.rootElement, type); @@ -377,7 +400,7 @@ class EnektoContactRootDoc extends EnketoRootDoc { } public deserializeDoc(formConfig: FormConfig): Record { - const rootDoc = new EnketoDoc(this.rootContactElement, this.id).deserializeDoc(formConfig); + const rootDoc = new EnketoData(this.rootContactElement, this.id).deserializeDoc(formConfig); const liftIdValue = (idValue: unknown) => typeof idValue === 'string' ? { _id: idValue } : idValue; return { ...rootDoc, @@ -386,77 +409,34 @@ class EnektoContactRootDoc extends EnketoRootDoc { }; } - public getChildDocs() { - return this.childElements.map(dbDoc => new EnketoDoc( - dbDoc, - this.getDocId(dbDoc), - )); + public getChildData() { + return this.childElements.map(dbDoc => new EnketoData(dbDoc, this.getDocId(dbDoc))); } - public getSiblingDoc(fieldName: typeof EnektoContactRootDoc.SIBLING_FIELD_NAMES[number]) { + public getSiblingData(fieldName: typeof EnektoContactData.SIBLING_FIELD_NAMES[number]) { const element = this.findChildNode(this.rootElement, fieldName); - return element ? new EnketoDoc(element, this.getDocId(element)) : null; + return element ? new EnketoData(element, this.getDocId(element)) : null; } } -class EnketoReportRootDoc extends EnketoRootDoc { +class EnketoReportData extends EnketoRootData { private readonly dbDocElements: Element[]; public readonly hiddenElements: Element[]; public readonly dbDocRefElements: Element[]; - constructor( - xmlDoc: XMLDocument, - id: string, - ) { + constructor(xmlDoc: XMLDocument, id: string) { super(xmlDoc, id); this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]')); this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]')); this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]')); } - public getDbDocs() { - return this.dbDocElements.map(dbDoc => new EnketoDoc( + public getDbDocData() { + return this.dbDocElements.map(dbDoc => new EnketoData( dbDoc, - this.getDocId(dbDoc), + this.getDocId(dbDoc) )); } - - public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { - const xpath = rawXpath?.trim(); - if (!xpath) { - return null; - } - const xpathSegments = xpath - .trim() - .split('/') - .filter(Boolean); - const contextLineage = this.getNodeWithLineage(contextNode); - - // Number of leading segments the target path shares with the context node's lineage. - const firstDivergence = xpathSegments - .findIndex((segment, i) => segment !== contextLineage[i]?.nodeName); - const commonAncestorIndex = firstDivergence === -1 ? xpathSegments.length : firstDivergence; - - // Fall back to contextNode in case of relative xpath - const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; - const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; - const result = this.xmlDoc.evaluate( - relativePath, - anchor, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null - ); - return result.singleNodeValue; - } - - private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { - if (!this.isElementNode(contextNode)) { - return lineage; - } - lineage.unshift(contextNode); - return this.getNodeWithLineage(contextNode.parentNode, lineage); - } } From ec882d7030e1c617b7c485ef696e77fce52d31df Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 08:53:00 -0500 Subject: [PATCH 19/41] Slot everything into place and remove old code. --- .../training-cards-form.component.ts | 2 +- .../contacts/contacts-edit.component.ts | 2 +- .../contacts/contacts-report.component.ts | 2 +- .../modules/reports/reports-add.component.ts | 2 +- .../modules/tasks/tasks-content.component.ts | 4 +- webapp/src/ts/services/NewEnketoService.ts | 442 ----------------- webapp/src/ts/services/attachment.service.ts | 43 -- .../src/ts/services/contact-save.service.ts | 343 -------------- .../enketo-prepopulation-data.service.ts | 58 ++- .../ts/services/enketo-translation.service.ts | 199 -------- webapp/src/ts/services/enketo.service.ts | 443 +++++++++--------- webapp/src/ts/services/form.service.ts | 17 +- webapp/src/ts/services/form/form-config.ts | 20 + webapp/src/ts/services/form/form-data.ts | 186 ++++++++ webapp/src/ts/services/xml-forms.service.ts | 2 +- .../cht-form/src/app.component.ts | 9 +- 16 files changed, 499 insertions(+), 1275 deletions(-) delete mode 100644 webapp/src/ts/services/NewEnketoService.ts delete mode 100644 webapp/src/ts/services/attachment.service.ts delete mode 100644 webapp/src/ts/services/contact-save.service.ts delete mode 100644 webapp/src/ts/services/enketo-translation.service.ts create mode 100644 webapp/src/ts/services/form/form-config.ts create mode 100644 webapp/src/ts/services/form/form-data.ts diff --git a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts index e3dc9d9abb1..a6ef7757e60 100644 --- a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts +++ b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts @@ -13,7 +13,7 @@ import { TranslateFromService } from '@mm-services/translate-from.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; -import { FormConfig } from '@mm-services/enketo.service'; +import { FormConfig } from '@mm-services/form/form-config'; @Component({ selector: 'training-cards-form', diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 59a2618461e..2c960c82d51 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -6,7 +6,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { DuplicatesFoundError, FormService, WebappEnketoFormContext } from '@mm-services/form.service'; -import { FormValidationError } from '@mm-services/NewEnketoService'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; import { Selectors } from '@mm-selectors/index'; @@ -23,6 +22,7 @@ import { Contact, Qualifier } from '@medic/cht-datasource'; import { TelemetryService } from '@mm-services/telemetry.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormValidationError } from '@mm-services/enketo.service'; @Component({ templateUrl: './contacts-edit.component.html', diff --git a/webapp/src/ts/modules/contacts/contacts-report.component.ts b/webapp/src/ts/modules/contacts/contacts-report.component.ts index f9c1cfdd872..fc897739770 100644 --- a/webapp/src/ts/modules/contacts/contacts-report.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-report.component.ts @@ -16,7 +16,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; -import { EnketoForm } from '@mm-services/NewEnketoService'; +import { EnketoForm } from '@mm-services/enketo.service'; @Component({ templateUrl: './contacts-report.component.html', diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index 7a01410246f..e128d9ed751 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -19,7 +19,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; -import { FormConfig } from '@mm-services/enketo.service'; +import { FormConfig } from '@mm-services/form/form-config'; @Component({ templateUrl: './reports-add.component.html', diff --git a/webapp/src/ts/modules/tasks/tasks-content.component.ts b/webapp/src/ts/modules/tasks/tasks-content.component.ts index 24893dd847a..068caa88ebb 100644 --- a/webapp/src/ts/modules/tasks/tasks-content.component.ts +++ b/webapp/src/ts/modules/tasks/tasks-content.component.ts @@ -21,8 +21,8 @@ import { SimpleDatePipe } from '@mm-pipes/date.pipe'; import { TranslateFromPipe } from '@mm-pipes/translate-from.pipe'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; -import { EnketoForm } from '@mm-services/NewEnketoService'; -import { FormConfig } from '@mm-services/enketo.service'; +import { FormConfig } from '@mm-services/form/form-config'; +import { EnketoForm } from '@mm-services/enketo.service'; @Component({ templateUrl: './tasks-content.component.html', diff --git a/webapp/src/ts/services/NewEnketoService.ts b/webapp/src/ts/services/NewEnketoService.ts deleted file mode 100644 index d264089e34b..00000000000 --- a/webapp/src/ts/services/NewEnketoService.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { Injectable, NgZone } from '@angular/core'; -import events from 'enketo-core/src/js/event'; -import { DOC_TYPES } from '@medic/constants'; -import { v7 as uuid } from 'uuid'; -import { Xpath } from '@mm-providers/xpath-element-path.provider'; -import * as FileManager from '../../js/enketo/file-manager'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { Contact, Qualifier } from '@medic/cht-datasource'; -import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import { isHardcodedType } from '@medic/contact-types-utils'; -import { FormConfig } from '@mm-services/enketo.service'; - -@Injectable({ - providedIn: 'root' -}) -export class NewEnketoService { - private readonly USER_BINARY_ATTACHMENT_PREFIX = 'user-file'; - private readonly USER_FILE_ATTACHMENT_PREFIX = `${this.USER_BINARY_ATTACHMENT_PREFIX}-`; - private readonly getContactFromDatasource: ReturnType; - - constructor( - private readonly extractLineageService:ExtractLineageService, - private readonly ngZone: NgZone, - chtDatasourceService: CHTDatasourceService, - ) { - this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); - } - - public async saveContact({ config, form }: EnketoForm, defaultData: Record) { - return this.ngZone.runOutsideAngular(async () => { - await this.validate(form); - const contactDoc = this.initializeDoc(defaultData); - const formData = new EnektoContactData( - this.getFormDataXml(form), - contactDoc._id, - isHardcodedType(contactDoc.type) ? contactDoc.type : contactDoc.contact_type - ); - - const formAttachments = this.processFormAttachments(config.doc.internalId, formData, contactDoc._attachments); - - const rootOutputDoc: Record = { - ...contactDoc, - ...formData.deserializeDoc(config), - type: contactDoc.type, - contact_type: contactDoc.contact_type, - _attachments: formAttachments - }; - - const siblings = EnektoContactData.SIBLING_FIELD_NAMES - .map(fieldName => ({ fieldName, doc: formData.getSiblingData(fieldName)?.deserializeDoc(config) })) - .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); - await Promise.all(siblings.map(async ({ fieldName, doc }) => { - rootOutputDoc[fieldName] = await this.getContactSiblingValue( - doc, rootOutputDoc[fieldName], defaultData[fieldName] - ); - })); - const outputSiblings = siblings - .filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName] === doc) - .map(({ doc }) => doc) - .filter(doc => !!doc); - - const childDocs = formData - .getChildData() - .map(doc => this.initializeDoc(doc.deserializeDoc(config))) - .map(doc => ({ ...doc, parent: rootOutputDoc })); - - return { - docId: rootOutputDoc._id, - preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs].map(doc => this.dehydrateContactLineage(doc)) - }; - }); - } - - public async saveReport({ config, form }: EnketoForm, defaultData: Record) { - return this.ngZone.runOutsideAngular(async () => { - await this.validate(form); - const { internalId, xmlVersion } = config.doc; - const reportDoc: Record = this.initializeReportDoc(internalId, xmlVersion, defaultData); - const formData = new EnketoReportData(this.getFormDataXml(form), reportDoc._id); - const subDocsData = formData.getDbDocData(); - - this.populateDbDocRefElements(formData, [formData, ...subDocsData]); - const attachments = this.processFormAttachments(config.doc.internalId, formData, reportDoc._attachments); - const hiddenFields = this.getHiddenFields([ - ...formData.hiddenElements, - ...subDocsData.map(({ rootElement }) => rootElement) - ]); - - const rootOutputDoc: Record = { - ...reportDoc, - hidden_fields: hiddenFields, - fields: formData.deserialize(config), - _attachments: attachments - }; - - const dbDocObjects = subDocsData - .map(docData => docData.deserializeDoc(config)) - .map(doc => this.initializeDoc(doc)); - return [rootOutputDoc, ...dbDocObjects]; - }); - } - - private async validate(form: Record) { - const valid = await form.validate(); - if (!valid) { - throw new FormValidationError(); - } - form.view.html.dispatchEvent(events.BeforeSave()); - } - - private getFormDataXml(form: Record) { - const formString = form.getDataStr({ irrelevant: false }); - return new DOMParser().parseFromString(formString, 'text/xml'); - } - - private initializeContactSibling(rootContactDoc: Record, rawSibling?: Record) { - if (!rawSibling) { - return; - } - return { - type: 'person', // legacy support - form data should override this - ...this.initializeDoc(rawSibling), - parent: rawSibling.parent === 'PARENT' ? { ...rootContactDoc } : rawSibling.parent - }; - } - - private async getContactSiblingValue( - sibling?: Record, - currentValue?: Record, - defaultValue?: Record - ) { - if (!currentValue?._id) { - return undefined; - } - if (currentValue._id === 'NEW') { - return sibling; - } - if (currentValue._id === defaultValue?._id) { - return defaultValue; - } - - return await this.getContactFromDatasource(Qualifier.byUuid(currentValue._id)); - } - - private dehydrateContactLineage(contactDoc: Record) { - return { - ...contactDoc, - parent: this.extractLineageService.extract(contactDoc.parent), - contact: this.extractLineageService.extract(contactDoc.contact) - }; - } - - private getHiddenFields(elements: Element[]) { - const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementRawXPath(element))); - const hasHiddenAncestor = ( - segments: string[] - ) => (_: string, i: number) => i > 0 && hiddenXpaths.has(segments.slice(0, i).join('/')); - return [...hiddenXpaths] - .map(xpath => xpath.split('/')) - .filter(segments => !segments.some(hasHiddenAncestor(segments))) - .map(segments => segments - .filter(Boolean) - .slice(1) - .join('.')); - } - - private initializeDoc(defaultData: Record): Record { - return { - _id: defaultData._id || uuid(), - reported_date: Date.now(), - ...defaultData - }; - } - - private initializeReportDoc(form: string, formVersion: string, defaultData: Record) { - return { - form, - type: DOC_TYPES.DATA_RECORD, - content_type: 'xml', - from: defaultData.contact?.phone, - ...this.initializeDoc(defaultData), - contact: this.extractLineageService.extract(defaultData.contact), - form_version: formVersion - }; - } - - private populateDbDocRefElements(formData: EnketoReportData, allData: EnketoData[]) { - formData.dbDocRefElements.forEach(element => { - const reference = element.getAttribute('db-doc-ref'); - const referencedNode = formData.getNodeByXpath(element, reference); - if (!referencedNode) { - return; - } - const refDoc = allData.find(({ rootElement }) => rootElement === referencedNode); - if (refDoc) { - element.textContent = refDoc.id; - } - }); - } - - private buildBinaryAttachmentData(form: string, originalAttachments: Record, element: Element) { - const xpath = Xpath.getElementTreeXPath(element); - const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`); - const filename = `${this.USER_BINARY_ATTACHMENT_PREFIX}${formXpath}`; - const data = element.textContent; - element.textContent = ''; - return { - filename, - // Currently do not support loading binary attachment data into edit form. So, keep existing value. - attachment: data ? { data, content_type: 'image/png' } : originalAttachments[filename] - }; - } - - private processFormAttachments( - form: string, - rootData: EnketoRootData, - originalAttachments: Record = {} - ) { - const binaryAttachments = rootData.binaryTypeElements - .map(element => this.buildBinaryAttachmentData(form, originalAttachments, element)) - .filter(({ attachment }) => attachment) - .reduce((acc, { filename, attachment }) => ({ ...acc, [filename]: attachment }), {}); - const newFileAttachments = FileManager - .getCurrentFiles() - .map(file => ({ - name: `${this.USER_FILE_ATTACHMENT_PREFIX}${file.name}`, - content_type: file.type, - data: new Blob([ file ], { type: file.type }) - })) - .reduce((acc, { name, content_type, data }) => ({ ...acc, [name]: { content_type, data } }), {}); - const existingFileAttachments = Object - .entries(originalAttachments) - .filter(([key]) => key.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)) - // Keep existing file attachments still referenced by a field - .filter(([key]) => rootData.findNodeWithTextContent(key.slice(this.USER_FILE_ATTACHMENT_PREFIX.length))) - .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); - - const attachments = { - ...existingFileAttachments, - ...newFileAttachments, - ...binaryAttachments - }; - return Object.keys(attachments).length ? attachments : undefined; - } -} - -export interface EnketoForm { - form: Record - config: FormConfig -} - -export class FormValidationError extends Error { - constructor(message = 'Form is invalid') { - super(message); - this.name = 'FormValidationError'; - } -} - -class EnketoData { - constructor( - public readonly rootElement: Element, - public readonly id: string, - ) {} - - public deserialize(formConfig: FormConfig): Record { - return this.nodesToJs( - this.getChildElements(this.rootElement), - formConfig.repeatPaths, - Xpath.getElementRawXPath(this.rootElement) - ); - } - - public deserializeDoc(formConfig: FormConfig): Record { - return { - _id: this.id, - form_version: formConfig.doc.xmlVersion, - ...this.deserialize(formConfig) - }; - } - - protected isElementNode(node: unknown): node is Element { - return node?.['nodeType'] === Node.ELEMENT_NODE; - } - - protected getChildElements(node: Node) { - return Array - .from(node.childNodes) - .filter(this.isElementNode); - } - - protected nodesToJs(nodes: Element[], repeatPaths: string[], path: string) { - return nodes - .map(node => ({ node, nodePath: `${path}/${node.nodeName}`})) - .map(({ node, nodePath }) => ({ node, nodePath, value: this.getJsValueForNode(node, repeatPaths, nodePath) })) - .reduce((acc, { node, nodePath, value }) => { - if (repeatPaths.includes(nodePath)) { - acc[node.nodeName] ??= []; - acc[node.nodeName].push(value); - } else { - acc[node.nodeName] = value; - } - return acc; - }, {}); - } - - private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) { - const elements = this.getChildElements(node); - return elements.length ? this.nodesToJs(elements, repeatPaths, nodePath) : node.textContent; - } - - protected findChildNode(element: Element, tagName: string) { - return Array - .from(element.children) - .find(child => child.tagName === tagName); - } - - protected getDocId(element: Element) { - return this.findChildNode(element, '_id')?.textContent || uuid(); - } -} - -abstract class EnketoRootData extends EnketoData { - public readonly binaryTypeElements: Element[]; - - protected constructor( - private readonly xmlDoc: XMLDocument, - id: string, - ) { - super(xmlDoc.documentElement, id); - this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); - } - - public findNodeWithTextContent(textContent: string) { - const result = this.xmlDoc.evaluate( - `.//*[text()=${JSON.stringify(textContent)}]`, - this.rootElement, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null - ); - return result.singleNodeValue; - } - - public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { - const xpath = rawXpath?.trim(); - if (!xpath) { - return null; - } - const xpathSegments = xpath - .trim() - .split('/') - .filter(Boolean); - const contextLineage = this.getNodeWithLineage(contextNode); - - // Number of leading segments the target path shares with the context node's lineage. - const firstDivergence = xpathSegments - .findIndex((segment, i) => segment !== contextLineage[i]?.nodeName); - const commonAncestorIndex = firstDivergence === -1 ? xpathSegments.length : firstDivergence; - - // Fall back to contextNode in case of relative xpath - const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; - const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; - const result = this.xmlDoc.evaluate( - relativePath, - anchor, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null - ); - return result.singleNodeValue; - } - - private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { - if (!this.isElementNode(contextNode)) { - return lineage; - } - lineage.unshift(contextNode); - return this.getNodeWithLineage(contextNode.parentNode, lineage); - } -} - -class EnektoContactData extends EnketoRootData { - public static readonly SIBLING_FIELD_NAMES = ['parent', 'contact'] as const; - private readonly childElements: Element[]; - private readonly rootContactElement: Element; - - constructor(xmlDoc: XMLDocument, id: string, type: string) { - super(xmlDoc, id); - this.childElements = Array.from(this.rootElement.querySelectorAll(':scope > repeat > child')); - const elementForType = this.findChildNode(this.rootElement, type); - if (!elementForType) { - // Fail loudly here because previous save logic was very "flexible" around the naming of this group. However, the - // contact form documentation and the prepopulation logic when rendering the form both clearly intend for the - // contact's data to be loaded from the group with the name of the contact type. - throw new Error( - `Failed to save contact form because the data for the contact is not contained in the ${type} group.` - ); - } - this.rootContactElement = elementForType; - } - - public deserializeDoc(formConfig: FormConfig): Record { - const rootDoc = new EnketoData(this.rootContactElement, this.id).deserializeDoc(formConfig); - const liftIdValue = (idValue: unknown) => typeof idValue === 'string' ? { _id: idValue } : idValue; - return { - ...rootDoc, - parent: liftIdValue(rootDoc.parent), - contact: liftIdValue(rootDoc.contact) - }; - } - - public getChildData() { - return this.childElements.map(dbDoc => new EnketoData(dbDoc, this.getDocId(dbDoc))); - } - - public getSiblingData(fieldName: typeof EnektoContactData.SIBLING_FIELD_NAMES[number]) { - const element = this.findChildNode(this.rootElement, fieldName); - return element ? new EnketoData(element, this.getDocId(element)) : null; - } -} - -class EnketoReportData extends EnketoRootData { - private readonly dbDocElements: Element[]; - public readonly hiddenElements: Element[]; - public readonly dbDocRefElements: Element[]; - - constructor(xmlDoc: XMLDocument, id: string) { - super(xmlDoc, id); - this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]')); - this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]')); - this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]')); - } - - public getDbDocData() { - return this.dbDocElements.map(dbDoc => new EnketoData( - dbDoc, - this.getDocId(dbDoc) - )); - } -} - - diff --git a/webapp/src/ts/services/attachment.service.ts b/webapp/src/ts/services/attachment.service.ts deleted file mode 100644 index 46f346418e5..00000000000 --- a/webapp/src/ts/services/attachment.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class AttachmentService { - /** - * @ngdoc service - * @name AttachmentService - * @description Creates and attaches data to a couchdb doc - * @memberof inboxServices - * @param {Object} doc The doc to add the attachment to - * @param {string} name The name of the attachment - * @param {string} content The data to include in the attachment - * @param {string} contentType The mime type of data, eg: 'application/xml' - * @param {boolean} alreadyEncoded (default false) If true, assumes - * the content is already base64 encoded and doesn't do it again - */ - add(doc, name, content, contentType, alreadyEncoded?) { - if (!doc) { - return; - } - - if (!doc._attachments) { - doc._attachments = {}; - } - if (!alreadyEncoded) { - content = new Blob([ content ], { type: contentType }); - } - doc._attachments[name] = { - data: content, - content_type: contentType - }; - } - - remove(doc, name) { - if (!doc || !doc._attachments || !name) { - return; - } - - delete doc._attachments[name]; - } -} diff --git a/webapp/src/ts/services/contact-save.service.ts b/webapp/src/ts/services/contact-save.service.ts deleted file mode 100644 index 8d72dabd892..00000000000 --- a/webapp/src/ts/services/contact-save.service.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { v7 as uuid } from 'uuid'; -import { Injectable, NgZone } from '@angular/core'; -import { defaults as _defaults, isObject as _isObject } from 'lodash-es'; -import * as objectPath from 'object-path'; - -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { AttachmentService } from '@mm-services/attachment.service'; -import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import { Contact, Qualifier } from '@medic/cht-datasource'; -import { Xpath } from '@mm-providers/xpath-element-path.provider'; -import FileManager from '../../js/enketo/file-manager'; - -@Injectable({ - providedIn: 'root' -}) -export class ContactSaveService { - private readonly CONTACT_FIELD_NAMES = [ 'parent', 'contact' ]; - private readonly USER_FILE_ATTACHMENT_PREFIX = 'user-file-'; - private readonly getContactFromDatasource: ReturnType; - - constructor( - private enketoTranslationService:EnketoTranslationService, - private extractLineageService:ExtractLineageService, - private readonly attachmentService:AttachmentService, - private ngZone:NgZone, - chtDatasourceService: CHTDatasourceService, - ) { - this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); - } - - private prepareSubmittedDocsForSave(original, submitted, typeFields, xmlStr: string) { - if (original) { - _defaults(submitted.doc, original); - } else { - Object.assign(submitted.doc, typeFields); - } - - const doc = this.prepare(submitted.doc); - - return this - .prepareAndAttachSiblingDocs(submitted.doc, original, submitted.siblings) - .then((siblings) => { - const extract = item => { - item.parent = item.parent && this.extractLineageService.extract(item.parent); - item.contact = item.contact && this.extractLineageService.extract(item.contact); - }; - - siblings.forEach(extract); - extract(doc); - - // This must be done after prepareAndAttachSiblingDocs, as it relies - // on the doc's parents being attached. - const repeated = this.prepareRepeatedDocs(submitted.doc, submitted.repeats); - - // Process attachments for all documents - const preparedDocs = [ doc ].concat(repeated, siblings); // NB: order matters: #4200 - this.processAllAttachments(preparedDocs, xmlStr); - - return { - docId: doc._id, - preparedDocs: preparedDocs - }; - }); - } - - // Prepares document to be bulk-saved at a later time, and for it to be - // referenced by _id by other docs if required. - private prepare(doc): Record { - if (!doc._id) { - doc._id = uuid(); - } - - if (!doc._rev) { - doc.reported_date = Date.now(); - } - - return doc; - } - - private prepareRepeatedDocs(doc, repeated) { - const childData = repeated?.child_data || []; - return childData.map(child => { - child.parent = this.extractLineageService.extract(doc); - return this.prepare(child); - }); - } - - /** - * Sanitizes a file name by removing special characters. - * Only allows letters (a-z, A-Z), numbers (0-9), underscores (_), dashes (-), and dots (.). - * All other characters are removed. - * - * If sanitizing removes all characters from the file stem (e.g. a file name written entirely - * in a non-Latin script like Devanagari), a UUID is used as the stem to ensure a valid file name. - * - * @param fileName - The file name to sanitize - * @returns The sanitized file name with special characters removed, or a UUID-based name if - * the stem becomes empty after sanitization - */ - private sanitizeFileName(fileName: string): string { - const disallowedChars = /[^a-zA-Z0-9_.-]/g; - const lastDotIndex = fileName.lastIndexOf('.'); - const hasExtension = lastDotIndex > 0; - - if (!hasExtension) { - return fileName.replace(disallowedChars, '') || uuid(); // NOSONAR - } - - const stem = fileName.slice(0, lastDotIndex); - const extension = fileName.slice(lastDotIndex); - const sanitizedStem = stem.replace(disallowedChars, ''); // NOSONAR - return (sanitizedStem || uuid()) + extension; - } - - /** - * Finds all paths in a document that match a given filter predicate. - * Returns paths in dot notation (e.g., 'photo', 'metadata.images.0.photo'). - * - * Always skips keys starting with underscore (CouchDB internal fields like _id, _rev, _attachments). - * - * @param doc - The document to search - * @param filter - Predicate function that returns true for values we want to find - * @returns Array of paths in dot notation - */ - private findPaths( - doc: Record, - filter: (value: any) => boolean - ): string[] { - const paths: string[] = []; - - const traverse = (current: any, currentPath: string[] = []) => { - if (filter(current)) { - paths.push(currentPath.join('.')); - } - - if (Array.isArray(current)) { - current.forEach((item, idx) => traverse(item, [...currentPath, idx.toString()])); - } else if (current && typeof current === 'object') { - Object.entries(current).forEach(([key, value]) => { - if (!key.startsWith('_')) { - traverse(value, [...currentPath, key]); - } - }); - } - }; - - Object.entries(doc).forEach(([key, value]) => { - if (!key.startsWith('_')) { - traverse(value, [key]); - } - }); - - return paths; - } - - /** - * Recursively scans through a document and replaces field values that reference - * uploaded file names with their sanitized equivalents. Modifies the document in place. - * - * @param doc - The document to scan and modify - * @param fileNameMap - Map of original file names to sanitized file names - */ - private sanitizeFieldValues(doc: Record, fileNameMap: Map): void { - const pathsToUpdate = this.findPaths( - doc, - value => typeof value === 'string' && fileNameMap.has(value) - ); - - pathsToUpdate.forEach(path => { - const currentValue = objectPath.get(doc, path); - objectPath.set(doc, path, fileNameMap.get(currentValue)); - }); - } - - private processAllAttachments(preparedDocs: Record[], xmlStr: string) { - const mainDoc = preparedDocs[0]; - const newAttachmentNames = new Set(); - const fileNameMap = new Map(); // Map of original file name -> sanitized file name - - // Attach files from FileManager (uploaded via file widgets) - FileManager - .getCurrentFiles() - .forEach(file => { - const sanitizedFileName = this.sanitizeFileName(file.name); - const attachmentName = `${this.USER_FILE_ATTACHMENT_PREFIX}${sanitizedFileName}`; - newAttachmentNames.add(attachmentName); - this.attachmentService.add(mainDoc, attachmentName, file, file.type, false); - - // Track the mapping for field value sanitization - fileNameMap.set(file.name, sanitizedFileName); - }); - - // Process binary fields from XML - if (xmlStr) { - const $record = $($.parseXML(xmlStr)); - const formId = $record.find(':first').attr('id'); - - $record - .find('[type=binary]') - .each((_idx, element) => { - const content = $(element).text(); - if (content) { - const xpath = Xpath.getElementXPath(element); - // Replace instance root element node name with form internal ID - const filename = this.USER_FILE_ATTACHMENT_PREFIX.slice(0, -1) + - (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId)); - newAttachmentNames.add(filename); - this.attachmentService.add(mainDoc, filename, content, 'image/png', true); - } - }); - } - - // Sanitize field values in the document to match sanitized attachment names - this.sanitizeFieldValues(mainDoc, fileNameMap); - - // Remove orphaned user attachments that are no longer referenced - const referencedAttachmentNames = this.findReferencedAttachments(mainDoc); - const validAttachmentNames = new Set([...newAttachmentNames, ...referencedAttachmentNames]); - this.removeOrphanedAttachments(mainDoc, validAttachmentNames); - } - - private findReferencedAttachments(doc: Record): Set { - const referenced = new Set(); - if (!doc._attachments) { - return referenced; - } - - const existingAttachmentNames = Object.keys(doc._attachments); - - const isReferencedAttachment = (value: any): boolean => { - if (typeof value !== 'string' || !value) { - return false; - } - const possibleAttachmentName = `${this.USER_FILE_ATTACHMENT_PREFIX}${value}`; - return existingAttachmentNames.includes(possibleAttachmentName); - }; - - const referencedPaths = this.findPaths(doc, isReferencedAttachment); - - referencedPaths.forEach(path => { - const value = objectPath.get(doc, path); - referenced.add(`${this.USER_FILE_ATTACHMENT_PREFIX}${value}`); - }); - - return referenced; - } - - private removeOrphanedAttachments(doc: Record, validAttachmentNames: Set) { - if (!doc._attachments) { - return; - } - - Object.keys(doc._attachments) - .filter(name => name.startsWith(this.USER_FILE_ATTACHMENT_PREFIX) && !validAttachmentNames.has(name)) - .forEach(name => this.attachmentService.remove(doc, name)); - } - - - private extractIfRequired(name, value) { - return this.CONTACT_FIELD_NAMES.includes(name) ? this.extractLineageService.extract(value) : value; - } - - private prepareNewSibling(doc, fieldName, siblings) { - const preparedSibling = this.prepare(siblings[fieldName]); - - // by default all siblings are "person" types but can be overridden - // by specifying the type and contact_type in the form - if (!preparedSibling.type) { - preparedSibling.type = 'person'; - } - - if (preparedSibling.parent === 'PARENT') { - delete preparedSibling.parent; - // Cloning to avoid the circular references - doc[fieldName] = { ...preparedSibling }; - // Because we're assigning the actual doc reference, the dbService.get.get - // to attach the full parent to the doc will also attach it here. - preparedSibling.parent = doc; - } else { - doc[fieldName] = this.extractIfRequired(fieldName, preparedSibling); - } - - return preparedSibling; - } - - private async getContact(doc, fieldName, contactId) { - const dbFieldValue = await this.getContactFromDatasource(Qualifier.byUuid(contactId)); - // In a correctly configured form one of these will be the - // parent. This must happen before we attempt to run - // ExtractLineage on any siblings or repeats, otherwise they - // will extract an incomplete lineage - doc[fieldName] = this.extractIfRequired(fieldName, dbFieldValue); - } - - // Mutates the passed doc to attach prepared siblings, and returns all - // prepared siblings to be persisted. - // This will (on a correctly configured form) attach the full parent to - // doc, and in turn siblings. See internal comments. - private prepareAndAttachSiblingDocs(doc, original, siblings) { - if (!doc._id) { - throw new Error('doc passed must already be prepared with an _id'); - } - - const preparedSiblings: Record[] = []; - let promiseChain = Promise.resolve(); - - this.CONTACT_FIELD_NAMES.forEach(fieldName => { - let value = doc[fieldName]; - if (_isObject(value)) { - value = doc[fieldName]._id; - } - if (!value) { - return; - } - if (value === 'NEW') { - const preparedSibling = this.prepareNewSibling(doc, fieldName, siblings); - preparedSiblings.push(preparedSibling); - } else if (original?.[fieldName]?._id === value) { - doc[fieldName] = original[fieldName]; - } else { - promiseChain = promiseChain.then(() => this.getContact(doc, fieldName, value)); - } - }); - - return promiseChain.then(() => preparedSiblings); - } - - async save(form, docId, typeFields, xmlVersion) { - return this.ngZone.runOutsideAngular(async () => { - const original = docId ? await this.getContactFromDatasource(Qualifier.byUuid(docId)) : null; - const xmlStr = form.getDataStr({ irrelevant: false }); - const submitted = this.enketoTranslationService.contactRecordToJs(xmlStr); - const docData = await this.prepareSubmittedDocsForSave(original, submitted, typeFields, xmlStr); - if (xmlVersion) { - for (const doc of docData.preparedDocs) { - doc.form_version = xmlVersion; - } - } - return docData; - }); - } -} diff --git a/webapp/src/ts/services/enketo-prepopulation-data.service.ts b/webapp/src/ts/services/enketo-prepopulation-data.service.ts index c23aab279d9..a32e01cd4fb 100644 --- a/webapp/src/ts/services/enketo-prepopulation-data.service.ts +++ b/webapp/src/ts/services/enketo-prepopulation-data.service.ts @@ -1,16 +1,10 @@ import { Injectable } from '@angular/core'; import { isString as _isString } from 'lodash-es'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; - @Injectable({ providedIn: 'root' }) export class EnketoPrepopulationDataService { - constructor( - private enketoTranslationService:EnketoTranslationService, - ) {} - get(userSettings, model, data) { if (data && _isString(data)) { return data; @@ -22,16 +16,64 @@ export class EnketoPrepopulationDataService { const userRoot = bindRoot.find('>inputs>user'); if (data) { - this.enketoTranslationService.bindJsonToXml(bindRoot, data, (name) => { + this.bindJsonToXml(bindRoot, data, (name) => { // Either a direct child or a direct child of inputs return '>%, >inputs>%'.replace(/%/g, name); }); } if (userRoot.length) { - this.enketoTranslationService.bindJsonToXml(userRoot, userSettings); + this.bindJsonToXml(userRoot, userSettings); } return new XMLSerializer().serializeToString(bindRoot[0]); } + + private bindJsonToXml(elem, data, childMatcher?) { + // Enketo will remove all elements that have the "template" attribute + // https://github.com/enketo/enketo-core/blob/51c5c2f494f1515a67355543b435f6aaa4b151b4/src/js/form-model.js#L436-L451 + elem.removeAttr('jr:template'); + elem.removeAttr('template'); + + if (data === null || typeof data !== 'object') { + elem.text(data); + return; + } + + if (Array.isArray(data)) { + const parent = elem.parent(); + elem.remove(); + + data.forEach((dataEntry) => { + const clone = elem.clone(); + this.bindJsonToXml(clone, dataEntry); + parent.append(clone); + }); + return; + } + + if (!elem.children().length) { + this.bindJsonToXml(elem, data._id); + } + + Object.keys(data).forEach((key) => { + const value = data[key]; + const current = this.findCurrentElement(elem, key, childMatcher); + this.bindJsonToXml(current, value); + }); + } + + private findCurrentElement(elem, name, childMatcher) { + if (childMatcher) { + const matcher = childMatcher(name); + const found = elem.find(matcher); + if (found.length > 1) { + console.warn(`Enketo bindJsonToXml: Using the matcher "${matcher}" we found ${found.length} elements. ` + + 'We should only ever bind one.', elem, name); + } + return found; + } + + return elem.children(name); + } } diff --git a/webapp/src/ts/services/enketo-translation.service.ts b/webapp/src/ts/services/enketo-translation.service.ts deleted file mode 100644 index 1b45cbbca40..00000000000 --- a/webapp/src/ts/services/enketo-translation.service.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class EnketoTranslationService { - private withElements(nodes:any) { - return Array - .from(nodes) - .filter((node:any) => node.nodeType === Node.ELEMENT_NODE); - } - - private findChildNode(root, childNodeName) { - return this - .withElements(root.childNodes) - .find((node:any) => node.nodeName === childNodeName); - } - - private getHiddenFieldListRecursive(nodes, prefix, current:Set) { - nodes.forEach(node => { - const path = prefix + node.nodeName; - - const attr = node.attributes.getNamedItem('tag'); - if (attr && attr.value && attr.value.toLowerCase() === 'hidden') { - current.add(path); - } else { - const children = this.withElements(node.childNodes); - this.getHiddenFieldListRecursive(children, path + '.', current); - } - }); - } - - private nodesToJs(data, repeatPaths?, path?) { - repeatPaths = repeatPaths || []; - path = path || ''; - const result = {}; - this.withElements(data).forEach((n:any) => { - const typeAttribute = n.attributes.getNamedItem('type'); - const updatedPath = path + '/' + n.nodeName; - let value; - - const hasChildren = this.withElements(n.childNodes).length > 0; - if (hasChildren) { - value = this.nodesToJs(n.childNodes, repeatPaths, updatedPath); - } else if (typeAttribute && typeAttribute.value === 'binary') { - // this is attached to the doc instead of inlined - value = ''; - } else { - value = n.textContent; - } - - if (repeatPaths.indexOf(updatedPath) !== -1) { - if (!result[n.nodeName]) { - result[n.nodeName] = []; - } - result[n.nodeName].push(value); - } else { - result[n.nodeName] = value; - } - }); - return result; - } - - private repeatsToJs(data) { - const repeatNode:any = this.findChildNode(data, 'repeat'); - if (!repeatNode) { - return; - } - - const repeats = {}; - - this.withElements(repeatNode.childNodes).forEach((repeated:any) => { - const key = repeated.nodeName + '_data'; - if (!repeats[key]) { - repeats[key] = []; - } - repeats[key].push(this.nodesToJs(repeated.childNodes)); - }); - - return repeats; - } - - private findCurrentElement(elem, name, childMatcher) { - if (childMatcher) { - const matcher = childMatcher(name); - const found = elem.find(matcher); - if (found.length > 1) { - console.warn(`Enketo bindJsonToXml: Using the matcher "${matcher}" we found ${found.length} elements. ` + - 'We should only ever bind one.', elem, name); - } - return found; - } - - return elem.children(name); - } - - bindJsonToXml(elem, data, childMatcher?) { - // Enketo will remove all elements that have the "template" attribute - // https://github.com/enketo/enketo-core/blob/51c5c2f494f1515a67355543b435f6aaa4b151b4/src/js/form-model.js#L436-L451 - elem.removeAttr('jr:template'); - elem.removeAttr('template'); - - if (data === null || typeof data !== 'object') { - elem.text(data); - return; - } - - if (Array.isArray(data)) { - const parent = elem.parent(); - elem.remove(); - - data.forEach((dataEntry) => { - const clone = elem.clone(); - this.bindJsonToXml(clone, dataEntry); - parent.append(clone); - }); - return; - } - - if (!elem.children().length) { - this.bindJsonToXml(elem, data._id); - } - - Object.keys(data).forEach((key) => { - const value = data[key]; - const current = this.findCurrentElement(elem, key, childMatcher); - this.bindJsonToXml(current, value); - }); - } - - getHiddenFieldList (model, dbDocFields:Array) { - model = $.parseXML(model).firstChild; - if (!model) { - return; - } - const children = this.withElements(model.childNodes); - const fields = new Set(dbDocFields); - this.getHiddenFieldListRecursive(children, '', fields); - return [...fields]; - } - - getRepeatPaths(formXml) { - return $(formXml) - .find('repeat[nodeset]') - .map((idx, element) => { - return $(element).attr('nodeset'); - }) - .get(); - } - - reportRecordToJs(record, formXml?) { - const root = $.parseXML(record).firstChild!; - if (!formXml) { - return this.nodesToJs(root.childNodes); - } - const repeatPaths = this.getRepeatPaths(formXml); - return this.nodesToJs(root.childNodes, repeatPaths, '/' + root.nodeName); - } - - /* - * Given a record, returns the parsed doc and associated docs - * result.doc: the main document - * result.siblings: more documents at the same level. These docs are docs - * that must link to the main doc, but the main doc must also link to them, - * for example the main doc may be a place, and a CHW a sibling. - * see: contacts-edit.component.ts:saveSiblings - * result.repeats: documents from repeat nodes. These docs are simple docs - * that we wish to save independently of the main document. - * see: contacts-edit.component.ts:saveRepeated - */ - contactRecordToJs(record) { - const root = $.parseXML(record).firstChild!; - const result:any = { - doc: null, - siblings: {}, - }; - - const repeats = this.repeatsToJs(root); - if (repeats) { - result.repeats = repeats; - } - - const NODE_NAMES_TO_IGNORE = ['meta', 'inputs', 'repeat']; - - this - .withElements(root.childNodes) - .filter((node:any) => !NODE_NAMES_TO_IGNORE.includes(node.nodeName) && node.childElementCount > 0) - .forEach((child:any) => { - if (!result.doc) { - // First child is the main result, rest are siblings - result.doc = this.nodesToJs(child.childNodes); - return; - } - result.siblings[child.nodeName] = this.nodesToJs(child.childNodes); - }); - - return result; - } -} diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index 76ed058869b..23b6fc03e58 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -6,18 +6,22 @@ import * as FileManager from '../../js/enketo/file-manager.js'; import events from 'enketo-core/src/js/event'; import { Xpath } from '@mm-providers/xpath-element-path.provider'; -import { AttachmentService } from '@mm-services/attachment.service'; import { DbService } from '@mm-services/db.service'; import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulation-data.service'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { REPORT_ATTACHMENT_NAME } from '@mm-services/get-report-content.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { TranslateService } from '@mm-services/translate.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import { Qualifier, Report } from '@medic/cht-datasource'; +import { Contact, Qualifier } from '@medic/cht-datasource'; import { DOC_TYPES } from '@medic/constants'; -import { EnketoForm } from '@mm-services/NewEnketoService'; +import { FormConfig } from '@mm-services/form/form-config'; +import { + EnektoContactFormData, + EnketoFormData, + EnketoReportFormData, + EnketoRootFormData +} from '@mm-services/form/form-data'; +import { isHardcodedType } from '@medic/contact-types-utils'; /** * Service for interacting with Enketo forms. This code is intended for displaying forms in the CHT as well as being @@ -30,21 +34,22 @@ import { EnketoForm } from '@mm-services/NewEnketoService'; }) export class EnketoService { constructor( - private attachmentService: AttachmentService, private dbService: DbService, private enketoPrepopulationDataService: EnketoPrepopulationDataService, - private enketoTranslationService: EnketoTranslationService, private extractLineageService: ExtractLineageService, private translateFromService: TranslateFromService, private translateService: TranslateService, chtDatasourceService: CHTDatasourceService, private ngZone: NgZone, ) { - this.getReport = chtDatasourceService.bind(Report.v1.get); + this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); } + private readonly USER_BINARY_ATTACHMENT_PREFIX = 'user-file'; + private readonly USER_FILE_ATTACHMENT_PREFIX = `${this.USER_BINARY_ATTACHMENT_PREFIX}-`; + private readonly objUrls: string[] = []; - private readonly getReport: ReturnType; + private readonly getContactFromDatasource: ReturnType; private currentForm; getCurrentForm() { @@ -364,181 +369,6 @@ export class EnketoService { return { form, config: formConfig }; } - private xmlToDocs(doc, formXml, xmlVersion, record) { - const recordDoc = $.parseXML(record); - const $record = $($(recordDoc).children()[0]); - const repeatPaths = this.enketoTranslationService.getRepeatPaths(formXml); - - const mapOrAssignId = (e, id?) => { - if (!id) { - const $id = $(e).children('_id'); - if ($id.length) { - id = $id.text(); - } - if (!id) { - id = uuid(); - } - } - e._couchId = id; - }; - - mapOrAssignId($record[0], doc._id || uuid()); - - const getId = (xpath) => { - const xPathResult = recordDoc.evaluate(xpath, recordDoc, null, XPathResult.ANY_TYPE, null); - let node = xPathResult.iterateNext(); - while (node) { - if (node._couchId) { - return node._couchId; - } - node = xPathResult.iterateNext(); - } - }; - - const getRelativePath = (path) => { - if (!path) { - return; - } - path = path.trim(); - - const repeatReference = repeatPaths?.find(repeatPath => path === repeatPath || path.startsWith(`${repeatPath}/`)); - if (repeatReference) { - if (repeatReference === path) { - // when the path is the repeat element itself, return the repeat element node name - return path.split('/').slice(-1)[0]; - } - - return path.replace(`${repeatReference}/`, ''); - } - - if (path.startsWith('./')) { - return path.replace('./', ''); - } - }; - - const getClosestPath = (element, $element, path) => { - const relativePath = getRelativePath(path); - if (!relativePath) { - return; - } - - // assign a unique id for xpath context, since the element can be inside a repeat - if (!element.id) { - element.id = uuid(); - } - const uniqueElementSelector = `${element.nodeName}[@id="${element.id}"]`; - - const closestPath = `//${uniqueElementSelector}/ancestor-or-self::*/descendant-or-self::${relativePath}`; - try { - recordDoc.evaluate(closestPath, recordDoc); - return closestPath; - } catch (err) { - console.error('Error while evaluating closest path', closestPath, err); - } - }; - - // Chrome 30 doesn't support $xml.outerHTML: #3880 - const getOuterHTML = (xml) => { - if (xml.outerHTML) { - return xml.outerHTML; - } - return $('').append($(xml).clone()).html(); - }; - - const dbDocTags: string[] = []; - $record - .find('[db-doc]') - .filter((idx, element) => { - return $(element).attr('db-doc')?.toLowerCase() === 'true'; - }) - .each((idx, element) => { - mapOrAssignId(element); - dbDocTags.push(element.tagName); - }); - - $record - .find('[db-doc-ref]') - .each((idx, element) => { - const $element = $(element); - const reference = $element.attr('db-doc-ref'); - const path = getClosestPath(element, $element, reference); - - const refId = (path && getId(path)) || getId(reference); - $element.text(refId); - }); - - const docsToStore = $record - .find('[db-doc=true]') - .map((idx, element) => { - const docToStore: any = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element)); - docToStore._id = getId(Xpath.getElementXPath(element)); - docToStore.reported_date = Date.now(); - return docToStore; - }) - .get(); - - doc._id = getId('/*'); - if (xmlVersion) { - doc.form_version = xmlVersion; - } - doc.hidden_fields = this.enketoTranslationService.getHiddenFieldList(record, dbDocTags); - - FileManager - .getCurrentFiles() - .forEach(file => this.attachmentService.add(doc, `user-file-${file.name}`, file, file.type, false)); - - const attachLegacyFile = (elem, file, type, alreadyEncoded) => { - const xpath = Xpath.getElementXPath(elem); - // replace instance root element node name with form internal ID - const filename = 'user-file' + - (xpath.startsWith('/' + doc.form) ? xpath : xpath.replace(/^\/[^/]+/, '/' + doc.form)); - this.attachmentService.add(doc, filename, file, type, alreadyEncoded); - }; - - $record - .find('[type=binary]') - .each((idx, element) => { - const file = $(element).text(); - if (file) { - // Attach binary file with legacy-style filename because the actual filename is not stored as the question - // value in the form model (and so there is currently no way to map the answer in a saved report to the - // associated file attachment). - attachLegacyFile(element, file, 'image/png', true); - } - }); - - record = getOuterHTML($record[0]); - - // remove old style content attachment - this.attachmentService.remove(doc, REPORT_ATTACHMENT_NAME); - docsToStore.unshift(doc); - - doc.fields = this.enketoTranslationService.reportRecordToJs(record, formXml); - return docsToStore; - } - - private async update(docId) { - // update an existing doc. For convenience, get the latest version - // and then modify the content. This will avoid most concurrent - // edits, but is not ideal. - const doc = await this.getReport(Qualifier.byUuid(docId)); - // previously XML was stored in the content property - // TODO delete this and other "legacy" code support commited against - // the same git commit at some point in the future? - return { ...doc, content: undefined }; - } - - private create(formInternalId, contact) { - return { - form: formInternalId, - type: DOC_TYPES.DATA_RECORD, - content_type: 'xml', - reported_date: Date.now(), - contact: this.extractLineageService.extract(contact), - from: contact?.phone - }; - } - private forceRecalculate(form) { // Work-around for stale jr:choice-name() references in labels. ref #3870 form.calc.update(); @@ -566,33 +396,221 @@ export class EnketoService { } } - private async prepareForSave(form) { + public async saveContact({ config, form }: EnketoForm, defaultData: Record) { + return this.ngZone.runOutsideAngular(async () => { + await this.validate(form); + const contactDoc = this.initializeDoc(defaultData); + const formData = new EnektoContactFormData( + this.getFormDataXml(form), + contactDoc._id, + isHardcodedType(contactDoc.type) ? contactDoc.type : contactDoc.contact_type + ); + + const formAttachments = this.processFormAttachments(config.doc.internalId, formData, contactDoc._attachments); + + const rootOutputDoc: Record = { + ...contactDoc, + ...formData.deserializeDoc(config), + type: contactDoc.type, + contact_type: contactDoc.contact_type, + _attachments: formAttachments + }; + + const siblings = EnektoContactFormData.SIBLING_FIELD_NAMES + .map(fieldName => ({ fieldName, doc: formData.getSiblingData(fieldName)?.deserializeDoc(config) })) + .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); + await Promise.all(siblings.map(async ({ fieldName, doc }) => { + rootOutputDoc[fieldName] = await this.getContactSiblingValue( + doc, rootOutputDoc[fieldName], defaultData[fieldName] + ); + })); + const outputSiblings = siblings + .filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName] === doc) + .map(({ doc }) => doc) + .filter(doc => !!doc); + + const childDocs = formData + .getChildData() + .map(doc => this.initializeDoc(doc.deserializeDoc(config))) + .map(doc => ({ ...doc, parent: rootOutputDoc })); + + return { + docId: rootOutputDoc._id, + preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs].map(doc => this.dehydrateContactLineage(doc)) + }; + }); + } + + public async saveReport({ config, form }: EnketoForm, defaultData: Record) { + return this.ngZone.runOutsideAngular(async () => { + await this.validate(form); + const { internalId, xmlVersion } = config.doc; + const reportDoc: Record = this.initializeReportDoc(internalId, xmlVersion, defaultData); + const formData = new EnketoReportFormData(this.getFormDataXml(form), reportDoc._id); + const subDocsData = formData.getDbDocData(); + + this.populateDbDocRefElements(formData, [formData, ...subDocsData]); + const attachments = this.processFormAttachments(config.doc.internalId, formData, reportDoc._attachments); + const hiddenFields = this.getHiddenFields([ + ...formData.hiddenElements, + ...subDocsData.map(({ rootElement }) => rootElement) + ]); + + const rootOutputDoc: Record = { + ...reportDoc, + hidden_fields: hiddenFields, + fields: formData.deserialize(config), + _attachments: attachments + }; + + const dbDocObjects = subDocsData + .map(docData => docData.deserializeDoc(config)) + .map(doc => this.initializeDoc(doc)); + return [rootOutputDoc, ...dbDocObjects]; + }); + } + + private async validate(form: Record) { const valid = await form.validate(); if (!valid) { - throw new Error('Form is invalid'); + throw new FormValidationError(); } form.view.html.dispatchEvent(events.BeforeSave()); } - async completeNewReport(formInternalId, form, formDoc, contact) { - await this.prepareForSave(form); - return this.ngZone.runOutsideAngular(async () => { - const doc = this.create(formInternalId, contact); - return this._save(form, formDoc, doc); - }); + private getFormDataXml(form: Record) { + const formString = form.getDataStr({ irrelevant: false }); + return new DOMParser().parseFromString(formString, 'text/xml'); } - async completeExistingReport(form, formDoc, docId) { - await this.prepareForSave(form); - return this.ngZone.runOutsideAngular(async () => { - const doc = await this.update(docId); - return this._save(form, formDoc, doc); + private initializeContactSibling(rootContactDoc: Record, rawSibling?: Record) { + if (!rawSibling) { + return; + } + return { + type: 'person', // legacy support - form data should override this + ...this.initializeDoc(rawSibling), + parent: rawSibling.parent === 'PARENT' ? { ...rootContactDoc } : rawSibling.parent + }; + } + + private async getContactSiblingValue( + sibling?: Record, + currentValue?: Record, + defaultValue?: Record + ) { + if (!currentValue?._id) { + return undefined; + } + if (currentValue._id === 'NEW') { + return sibling; + } + if (currentValue._id === defaultValue?._id) { + return defaultValue; + } + + return await this.getContactFromDatasource(Qualifier.byUuid(currentValue._id)); + } + + private dehydrateContactLineage(contactDoc: Record) { + return { + ...contactDoc, + parent: this.extractLineageService.extract(contactDoc.parent), + contact: this.extractLineageService.extract(contactDoc.contact) + }; + } + + private getHiddenFields(elements: Element[]) { + const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementRawXPath(element))); + const hasHiddenAncestor = ( + segments: string[] + ) => (_: string, i: number) => i > 0 && hiddenXpaths.has(segments.slice(0, i).join('/')); + return [...hiddenXpaths] + .map(xpath => xpath.split('/')) + .filter(segments => !segments.some(hasHiddenAncestor(segments))) + .map(segments => segments + .filter(Boolean) + .slice(1) + .join('.')); + } + + private initializeDoc(defaultData: Record): Record { + return { + _id: defaultData._id || uuid(), + reported_date: Date.now(), + ...defaultData + }; + } + + private initializeReportDoc(form: string, formVersion: string, defaultData: Record) { + return { + form, + type: DOC_TYPES.DATA_RECORD, + content_type: 'xml', + from: defaultData.contact?.phone, + ...this.initializeDoc(defaultData), + contact: this.extractLineageService.extract(defaultData.contact), + form_version: formVersion + }; + } + + private populateDbDocRefElements(formData: EnketoReportFormData, allData: EnketoFormData[]) { + formData.dbDocRefElements.forEach(element => { + const reference = element.getAttribute('db-doc-ref'); + const referencedNode = formData.getNodeByXpath(element, reference); + if (!referencedNode) { + return; + } + const refDoc = allData.find(({ rootElement }) => rootElement === referencedNode); + if (refDoc) { + element.textContent = refDoc.id; + } }); } - private async _save(form, formDoc, doc) { - const dataString = form.getDataStr({ irrelevant: false }); - return this.xmlToDocs(doc, formDoc.xml, formDoc.doc.xmlVersion, dataString); + private buildBinaryAttachmentData(form: string, originalAttachments: Record, element: Element) { + const xpath = Xpath.getElementTreeXPath(element); + const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`); + const filename = `${this.USER_BINARY_ATTACHMENT_PREFIX}${formXpath}`; + const data = element.textContent; + element.textContent = ''; + return { + filename, + // Currently do not support loading binary attachment data into edit form. So, keep existing value. + attachment: data ? { data, content_type: 'image/png' } : originalAttachments[filename] + }; + } + + private processFormAttachments( + form: string, + rootData: EnketoRootFormData, + originalAttachments: Record = {} + ) { + const binaryAttachments = rootData.binaryTypeElements + .map(element => this.buildBinaryAttachmentData(form, originalAttachments, element)) + .filter(({ attachment }) => attachment) + .reduce((acc, { filename, attachment }) => ({ ...acc, [filename]: attachment }), {}); + const newFileAttachments = FileManager + .getCurrentFiles() + .map(file => ({ + name: `${this.USER_FILE_ATTACHMENT_PREFIX}${file.name}`, + content_type: file.type, + data: new Blob([ file ], { type: file.type }) + })) + .reduce((acc, { name, content_type, data }) => ({ ...acc, [name]: { content_type, data } }), {}); + const existingFileAttachments = Object + .entries(originalAttachments) + .filter(([key]) => key.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)) + // Keep existing file attachments still referenced by a field + .filter(([key]) => rootData.findNodeWithTextContent(key.slice(this.USER_FILE_ATTACHMENT_PREFIX.length))) + .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); + + const attachments = { + ...existingFileAttachments, + ...newFileAttachments, + ...binaryAttachments + }; + return Object.keys(attachments).length ? attachments : undefined; } unload(enketoForm?: EnketoForm) { @@ -618,6 +636,7 @@ export interface ContactSummary { id: string; context: Record; } + interface ContactSummaryXml { id: string; xml: Document; @@ -639,8 +658,6 @@ interface XmlFormContext { userContactSummary?: ContactSummary; } -export type FormType = 'contact' | 'report' | 'task' | 'training-card'; - export interface EnketoFormContext { readonly selector: string; readonly formConfig: FormConfig; @@ -653,20 +670,14 @@ export interface EnketoFormContext { readonly userContactSummary?: ContactSummary; } -export class FormConfig { - public readonly repeatPaths: string[]; +export interface EnketoForm { + form: Record + config: FormConfig +} - constructor( - public readonly doc: Record, - public type: FormType, - xml: string, - public readonly html: string, - public readonly model: string - ) { - const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); - this.repeatPaths = Array - .from(xmlDoc.querySelectorAll('repeat[nodeset]')) - .map(el => el.getAttribute('nodeset')!) - .filter(Boolean); +export class FormValidationError extends Error { + constructor(message = 'Form is invalid') { + super(message); + this.name = 'FormValidationError'; } } diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index cdc100d6354..0787dd5526f 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -20,9 +20,8 @@ import { TransitionsService } from '@mm-services/transitions.service'; import { GlobalActions } from '@mm-actions/global'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; -import { ContactSummary, EnketoFormContext, EnketoService, FormConfig } from '@mm-services/enketo.service'; +import { ContactSummary, EnketoForm, EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; import { reduce as _reduce } from 'lodash-es'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; @@ -31,8 +30,7 @@ import { Nullable, Person, Contact, Report, Qualifier } from '@medic/cht-datasou import { DeduplicateService, DuplicateCheck } from '@mm-services/deduplicate.service'; import { ContactsService } from '@mm-services/contacts.service'; import { PerformanceService } from '@mm-services/performance.service'; -import { EnketoForm, NewEnketoService } from '@mm-services/NewEnketoService'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; +import { FormConfig } from '@mm-services/form/form-config'; /** * Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the @@ -46,7 +44,6 @@ import { ExtractLineageService } from '@mm-services/extract-lineage.service'; export class FormService { constructor( private store: Store, - private contactSaveService: ContactSaveService, private contactSummaryService: ContactSummaryService, private contactTypesService: ContactTypesService, private dbService: DbService, @@ -63,8 +60,6 @@ export class FormService { private ngZone: NgZone, private chtDatasourceService: CHTDatasourceService, private enketoService: EnketoService, - private newEnketoService: NewEnketoService, - private extractLineageService: ExtractLineageService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, private readonly deduplicateService: DeduplicateService, @@ -314,15 +309,13 @@ export class FormService { private async completeReport(enketoForm: EnketoForm, docId?) { if (docId) { const doc = await this.getReport(Qualifier.byUuid(docId)); - return this.newEnketoService.saveReport(enketoForm, doc!); - // return this.enketoService.completeExistingReport(form, formDoc, docId); + return this.enketoService.saveReport(enketoForm, doc!); } const isTrainingCardForm = this.trainingCardsService.isTrainingCardForm(enketoForm.config.doc.internalId); const contact = await this.getUserContact(!isTrainingCardForm); - const docs = await this.newEnketoService.saveReport(enketoForm, { contact }); - // const docs = await this.enketoService.completeNewReport(formInternalId, form, formDoc, contact); + const docs = await this.enketoService.saveReport(enketoForm, { contact }); if (!docId && isTrainingCardForm) { docs[0]._id = this.trainingCardsService.getTrainingCardDocId(); } @@ -406,7 +399,7 @@ export class FormService { // const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion); const defaultData = docId ? await this.getContact(Qualifier.byUuid(docId)) : typeFields; - const docs = await this.newEnketoService.saveContact(enketoForm, defaultData!); + const docs = await this.enketoService.saveContact(enketoForm, defaultData!); const preparedDocs = await this.applyTransitions(docs); diff --git a/webapp/src/ts/services/form/form-config.ts b/webapp/src/ts/services/form/form-config.ts new file mode 100644 index 00000000000..785946aa452 --- /dev/null +++ b/webapp/src/ts/services/form/form-config.ts @@ -0,0 +1,20 @@ +export type FormType = 'contact' | 'report' | 'task' | 'training-card'; + +// FYI, putting this in the xml-forms.service file causes that service (and dependencies) to leak into cht-form. +export class FormConfig { + public readonly repeatPaths: string[]; + + constructor( + public readonly doc: Record, + public type: FormType, + xml: string, + public readonly html: string, + public readonly model: string + ) { + const xmlDoc = new DOMParser().parseFromString(xml, 'text/xml'); + this.repeatPaths = Array + .from(xmlDoc.querySelectorAll('repeat[nodeset]')) + .map(el => el.getAttribute('nodeset')!) + .filter(Boolean); + } +} diff --git a/webapp/src/ts/services/form/form-data.ts b/webapp/src/ts/services/form/form-data.ts new file mode 100644 index 00000000000..2bf38345352 --- /dev/null +++ b/webapp/src/ts/services/form/form-data.ts @@ -0,0 +1,186 @@ +import { FormConfig } from '@mm-services/form/form-config'; +import { Xpath } from '@mm-providers/xpath-element-path.provider'; +import { v7 as uuid } from 'uuid'; + +export class EnketoFormData { + constructor( + public readonly rootElement: Element, + public readonly id: string, + ) { } + + public deserialize(formConfig: FormConfig): Record { + return this.nodesToJs( + this.getChildElements(this.rootElement), + formConfig.repeatPaths, + Xpath.getElementRawXPath(this.rootElement) + ); + } + + public deserializeDoc(formConfig: FormConfig): Record { + return { + _id: this.id, + form_version: formConfig.doc.xmlVersion, + ...this.deserialize(formConfig) + }; + } + + protected isElementNode(node: unknown): node is Element { + return node?.['nodeType'] === Node.ELEMENT_NODE; + } + + protected getChildElements(node: Node) { + return Array + .from(node.childNodes) + .filter(this.isElementNode); + } + + protected nodesToJs(nodes: Element[], repeatPaths: string[], path: string) { + return nodes + .map(node => ({ node, nodePath: `${path}/${node.nodeName}` })) + .map(({ node, nodePath }) => ({ node, nodePath, value: this.getJsValueForNode(node, repeatPaths, nodePath) })) + .reduce((acc, { node, nodePath, value }) => { + if (repeatPaths.includes(nodePath)) { + acc[node.nodeName] ??= []; + acc[node.nodeName].push(value); + } else { + acc[node.nodeName] = value; + } + return acc; + }, {}); + } + + private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) { + const elements = this.getChildElements(node); + return elements.length ? this.nodesToJs(elements, repeatPaths, nodePath) : node.textContent; + } + + protected findChildNode(element: Element, tagName: string) { + return Array + .from(element.children) + .find(child => child.tagName === tagName); + } + + protected getDocId(element: Element) { + return this.findChildNode(element, '_id')?.textContent || uuid(); + } +} + +export abstract class EnketoRootFormData extends EnketoFormData { + public readonly binaryTypeElements: Element[]; + + protected constructor( + private readonly xmlDoc: XMLDocument, + id: string, + ) { + super(xmlDoc.documentElement, id); + this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); + } + + public findNodeWithTextContent(textContent: string) { + const result = this.xmlDoc.evaluate( + `.//*[text()=${JSON.stringify(textContent)}]`, + this.rootElement, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); + return result.singleNodeValue; + } + + public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { + const xpath = rawXpath?.trim(); + if (!xpath) { + return null; + } + const xpathSegments = xpath + .trim() + .split('/') + .filter(Boolean); + const contextLineage = this.getNodeWithLineage(contextNode); + + // Number of leading segments the target path shares with the context node's lineage. + const firstDivergence = xpathSegments + .findIndex((segment, i) => segment !== contextLineage[i]?.nodeName); + const commonAncestorIndex = firstDivergence === -1 ? xpathSegments.length : firstDivergence; + + // Fall back to contextNode in case of relative xpath + const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; + const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; + const result = this.xmlDoc.evaluate( + relativePath, + anchor, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ); + return result.singleNodeValue; + } + + private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { + if (!this.isElementNode(contextNode)) { + return lineage; + } + lineage.unshift(contextNode); + return this.getNodeWithLineage(contextNode.parentNode, lineage); + } +} + +export class EnektoContactFormData extends EnketoRootFormData { + public static readonly SIBLING_FIELD_NAMES = ['parent', 'contact'] as const; + private readonly childElements: Element[]; + private readonly rootContactElement: Element; + + constructor(xmlDoc: XMLDocument, id: string, type: string) { + super(xmlDoc, id); + this.childElements = Array.from(this.rootElement.querySelectorAll(':scope > repeat > child')); + const elementForType = this.findChildNode(this.rootElement, type); + if (!elementForType) { + // Fail loudly here because previous save logic was very "flexible" around the naming of this group. However, the + // contact form documentation and the prepopulation logic when rendering the form both clearly intend for the + // contact's data to be loaded from the group with the name of the contact type. + throw new Error( + `Failed to save contact form because the data for the contact is not contained in the ${type} group.` + ); + } + this.rootContactElement = elementForType; + } + + public deserializeDoc(formConfig: FormConfig): Record { + const rootDoc = new EnketoFormData(this.rootContactElement, this.id).deserializeDoc(formConfig); + const liftIdValue = (idValue: unknown) => typeof idValue === 'string' ? { _id: idValue } : idValue; + return { + ...rootDoc, + parent: liftIdValue(rootDoc.parent), + contact: liftIdValue(rootDoc.contact) + }; + } + + public getChildData() { + return this.childElements.map(dbDoc => new EnketoFormData(dbDoc, this.getDocId(dbDoc))); + } + + public getSiblingData(fieldName: typeof EnektoContactFormData.SIBLING_FIELD_NAMES[number]) { + const element = this.findChildNode(this.rootElement, fieldName); + return element ? new EnketoFormData(element, this.getDocId(element)) : null; + } +} + +export class EnketoReportFormData extends EnketoRootFormData { + private readonly dbDocElements: Element[]; + public readonly hiddenElements: Element[]; + public readonly dbDocRefElements: Element[]; + + constructor(xmlDoc: XMLDocument, id: string) { + super(xmlDoc, id); + this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]')); + this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]')); + this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]')); + } + + public getDbDocData() { + return this.dbDocElements.map(dbDoc => new EnketoFormData( + dbDoc, + this.getDocId(dbDoc) + )); + } +} diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index cbfd55f591f..c2add30de3d 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -11,7 +11,7 @@ import { UserContactService } from '@mm-services/user-contact.service'; import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; import { ParseProvider } from '@mm-providers/parse.provider'; import { UserContactSummaryService } from '@mm-services/user-contact-summary.service'; -import { FormConfig, FormType } from '@mm-services/enketo.service'; +import { FormConfig, FormType } from '@mm-services/form/form-config'; export const TRAINING_FORM_ID_PREFIX: string = `${PREFIXES.FORM}training:`; export const CONTACT_FORM_ID_PREFIX: string = `${PREFIXES.FORM}contact:`; diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index 56d23e7bef9..8361e65c668 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Inject, Input, Output } from '@angular/core'; -import { EnketoFormContext, EnketoService, FormConfig } from '@mm-services/enketo.service'; +import { EnketoForm, EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; import * as medicXpathExtensions from '../../../src/js/enketo/medic-xpath-extensions'; import moment from 'moment'; import { toBik_text } from 'bikram-sambat'; @@ -9,7 +9,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { CHTDatasourceService as CHTDatasourceServiceStub } from './stubs/cht-datasource.service'; import { CONTACT_TYPES } from '@medic/constants'; -import { EnketoForm, NewEnketoService } from '@mm-services/NewEnketoService'; +import { FormConfig } from '@mm-services/form/form-config'; const DEFAULT_FORM_ID = 'cht-form-id'; @@ -45,7 +45,6 @@ export class AppComponent { constructor( chtDatasourceService: CHTDatasourceService, private readonly enketoService: EnketoService, - private readonly newEnketoService: NewEnketoService, private readonly translateService: TranslateService, @Inject(DOCUMENT) private readonly document: Document, ) { @@ -154,10 +153,10 @@ export class AppComponent { const typeFields = this.HARDCODED_TYPES.includes(contactType) ? { type: contactType } : { type: 'contact', contact_type: contactType }; - const { preparedDocs } = await this.newEnketoService.saveContact(this.enketoForm!, typeFields); + const { preparedDocs } = await this.enketoService.saveContact(this.enketoForm!, typeFields); return preparedDocs; } - return this.newEnketoService.saveReport( + return this.enketoService.saveReport( this.enketoForm!, { contact: this.formContext.content?.contact } ); From 6f0c8cd8de10a46b7c8f645a8f42f4ed49c8b4b6 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 09:26:17 -0500 Subject: [PATCH 20/41] Fix the cht-form build --- webapp/src/ts/services/form.service.ts | 26 ------------- webapp/src/ts/services/form/form-config.ts | 2 +- .../tests/karma/app.component.spec.ts | 39 +++++++++---------- 3 files changed, 19 insertions(+), 48 deletions(-) diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 0787dd5526f..af45cf4e2dc 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -6,7 +6,6 @@ import * as moment from 'moment'; import * as enketoConstants from './../../js/enketo/constants'; import * as medicXpathExtensions from '../../js/enketo/medic-xpath-extensions'; import { DbService } from '@mm-services/db.service'; -import { FileReaderService } from '@mm-services/file-reader.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { SubmitFormBySmsService } from '@mm-services/submit-form-by-sms.service'; import { UserContactService } from '@mm-services/user-contact.service'; @@ -47,7 +46,6 @@ export class FormService { private contactSummaryService: ContactSummaryService, private contactTypesService: ContactTypesService, private dbService: DbService, - private fileReaderService: FileReaderService, private lineageModelGeneratorService: LineageModelGeneratorService, private submitFormBySmsService: SubmitFormBySmsService, private userContactService: UserContactService, @@ -99,30 +97,6 @@ export class FormService { }); } - private getAttachment(id, name) { - return this.dbService - .get() - .getAttachment(id, name) - .then(blob => this.fileReaderService.utf8(blob)); - } - - private transformXml(form) { - return Promise - .all([ - this.getAttachment(form._id, this.xmlFormsService.HTML_ATTACHMENT_NAME), - this.getAttachment(form._id, this.xmlFormsService.MODEL_ATTACHMENT_NAME) - ]) - .then(([html, model]) => { - const $html = $(html); - - return { - html: $html, - model: model, - title: form.title, - }; - }); - } - private modelHasInstance(model, instanceId) { return $(model).find(`instance[id="${instanceId}"]`).length >= 1; } diff --git a/webapp/src/ts/services/form/form-config.ts b/webapp/src/ts/services/form/form-config.ts index 785946aa452..8209b77a65d 100644 --- a/webapp/src/ts/services/form/form-config.ts +++ b/webapp/src/ts/services/form/form-config.ts @@ -6,7 +6,7 @@ export class FormConfig { constructor( public readonly doc: Record, - public type: FormType, + public readonly type: FormType, xml: string, public readonly html: string, public readonly model: string diff --git a/webapp/web-components/cht-form/tests/karma/app.component.spec.ts b/webapp/web-components/cht-form/tests/karma/app.component.spec.ts index 6ef94bbaf99..fd5a4fa11ba 100644 --- a/webapp/web-components/cht-form/tests/karma/app.component.spec.ts +++ b/webapp/web-components/cht-form/tests/karma/app.component.spec.ts @@ -8,7 +8,6 @@ import { expect } from 'chai'; import { AppComponent } from '../../src/app.component'; import * as medicXpathExtensions from '../../../../src/js/enketo/medic-xpath-extensions'; import { EnketoService } from '@mm-services/enketo.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('AppComponent', () => { @@ -22,7 +21,6 @@ describe('AppComponent', () => { } as const; let chtDatasourceService; - let contactSaveService; let enketoService; let fixture; @@ -38,13 +36,13 @@ describe('AppComponent', () => { addExtensionLib: sinon.stub(), clearExtensionLibs: sinon.stub(), }; - contactSaveService = { save: sinon.stub() }; enketoService = { renderForm: sinon .stub() .resolves(), getCurrentForm: sinon.stub(), - completeNewReport: sinon.stub(), + saveReport: sinon.stub(), + saveContact: sinon.stub(), unload: sinon.stub(), }; await TestBed @@ -55,7 +53,6 @@ describe('AppComponent', () => { ], providers: [ { provide: CHTDatasourceService, useValue: chtDatasourceService }, - { provide: ContactSaveService, useValue: contactSaveService }, { provide: EnketoService, useValue: enketoService }, ] }) @@ -437,7 +434,7 @@ describe('AppComponent', () => { { _id: 'doc1' }, { _id: 'doc2' }, ]; - enketoService.completeNewReport.resolves(expectedDocs); + enketoService.saveReport.resolves(expectedDocs); const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; @@ -487,13 +484,13 @@ describe('AppComponent', () => { await submitPromise; tick(); expect(enketoService.getCurrentForm.callCount).to.equal(8); - expect(contactSaveService.save.called).to.be.false; - expect(enketoService.completeNewReport.callCount).to.equal(1); + expect(enketoService.saveContact.called).to.be.false; + expect(enketoService.saveReport.callCount).to.equal(1); const expectedFormDoc = { xml: formXml, doc: {} }; - expect(enketoService.completeNewReport.args[0]).to.deep.equal([ + expect(enketoService.saveReport.args[0]).to.deep.equal([ formId, currentForm, expectedFormDoc, @@ -507,7 +504,7 @@ describe('AppComponent', () => { { _id: 'doc1' }, { _id: 'doc2' }, ]; - contactSaveService.save.resolves({ preparedDocs: expectedDocs }); + enketoService.saveContact.resolves({ preparedDocs: expectedDocs }); const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; @@ -558,9 +555,9 @@ describe('AppComponent', () => { await submitPromise; tick(); expect(enketoService.getCurrentForm.callCount).to.equal(9); - expect(enketoService.completeNewReport.called).to.be.false; - expect(contactSaveService.save.callCount).to.equal(1); - expect(contactSaveService.save.args[0]).to.deep.equal([ + expect(enketoService.saveReport.called).to.be.false; + expect(enketoService.saveContact.callCount).to.equal(1); + expect(enketoService.saveContact.args[0]).to.deep.equal([ currentForm, null, { type: 'person' }, @@ -574,7 +571,7 @@ describe('AppComponent', () => { { _id: 'doc1' }, { _id: 'doc2' }, ]; - contactSaveService.save.resolves({ preparedDocs: expectedDocs }); + enketoService.saveContact.resolves({ preparedDocs: expectedDocs }); const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; @@ -625,9 +622,9 @@ describe('AppComponent', () => { await submitPromise; tick(); expect(enketoService.getCurrentForm.callCount).to.equal(9); - expect(enketoService.completeNewReport.called).to.be.false; - expect(contactSaveService.save.callCount).to.equal(1); - expect(contactSaveService.save.args[0]).to.deep.equal([ + expect(enketoService.saveReport.called).to.be.false; + expect(enketoService.saveContact.callCount).to.equal(1); + expect(enketoService.saveContact.args[0]).to.deep.equal([ currentForm, null, { type: 'contact', contact_type: 'custom_contact' }, @@ -638,7 +635,7 @@ describe('AppComponent', () => { it('submits form when error thrown completing the report', fakeAsync(async () => { const expectedError = new Error('Validation Error'); - enketoService.completeNewReport.rejects(expectedError); + enketoService.saveReport.rejects(expectedError); const currentForm = { _id: 'current-form' }; const consoleErrorStub = sinon.stub(console, 'error'); @@ -666,12 +663,12 @@ describe('AppComponent', () => { expect(consoleErrorStub.callCount).to.equal(1); expect(consoleErrorStub.args[0]).to.deep.equal(['Error submitting form data: ', expectedError]); expect(enketoService.getCurrentForm.callCount).to.equal(2); - expect(enketoService.completeNewReport.callCount).to.equal(1); + expect(enketoService.saveReport.callCount).to.equal(1); const expectedFormDoc = { xml: FORM_XML, doc: {} }; - expect(enketoService.completeNewReport.args[0]).to.deep.equal([ + expect(enketoService.saveReport.args[0]).to.deep.equal([ FORM_ID, currentForm, expectedFormDoc, @@ -685,7 +682,7 @@ describe('AppComponent', () => { { _id: 'doc1' }, { _id: 'doc2' }, ]; - enketoService.completeNewReport.resolves(expectedDocs); + enketoService.saveReport.resolves(expectedDocs); const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; From d354e566c70b4d68ddd8d09fffb7fcda80c29d39 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 09:44:46 -0500 Subject: [PATCH 21/41] Fix the sonar issues --- .../training-cards-form.component.ts | 2 +- .../contacts/contacts-edit.component.ts | 4 +- .../contacts/contacts-report.component.ts | 26 +++++------ .../modules/reports/reports-add.component.ts | 32 +++++++------- .../modules/tasks/tasks-content.component.ts | 30 ++++++------- webapp/src/ts/services/enketo.service.ts | 16 +++---- webapp/src/ts/services/form.service.ts | 44 +++++++++---------- webapp/src/ts/services/form/form-data.ts | 2 +- webapp/src/ts/services/xml-forms.service.ts | 23 +++++----- 9 files changed, 89 insertions(+), 90 deletions(-) diff --git a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts index a6ef7757e60..b2b32ee72c7 100644 --- a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts +++ b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts @@ -34,7 +34,7 @@ export class TrainingCardsFormComponent implements OnInit, OnDestroy { private trackRender; private trackEditDuration; private trackSave; - private trackMetadata = { action: '', form: '' }; + private readonly trackMetadata = { action: '', form: '' }; readonly FORM_WRAPPER_ID = 'training-cards-form'; trainingCardFormId: null | string = null; formNoTitle = false; diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 2c960c82d51..f9487712efb 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -51,7 +51,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { subscription = new Subscription(); translationsLoadedSubscription; - private globalActions; + private readonly globalActions; private readonly getContactFromDatasource: ReturnType; enketoStatus; @@ -70,7 +70,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private trackRender; private trackEditDuration; private trackSave; - private trackMetadata = { action: '', form: '' }; + private readonly trackMetadata = { action: '', form: '' }; duplicatesAcknowledged = false; diff --git a/webapp/src/ts/modules/contacts/contacts-report.component.ts b/webapp/src/ts/modules/contacts/contacts-report.component.ts index fc897739770..86c2570a5d2 100644 --- a/webapp/src/ts/modules/contacts/contacts-report.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-report.component.ts @@ -23,13 +23,13 @@ import { EnketoForm } from '@mm-services/enketo.service'; imports: [NgIf, EnketoComponent, TranslatePipe] }) export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit { - private globalActions; + private readonly globalActions; private geoHandle: any; private routeSnapshot; private trackRender; private trackEditDuration; private trackSave; - private trackMetadata = { form: '' }; + private readonly trackMetadata = { form: '' }; subscription: Subscription = new Subscription(); enketoEdited; @@ -43,17 +43,17 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit cancelCallback; constructor( - private store: Store, - private formService: FormService, - private geolocationService: GeolocationService, - private performanceService: PerformanceService, - private xmlFormsService: XmlFormsService, - private translateFromService: TranslateFromService, - private router: Router, - private route: ActivatedRoute, - private translateService: TranslateService, - private contactViewModelGeneratorService: ContactViewModelGeneratorService, - private ngZone: NgZone, + private readonly store: Store, + private readonly formService: FormService, + private readonly geolocationService: GeolocationService, + private readonly performanceService: PerformanceService, + private readonly xmlFormsService: XmlFormsService, + private readonly translateFromService: TranslateFromService, + private readonly router: Router, + private readonly route: ActivatedRoute, + private readonly translateService: TranslateService, + private readonly contactViewModelGeneratorService: ContactViewModelGeneratorService, + private readonly ngZone: NgZone, ) { this.globalActions = new GlobalActions(store); } diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index e128d9ed751..a1de286e684 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -33,19 +33,19 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { subscription: Subscription = new Subscription(); constructor( - private store:Store, - private dbService:DbService, - private fileReaderService:FileReaderService, - private geolocationService:GeolocationService, - private getReportContentService:GetReportContentService, - private lineageModelGeneratorService:LineageModelGeneratorService, - private xmlFormsService:XmlFormsService, - private formService:FormService, - private translateService:TranslateService, - private router:Router, - private route:ActivatedRoute, - private performanceService:PerformanceService, - private ngZone:NgZone, + private readonly store:Store, + private readonly dbService:DbService, + private readonly fileReaderService:FileReaderService, + private readonly geolocationService:GeolocationService, + private readonly getReportContentService:GetReportContentService, + private readonly lineageModelGeneratorService:LineageModelGeneratorService, + private readonly xmlFormsService:XmlFormsService, + private readonly formService:FormService, + private readonly translateService:TranslateService, + private readonly router:Router, + private readonly route:ActivatedRoute, + private readonly performanceService:PerformanceService, + private readonly ngZone:NgZone, ) { this.globalActions = new GlobalActions(this.store); this.reportsActions = new ReportsActions(this.store); @@ -64,12 +64,12 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { selectMode; private geoHandle: any; - private globalActions: GlobalActions; - private reportsActions: ReportsActions; + private readonly globalActions: GlobalActions; + private readonly reportsActions: ReportsActions; private trackRender; private trackEditDuration; private trackSave; - private trackMetadata = { action: '', form: '' }; + private readonly trackMetadata = { action: '', form: '' }; private routeSnapshot; private subscribeToStore() { diff --git a/webapp/src/ts/modules/tasks/tasks-content.component.ts b/webapp/src/ts/modules/tasks/tasks-content.component.ts index 068caa88ebb..e9532ed0bdd 100644 --- a/webapp/src/ts/modules/tasks/tasks-content.component.ts +++ b/webapp/src/ts/modules/tasks/tasks-content.component.ts @@ -30,18 +30,18 @@ import { EnketoForm } from '@mm-services/enketo.service'; }) export class TasksContentComponent implements OnInit, OnDestroy { constructor( - private translateService:TranslateService, - private route:ActivatedRoute, - private store:Store, - private formService:FormService, - private performanceService:PerformanceService, - private translateFromService:TranslateFromService, - private xmlFormsService:XmlFormsService, - private geolocationService:GeolocationService, - chtDatasourceService: CHTDatasourceService, - private router:Router, - private tasksForContactService:TasksForContactService, + private readonly translateService:TranslateService, + private readonly route:ActivatedRoute, + private readonly store:Store, + private readonly formService:FormService, + private readonly performanceService:PerformanceService, + private readonly translateFromService:TranslateFromService, + private readonly xmlFormsService:XmlFormsService, + private readonly geolocationService:GeolocationService, + private readonly router:Router, + private readonly tasksForContactService:TasksForContactService, private readonly interactionTrackingService:InteractionTrackingService, + chtDatasourceService: CHTDatasourceService, ) { this.globalActions = new GlobalActions(store); this.tasksActions = new TasksActions(store); @@ -49,8 +49,8 @@ export class TasksContentComponent implements OnInit, OnDestroy { } subscription = new Subscription(); - private globalActions; - private tasksActions; + private readonly globalActions; + private readonly tasksActions; private readonly getContact: ReturnType; enketoStatus; @@ -69,8 +69,8 @@ export class TasksContentComponent implements OnInit, OnDestroy { private trackRender; private trackEditDuration; private trackSave; - private trackMetadata = { action: '' }; - private viewInited = new Subject(); + private readonly trackMetadata = { action: '' }; + private readonly viewInited = new Subject(); ngOnInit() { this.trackRender = this.performanceService.track(); diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index 23b6fc03e58..0b1cbddb557 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -34,13 +34,13 @@ import { isHardcodedType } from '@medic/contact-types-utils'; }) export class EnketoService { constructor( - private dbService: DbService, - private enketoPrepopulationDataService: EnketoPrepopulationDataService, - private extractLineageService: ExtractLineageService, - private translateFromService: TranslateFromService, - private translateService: TranslateService, + private readonly dbService: DbService, + private readonly enketoPrepopulationDataService: EnketoPrepopulationDataService, + private readonly extractLineageService: ExtractLineageService, + private readonly translateFromService: TranslateFromService, + private readonly translateService: TranslateService, + private readonly ngZone: NgZone, chtDatasourceService: CHTDatasourceService, - private ngZone: NgZone, ) { this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get); } @@ -490,7 +490,7 @@ export class EnketoService { return { type: 'person', // legacy support - form data should override this ...this.initializeDoc(rawSibling), - parent: rawSibling.parent === 'PARENT' ? { ...rootContactDoc } : rawSibling.parent + parent: rawSibling.parent === 'PARENT' ? rootContactDoc : rawSibling.parent }; } @@ -606,7 +606,7 @@ export class EnketoService { .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); const attachments = { - ...existingFileAttachments, + ...existingFileAttachments, // TODO I think we need to keep anything without the prefixing.... ...newFileAttachments, ...binaryAttachments }; diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index af45cf4e2dc..25024bbd71b 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -42,28 +42,28 @@ import { FormConfig } from '@mm-services/form/form-config'; }) export class FormService { constructor( - private store: Store, - private contactSummaryService: ContactSummaryService, - private contactTypesService: ContactTypesService, - private dbService: DbService, - private lineageModelGeneratorService: LineageModelGeneratorService, - private submitFormBySmsService: SubmitFormBySmsService, - private userContactService: UserContactService, - private userSettingsService: UserSettingsService, - private xmlFormsService: XmlFormsService, - private zScoreService: ZScoreService, - private trainingCardsService: TrainingCardsService, - private transitionsService: TransitionsService, - private translateService: TranslateService, - private ngZone: NgZone, - private chtDatasourceService: CHTDatasourceService, - private enketoService: EnketoService, - private targetAggregatesService: TargetAggregatesService, - private contactViewModelGeneratorService: ContactViewModelGeneratorService, + private readonly store: Store, + private readonly contactSummaryService: ContactSummaryService, + private readonly contactTypesService: ContactTypesService, + private readonly dbService: DbService, + private readonly lineageModelGeneratorService: LineageModelGeneratorService, + private readonly submitFormBySmsService: SubmitFormBySmsService, + private readonly userContactService: UserContactService, + private readonly userSettingsService: UserSettingsService, + private readonly xmlFormsService: XmlFormsService, + private readonly zScoreService: ZScoreService, + private readonly trainingCardsService: TrainingCardsService, + private readonly transitionsService: TransitionsService, + private readonly translateService: TranslateService, + private readonly ngZone: NgZone, + private readonly chtDatasourceService: CHTDatasourceService, + private readonly enketoService: EnketoService, + private readonly targetAggregatesService: TargetAggregatesService, + private readonly contactViewModelGeneratorService: ContactViewModelGeneratorService, private readonly deduplicateService: DeduplicateService, private readonly contactsService: ContactsService, private readonly performanceService: PerformanceService, - private userContactSummaryService: UserContactSummaryService, + private readonly userContactSummaryService: UserContactSummaryService, ) { this.inited = this.init(); this.globalActions = new GlobalActions(store); @@ -74,10 +74,10 @@ export class FormService { private readonly getReport: ReturnType; private readonly getContact: ReturnType; - private globalActions: GlobalActions; - private servicesActions: ServicesActions; + private readonly globalActions: GlobalActions; + private readonly servicesActions: ServicesActions; - private inited; + private readonly inited; private userContactId; private userFacilityIds; diff --git a/webapp/src/ts/services/form/form-data.ts b/webapp/src/ts/services/form/form-data.ts index 2bf38345352..a8b4c0d2f36 100644 --- a/webapp/src/ts/services/form/form-data.ts +++ b/webapp/src/ts/services/form/form-data.ts @@ -73,7 +73,7 @@ export abstract class EnketoRootFormData extends EnketoFormData { id: string, ) { super(xmlDoc.documentElement, id); - this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary i]')); + this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary]')); } public findNodeWithTextContent(textContent: string) { diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index c2add30de3d..34611a0ba34 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -21,21 +21,21 @@ export const CONTACT_FORM_ID_PREFIX: string = `${PREFIXES.FORM}contact:`; }) export class XmlFormsService { private init; - private observable = new Subject(); + private readonly observable = new Subject(); readonly HTML_ATTACHMENT_NAME = 'form.html'; readonly MODEL_ATTACHMENT_NAME = 'model.xml'; constructor( - private authService:AuthService, - private changesService:ChangesService, - private contactTypesService:ContactTypesService, - private dbService:DbService, - private fileReaderService: FileReaderService, - private userContactService:UserContactService, - private userContactSummaryService: UserContactSummaryService, - private xmlFormsContextUtilsService:XmlFormsContextUtilsService, - private parseProvider:ParseProvider, - private ngZone:NgZone, + private readonly authService:AuthService, + private readonly changesService:ChangesService, + private readonly contactTypesService:ContactTypesService, + private readonly dbService:DbService, + private readonly fileReaderService: FileReaderService, + private readonly userContactService:UserContactService, + private readonly userContactSummaryService: UserContactSummaryService, + private readonly xmlFormsContextUtilsService:XmlFormsContextUtilsService, + private readonly parseProvider:ParseProvider, + private readonly ngZone:NgZone, ) { this.init = this.getForms(); @@ -410,4 +410,3 @@ export class XmlFormsService { return this.init.then(forms => this.filterAll(forms, options || {})); } } - From e4b7e324c1b60b88fec76a82d5192fd4c9cb7489 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 14:23:50 -0500 Subject: [PATCH 22/41] Lock in the cht-form --- .github/workflows/build.yml | 28 ++- webapp/src/ts/services/enketo.service.ts | 21 +- webapp/src/ts/services/form.service.ts | 4 +- webapp/tsconfig.json | 7 +- webapp/tsconfig.spec.json | 4 +- .../cht-form/src/app.component.ts | 2 +- .../tests/karma/app.component.spec.ts | 206 +++++++++--------- 7 files changed, 143 insertions(+), 129 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7abd3390830..8e13858a08e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,8 +78,8 @@ jobs: - run: npm ci - name: Use system shellcheck binary run: cp /usr/bin/shellcheck node_modules/shellcheck/bin/shellcheck - - name: Compile (skip unit tests) - run: node scripts/ci/check-versions.js && node scripts/build/cli npmCiModules && npm run lint && npm run build && npm run integration-api + - name: Compile + run: npm run ci-compile - name: Setup QEMU if: ${{ env.INTERNAL_CONTRIBUTOR }} uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 @@ -128,6 +128,28 @@ jobs: - name: Run Tests run: npm run ${{ matrix.cmd }} + test-cht-form: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # zizmor: ignore[cache-poisoning] + with: + node-version: ${{ env.NODE_VERSION }} + - run: | + sudo apt-get update + sudo apt-get install -y xsltproc + - run: npm ci + - name: Build cht-form Web Component + run: npm run build-cht-form + - name: Run Tests + run: npm run integration-cht-form + tests-k3d: needs: build name: ${{ matrix.cmd }} @@ -409,7 +431,7 @@ jobs: if: ${{ failure() }} publish: - needs: [tests, config-tests, build-generated-docs] + needs: [tests, config-tests, test-cht-form, build-generated-docs] name: Publish branch build runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index 0b1cbddb557..d7961500e7d 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -22,6 +22,7 @@ import { EnketoRootFormData } from '@mm-services/form/form-data'; import { isHardcodedType } from '@medic/contact-types-utils'; +import { REPORT_ATTACHMENT_NAME } from '@mm-services/get-report-content.service'; /** * Service for interacting with Enketo forms. This code is intended for displaying forms in the CHT as well as being @@ -449,6 +450,10 @@ export class EnketoService { const formData = new EnketoReportFormData(this.getFormDataXml(form), reportDoc._id); const subDocsData = formData.getDbDocData(); + // As of 4.0.0, content is no longer stored in legacy fields + delete reportDoc[REPORT_ATTACHMENT_NAME]; + delete reportDoc._attachments?.[REPORT_ATTACHMENT_NAME]; + this.populateDbDocRefElements(formData, [formData, ...subDocsData]); const attachments = this.processFormAttachments(config.doc.internalId, formData, reportDoc._attachments); const hiddenFields = this.getHiddenFields([ @@ -586,6 +591,10 @@ export class EnketoService { rootData: EnketoRootFormData, originalAttachments: Record = {} ) { + const hasCustomAttachmentName = (fileName: string) => !fileName.startsWith(this.USER_FILE_ATTACHMENT_PREFIX) + && !fileName.startsWith(`${this.USER_BINARY_ATTACHMENT_PREFIX}/`); + const isExistingFileAttachment = (fileName: string) => fileName.startsWith(this.USER_FILE_ATTACHMENT_PREFIX) + && rootData.findNodeWithTextContent(fileName.slice(this.USER_FILE_ATTACHMENT_PREFIX.length)); const binaryAttachments = rootData.binaryTypeElements .map(element => this.buildBinaryAttachmentData(form, originalAttachments, element)) .filter(({ attachment }) => attachment) @@ -598,23 +607,21 @@ export class EnketoService { data: new Blob([ file ], { type: file.type }) })) .reduce((acc, { name, content_type, data }) => ({ ...acc, [name]: { content_type, data } }), {}); - const existingFileAttachments = Object + const existingAttachments = Object .entries(originalAttachments) - .filter(([key]) => key.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)) - // Keep existing file attachments still referenced by a field - .filter(([key]) => rootData.findNodeWithTextContent(key.slice(this.USER_FILE_ATTACHMENT_PREFIX.length))) + // Keep custom attachments and existing file attachments still referenced by a field + .filter(([key]) => hasCustomAttachmentName(key) || isExistingFileAttachment(key)) .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); const attachments = { - ...existingFileAttachments, // TODO I think we need to keep anything without the prefixing.... + ...existingAttachments, ...newFileAttachments, ...binaryAttachments }; return Object.keys(attachments).length ? attachments : undefined; } - unload(enketoForm?: EnketoForm) { - const form = enketoForm?.form; + unload(form) { if (form !== this.currentForm) { return; } diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 25024bbd71b..20ad986d85f 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -174,7 +174,7 @@ export class FormService { const { formConfig, instanceData } = formContext; try { - this.unload(this.enketoService.getCurrentForm()); + this.enketoService.unload(this.enketoService.getCurrentForm()); const userSettings = await this.userSettingsService.getWithLanguage(); formContext.contactSummary = await this.getContactSummary(formConfig, instanceData); formContext.userContactSummary = await this.getUserContactSummary(formConfig); @@ -401,7 +401,7 @@ export class FormService { } unload(form?: EnketoForm) { - this.enketoService.unload(form); + this.enketoService.unload(form?.form); } } export class DuplicatesFoundError extends Error { diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index e4c250a018d..2eb008088a9 100755 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -43,10 +43,5 @@ "strictInjectionParameters": true, "preserveWhitespaces": true, "skipTemplateCodegen": false - }, - // TEMPORARY: skip type-checking the karma specs during `ngc` (npm run compile) so e2e can run - // before the unit tests are updated. tsconfig.spec.json overrides this so karma still compiles - // them. Revert once the webapp specs transpile again. - // NOTE: specifying "exclude" replaces TS's defaults, so node_modules/outDir must be re-listed. - "exclude": ["node_modules", "dist", "tests/**"] + } } diff --git a/webapp/tsconfig.spec.json b/webapp/tsconfig.spec.json index fdab2641a77..23301bd3920 100755 --- a/webapp/tsconfig.spec.json +++ b/webapp/tsconfig.spec.json @@ -17,7 +17,5 @@ "include": [ "tests/karma/**/*.spec.ts", "tests/karma/**/*.d.ts" - ], - // Override the temporary "tests/**" exclusion in tsconfig.json so karma still compiles the specs. - "exclude": [] + ] } diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index 8361e65c668..d7c5f18ee70 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -198,7 +198,7 @@ export class AppComponent { private unloadForm() { if (this.enketoForm) { - this.enketoService.unload(this.enketoForm); + this.enketoService.unload(this.enketoForm.form); this.enketoForm = undefined; $(`${this.formContext.selector} .container.pages`).empty(); } diff --git a/webapp/web-components/cht-form/tests/karma/app.component.spec.ts b/webapp/web-components/cht-form/tests/karma/app.component.spec.ts index fd5a4fa11ba..42f1e434822 100644 --- a/webapp/web-components/cht-form/tests/karma/app.component.spec.ts +++ b/webapp/web-components/cht-form/tests/karma/app.component.spec.ts @@ -22,6 +22,7 @@ describe('AppComponent', () => { let chtDatasourceService; let enketoService; + let enketoForm; let fixture; const getComponent = () => { @@ -36,11 +37,14 @@ describe('AppComponent', () => { addExtensionLib: sinon.stub(), clearExtensionLibs: sinon.stub(), }; + enketoForm = { + form: { }, + config: { doc: { _id: FORM_ID }, type: 'report' }, + }; enketoService = { renderForm: sinon .stub() - .resolves(), - getCurrentForm: sinon.stub(), + .resolves(enketoForm), saveReport: sinon.stub(), saveContact: sinon.stub(), unload: sinon.stub(), @@ -75,6 +79,13 @@ describe('AppComponent', () => { saving: false, error: null }); + expect(component.formContext.formConfig).to.deep.include({ + doc: { _id: FORM_ID }, + type: 'report', + html: '', + model: '', + repeatPaths: [] + }); }); it('renders form when required fields are set', fakeAsync(async () => { @@ -95,28 +106,46 @@ describe('AppComponent', () => { component.formHtml = FORM_HTML; tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(3); expect(enketoService.renderForm.callCount).to.equal(1); expect(onRender.callCount).to.equal(1); const actualContext = enketoService.renderForm.args[0][0]; expect(actualContext).to.deep.include({ selector: `#${FORM_ID}`, type: 'report', - formDoc: { _id: FORM_ID }, instanceData: undefined }); expect(actualContext.contactSummary).to.be.undefined; expect(actualContext.editedListener).to.exist; expect(actualContext.valuechangeListener).to.exist; - expect(enketoService.renderForm.args[0][1]).to.deep.equal({ - html: $(FORM_HTML), + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: FORM_ID }, + type: 'report', + html: FORM_HTML, model: FORM_MODEL, - hasContactSummary: false + repeatPaths: [] }); - expect(enketoService.renderForm.args[0][2]).to.deep.equal(USER); + expect(enketoService.renderForm.args[0][1]).to.deep.equal(USER); expect(enketoService.unload.called).to.be.false; })); + it('logs an error and does not emit onRender when rendering the form fails', fakeAsync(async () => { + const consoleError = sinon.stub(console, 'error'); + const renderError = new Error('render failed'); + enketoService.renderForm.rejects(renderError); + const component = await getComponent(); + const onRender = sinon.stub(); + component.onRender.subscribe(onRender); + + component.formXml = FORM_XML; + component.formModel = FORM_MODEL; + component.formHtml = FORM_HTML; + tick(); + + expect(enketoService.renderForm.callCount).to.equal(1); + expect(onRender.called).to.be.false; + expect(consoleError.calledOnceWithExactly('Error rendering form: ', renderError)).to.be.true; + })); + it('renders form with optional field values', fakeAsync(async () => { const formId = 'test-form-id'; const formModel = ''; @@ -162,18 +191,19 @@ describe('AppComponent', () => { expect(actualContext).to.deep.include({ selector: `#${formId}`, type: 'contact', - formDoc: { _id: formId }, instanceData: content, contactSummary: { id: 'contact-summary', context: contactSummary } }); expect(actualContext.editedListener).to.exist; expect(actualContext.valuechangeListener).to.exist; - expect(enketoService.renderForm.args[0][1]).to.deep.equal({ - html: $(FORM_HTML), + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: formId }, + type: 'contact', + html: FORM_HTML, model: formModel, - hasContactSummary: true + repeatPaths: [] }); - expect(enketoService.renderForm.args[0][2]).to.deep.equal(user); + expect(enketoService.renderForm.args[0][1]).to.deep.equal(user); expect(enketoService.unload.called).to.be.false; // Null out the optional fields and render again @@ -191,17 +221,18 @@ describe('AppComponent', () => { expect(actualContext1).to.deep.include({ selector: `#${formId}`, type: 'report', - formDoc: { _id: formId }, instanceData: undefined }); - expect(actualContext.contactSummary).to.be.undefined; - expect(enketoService.renderForm.args[2][1]).to.deep.equal({ - html: $(FORM_HTML), + expect(actualContext1.contactSummary).to.be.undefined; + expect(actualContext1.formConfig).to.deep.include({ + doc: { _id: formId }, + type: 'report', + html: FORM_HTML, model: formModel, - hasContactSummary: true + repeatPaths: [] }); - expect(enketoService.renderForm.args[2][2]).to.deep.equal(user); - expect(enketoService.unload.called).to.be.false; + expect(enketoService.renderForm.args[2][1]).to.deep.equal(user); + expect(enketoService.unload.args).to.deep.equal([[enketoForm.form], [enketoForm.form]]); })); it('renders form with default fields missing from the provided user', fakeAsync(async () => { @@ -215,7 +246,7 @@ describe('AppComponent', () => { component.formHtml = FORM_HTML; tick(); expect(enketoService.renderForm.callCount).to.equal(1); - expect(enketoService.renderForm.args[0][2]).to.deep.equal({ + expect(enketoService.renderForm.args[0][1]).to.deep.equal({ contact_id: 'default_user', language: 'en', hello: 'world' @@ -259,17 +290,22 @@ describe('AppComponent', () => { expect(mutationObserverMock.disconnect.callCount).to.equal(1); tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(3); expect(enketoService.renderForm.callCount).to.equal(1); expect(onRender.callCount).to.equal(1); const actualContext = enketoService.renderForm.args[0][0]; expect(actualContext).to.deep.include({ selector: `#${formId}`, type: 'report', - formDoc: { _id: formId }, instanceData: undefined }); expect(actualContext.contactSummary).to.be.undefined; + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: formId }, + type: 'report', + html: FORM_HTML, + model: FORM_MODEL, + repeatPaths: [] + }); } finally { global.MutationObserver = MutationObserver; } @@ -292,10 +328,16 @@ describe('AppComponent', () => { expect(actualContext).to.deep.include({ selector: `#${FORM_ID}`, type: 'report', - formDoc: { _id: FORM_ID }, instanceData: { ...content, source: 'contact' } }); expect(actualContext.contactSummary).to.be.undefined; + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: FORM_ID }, + type: 'report', + html: FORM_HTML, + model: FORM_MODEL, + repeatPaths: [] + }); })); it('renders form with given source value even if contact is provided', fakeAsync(async () => { @@ -318,10 +360,16 @@ describe('AppComponent', () => { expect(actualContext).to.deep.include({ selector: `#${FORM_ID}`, type: 'report', - formDoc: { _id: FORM_ID }, instanceData: content }); expect(actualContext.contactSummary).to.be.undefined; + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: FORM_ID }, + type: 'report', + html: FORM_HTML, + model: FORM_MODEL, + repeatPaths: [] + }); })); it('re-renders form when any field is set', fakeAsync(async () => { @@ -334,8 +382,6 @@ describe('AppComponent', () => { }; const contactSummary = { hello: 'world' }; const content = { my: 'content' }; - const currentForm = { _id: 'current-form' }; - enketoService.getCurrentForm.returns(currentForm); const component = await getComponent(); const onRender = sinon.stub(); @@ -363,20 +409,20 @@ describe('AppComponent', () => { expect(actualContext).to.deep.include({ selector: `#${formId}`, type: 'contact', - formDoc: { _id: formId }, instanceData: content, contactSummary: { id: 'contact-summary', context: contactSummary } }); expect(actualContext.editedListener).to.exist; expect(actualContext.valuechangeListener).to.exist; - expect(enketoService.renderForm.args[0][1]).to.deep.equal({ - html: $(FORM_HTML), + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: formId }, + type: 'contact', + html: FORM_HTML, model: formModel, - hasContactSummary: true + repeatPaths: [] }); - expect(enketoService.renderForm.args[0][2]).to.deep.equal(user); - expect(enketoService.unload.callCount).to.equal(2); - enketoService.unload.args.forEach((args) => expect(args).to.deep.equal([currentForm])); + expect(enketoService.renderForm.args[0][1]).to.deep.equal(user); + expect(enketoService.unload.called).to.be.false; })); [ @@ -435,7 +481,6 @@ describe('AppComponent', () => { { _id: 'doc2' }, ]; enketoService.saveReport.resolves(expectedDocs); - const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; const formModel = ''; @@ -464,15 +509,9 @@ describe('AppComponent', () => { component.formModel = formModel; tick(); component.formHtml = formHtml; + tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(7); expect(enketoService.renderForm.callCount).to.equal(1); - enketoService.getCurrentForm - .onCall(7) - .returns(currentForm); - enketoService.getCurrentForm - .onCall(8) - .returns(currentForm); let actualSubmittedDocs; component.onSubmit.subscribe((submittedDocs) => { @@ -483,18 +522,11 @@ describe('AppComponent', () => { expect(component.status.saving).to.be.true; await submitPromise; tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(8); expect(enketoService.saveContact.called).to.be.false; expect(enketoService.saveReport.callCount).to.equal(1); - const expectedFormDoc = { - xml: formXml, - doc: {} - }; expect(enketoService.saveReport.args[0]).to.deep.equal([ - formId, - currentForm, - expectedFormDoc, - contact + enketoForm, + { contact } ]); expect(actualSubmittedDocs).to.deep.equal(expectedDocs); })); @@ -505,7 +537,6 @@ describe('AppComponent', () => { { _id: 'doc2' }, ]; enketoService.saveContact.resolves({ preparedDocs: expectedDocs }); - const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; const formModel = ''; @@ -535,15 +566,9 @@ describe('AppComponent', () => { component.formModel = formModel; tick(); component.formHtml = formHtml; + tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(8); expect(enketoService.renderForm.callCount).to.equal(1); - enketoService.getCurrentForm - .onCall(8) - .returns(currentForm); - enketoService.getCurrentForm - .onCall(9) - .returns(currentForm); let actualSubmittedDocs; component.onSubmit.subscribe((submittedDocs) => { @@ -554,14 +579,11 @@ describe('AppComponent', () => { expect(component.status.saving).to.be.true; await submitPromise; tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(9); expect(enketoService.saveReport.called).to.be.false; expect(enketoService.saveContact.callCount).to.equal(1); expect(enketoService.saveContact.args[0]).to.deep.equal([ - currentForm, - null, - { type: 'person' }, - null + enketoForm, + { type: 'person' } ]); expect(actualSubmittedDocs).to.deep.equal(expectedDocs); })); @@ -572,7 +594,6 @@ describe('AppComponent', () => { { _id: 'doc2' }, ]; enketoService.saveContact.resolves({ preparedDocs: expectedDocs }); - const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; const formModel = ''; @@ -602,15 +623,9 @@ describe('AppComponent', () => { component.formModel = formModel; tick(); component.formHtml = formHtml; + tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(8); expect(enketoService.renderForm.callCount).to.equal(1); - enketoService.getCurrentForm - .onCall(8) - .returns(currentForm); - enketoService.getCurrentForm - .onCall(9) - .returns(currentForm); let actualSubmittedDocs; component.onSubmit.subscribe((submittedDocs) => { @@ -621,14 +636,11 @@ describe('AppComponent', () => { expect(component.status.saving).to.be.true; await submitPromise; tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(9); expect(enketoService.saveReport.called).to.be.false; expect(enketoService.saveContact.callCount).to.equal(1); expect(enketoService.saveContact.args[0]).to.deep.equal([ - currentForm, - null, - { type: 'contact', contact_type: 'custom_contact' }, - null + enketoForm, + { type: 'contact', contact_type: 'custom_contact' } ]); expect(actualSubmittedDocs).to.deep.equal(expectedDocs); })); @@ -636,20 +648,12 @@ describe('AppComponent', () => { it('submits form when error thrown completing the report', fakeAsync(async () => { const expectedError = new Error('Validation Error'); enketoService.saveReport.rejects(expectedError); - const currentForm = { _id: 'current-form' }; const consoleErrorStub = sinon.stub(console, 'error'); const component = getComponent(); component.formXml = FORM_XML; tick(); - enketoService.getCurrentForm - .onCall(1) - .returns(currentForm); - enketoService.getCurrentForm - .onCall(2) - .returns(currentForm); - component.onSubmit.subscribe(() => { expect.fail('Should not have emitted docs'); }); @@ -662,17 +666,10 @@ describe('AppComponent', () => { expect(component.status.error).to.equal('error.report.save'); expect(consoleErrorStub.callCount).to.equal(1); expect(consoleErrorStub.args[0]).to.deep.equal(['Error submitting form data: ', expectedError]); - expect(enketoService.getCurrentForm.callCount).to.equal(2); expect(enketoService.saveReport.callCount).to.equal(1); - const expectedFormDoc = { - xml: FORM_XML, - doc: {} - }; expect(enketoService.saveReport.args[0]).to.deep.equal([ - FORM_ID, - currentForm, - expectedFormDoc, - undefined + undefined, + { contact: undefined } ]); expect(enketoService.unload.called).to.be.false; })); @@ -683,7 +680,6 @@ describe('AppComponent', () => { { _id: 'doc2' }, ]; enketoService.saveReport.resolves(expectedDocs); - const currentForm = { _id: 'current-form' }; const formId = 'test-form-id'; const formXml = '
custom
'; const formModel = ''; @@ -718,13 +714,10 @@ describe('AppComponent', () => { component.formModel = formModel; tick(); component.formHtml = formHtml; + tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(9); expect(chtDatasourceService.addExtensionLib.args).to.deep.equal([['hello', 'world'], ['world', 'hello']]); expect(enketoService.renderForm.callCount).to.equal(1); - enketoService.getCurrentForm - .onCall(9) - .returns(currentForm); let cancelEmitted = false; component.onCancel.subscribe(() => { @@ -734,10 +727,9 @@ describe('AppComponent', () => { component.cancelForm(); tick(); expect(cancelEmitted).to.be.true; - expect(enketoService.getCurrentForm.callCount).to.equal(12); expect(chtDatasourceService.clearExtensionLibs.calledOnceWithExactly()).to.be.true; expect(enketoService.unload.callCount).to.equal(1); - expect(enketoService.unload.args[0]).to.deep.equal([currentForm]); + expect(enketoService.unload.args[0]).to.deep.equal([enketoForm.form]); expect(component.formId).to.equal(FORM_ID); expect(component.editing).to.be.false; expect(component.status).to.deep.equal({ @@ -756,7 +748,6 @@ describe('AppComponent', () => { expect(enketoService.renderForm.callCount).to.equal(1); component.formHtml = FORM_HTML; tick(); - expect(enketoService.getCurrentForm.callCount).to.equal(15); expect(chtDatasourceService.addExtensionLib.callCount).to.equal(2); expect(enketoService.renderForm.callCount).to.equal(2); // New form has been rendered with default values (internal data was reset) @@ -764,17 +755,18 @@ describe('AppComponent', () => { expect(actualContext).to.deep.include({ selector: `#${FORM_ID}`, type: 'report', - formDoc: { _id: FORM_ID }, instanceData: undefined, contactSummary: undefined }); expect(actualContext.editedListener).to.exist; expect(actualContext.valuechangeListener).to.exist; - expect(enketoService.renderForm.args[1][1]).to.deep.equal({ - html: $(FORM_HTML), + expect(actualContext.formConfig).to.deep.include({ + doc: { _id: FORM_ID }, + type: 'report', + html: FORM_HTML, model: FORM_MODEL, - hasContactSummary: false + repeatPaths: [] }); - expect(enketoService.renderForm.args[1][2]).to.deep.equal(USER); + expect(enketoService.renderForm.args[1][1]).to.deep.equal(USER); })); }); From 9e5fa1c1755d8d4d61f36ffe597ba34867168727 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 14:52:08 -0500 Subject: [PATCH 23/41] Lock in module unit tests --- .../cht-form/default/person-edit.wdio-spec.js | 18 +- .../training-cards-form.component.spec.ts | 35 ++-- .../contacts/contacts-edit.component.spec.ts | 191 ++++++++---------- .../contacts-report.component.spec.ts | 12 +- .../reports/reports-add.component.spec.ts | 31 ++- .../tasks/tasks-content.component.spec.ts | 113 ++++++----- 6 files changed, 183 insertions(+), 217 deletions(-) diff --git a/tests/integration/cht-form/default/person-edit.wdio-spec.js b/tests/integration/cht-form/default/person-edit.wdio-spec.js index 27a741f3a2d..7bf960985d8 100644 --- a/tests/integration/cht-form/default/person-edit.wdio-spec.js +++ b/tests/integration/cht-form/default/person-edit.wdio-spec.js @@ -9,6 +9,14 @@ const excludeProps = [ 'dob_approx', 'reported_date', ]; +// These properties are set by default in the enketo service to undefined +const undefinedProps = { + _attachments: undefined, + contact: undefined, + contact_type: undefined, + form_version: undefined, + parent: undefined, +}; describe('cht-form web component - Edit Person Form', () => { @@ -31,8 +39,6 @@ describe('cht-form web component - Edit Person Form', () => { role: 'chw', external_id: '12345', notes: 'Test notes', - contact: undefined, - parent: undefined, user_for_contact: { create: 'true' }, @@ -60,7 +66,7 @@ describe('cht-form web component - Edit Person Form', () => { const [doc, ...additionalDocs] = await mockConfig.submitForm(); expect(additionalDocs).to.be.empty; - expect(doc).excludingEvery(excludeProps).to.deep.equal(initialPerson); + expect(doc).excludingEvery(excludeProps).to.deep.equal({ ...undefinedProps, ...initialPerson }); await mockConfig.cancelForm(); @@ -82,14 +88,12 @@ describe('cht-form web component - Edit Person Form', () => { role: 'patient', external_id: '54321', notes: 'Updated notes', - contact: undefined, - parent: '', meta: { ...initialPerson.meta, last_edited_by: '', last_edited_by_person_uuid: 'default_user', last_edited_by_place_uuid: '' - }, + } }; await mockConfig.loadForm('default', 'contact', 'person-edit'); @@ -130,6 +134,6 @@ describe('cht-form web component - Edit Person Form', () => { const [updatedDoc, ...updatedAdditionalDocs] = await mockConfig.submitForm(); expect(updatedAdditionalDocs).to.be.empty; - expect(updatedDoc).excludingEvery(excludeProps).to.deep.equal(updatedPerson); + expect(updatedDoc).excludingEvery(excludeProps).to.deep.equal({ ...undefinedProps, ...updatedPerson }); }); }); diff --git a/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts b/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts index ef2bf0c8102..d5081e3cb50 100644 --- a/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts +++ b/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts @@ -36,7 +36,7 @@ describe('TrainingCardsFormComponent', () => { consoleErrorMock = sinon.stub(console, 'error'); geoHandle = { cancel: sinon.stub() }; geolocationService = { init: sinon.stub().returns(geoHandle) }; - xmlFormsService = { get: sinon.stub().resolves() }; + xmlFormsService = { getFormConfig: sinon.stub().resolves() }; translateService = { get: sinon.stub().resolvesArg(0), instant: sinon.stub().returnsArg(0), @@ -185,7 +185,7 @@ describe('TrainingCardsFormComponent', () => { it('should call enketo save, set content in snackbar and unload form', fakeAsync(() => { const consoleDebugMock = sinon.stub(console, 'debug'); - xmlFormsService.get.resolves({ _id: 'form:training:new_feature' }); + xmlFormsService.getFormConfig.resolves({ doc: { _id: 'form:training:new_feature' } }); formService.save.resolves([{ _id: 'completed_training' }]); formService.render.resolves({ _id: 'form:training:new_feature', @@ -203,15 +203,13 @@ describe('TrainingCardsFormComponent', () => { _id: 'form:training:new_feature', pages: { activePages: [ { id: 'page-1' } ] }, }]); - expect(formService.save.calledOnce).to.be.true; - expect(formService.save.args[0]).to.deep.equal([ - 'training:a_form_id', + expect(formService.save.args).to.deep.equal([[ { _id: 'form:training:new_feature', pages: { activePages: [ { id: 'page-1' } ] }, }, geoHandle - ]); + ]]); expect(consoleDebugMock.callCount).to.equal(1); expect(consoleDebugMock.args[0]).to.deep.equal([ 'Saved form and associated docs', @@ -232,7 +230,7 @@ describe('TrainingCardsFormComponent', () => { it('should catch enketo saving error', fakeAsync(() => { sinon.resetHistory(); - xmlFormsService.get.resolves({ the: 'rendered training form' }); + xmlFormsService.getFormConfig.resolves({ doc: { the: 'rendered training form' } }); formService.render.resolves({ the: 'rendered training form' }); formService.save.rejects({ some: 'error' }); store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id'); @@ -242,12 +240,10 @@ describe('TrainingCardsFormComponent', () => { component.saveForm(); tick(); - expect(formService.save.calledOnce).to.be.true; - expect(formService.save.args[0]).to.deep.equal([ - 'training:a_form_id', + expect(formService.save.args).to.deep.equal([[ { the: 'rendered training form' }, geoHandle - ]); + ]]); expect(consoleErrorMock.calledOnce).to.be.true; expect(consoleErrorMock.args[0]).to.deep.equal([ 'TrainingCardsFormComponent :: Error submitting form data.', @@ -264,19 +260,18 @@ describe('TrainingCardsFormComponent', () => { describe('loadForm', () => { it('should load form', fakeAsync(() => { sinon.resetHistory(); - const xmlForm = { _id: 'training:a_form_id', some: 'content' }; + const formConfig = { doc: { _id: 'training:a_form_id', some: 'content' } }; const renderedForm = { rendered: 'form', model: {}, instance: {} }; - xmlFormsService.get.resolves(xmlForm); + xmlFormsService.getFormConfig.resolves(formConfig); formService.render.resolves(renderedForm); store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id'); store.refreshState(); tick(); expect(geolocationService.init.calledOnce).to.be.true; - expect(xmlFormsService.get.calledOnce).to.be.true; - expect(xmlFormsService.get.args[0]).to.deep.equal([ 'training:a_form_id' ]); + expect(xmlFormsService.getFormConfig.args).to.deep.equal([[ 'training-card', 'training:a_form_id' ]]); expect(formService.render.calledOnce).to.be.true; - expect(formService.render.args[0][0].formDoc).to.deep.equal(xmlForm); + expect(formService.render.args[0][0].formConfig).to.deep.equal(formConfig); expect(component.form).to.equal(renderedForm); expect(consoleErrorMock.notCalled).to.be.true; expect(feedbackService.submit.notCalled).to.be.true; @@ -310,13 +305,13 @@ describe('TrainingCardsFormComponent', () => { })); it('should catch form loading errors', fakeAsync(() => { - xmlFormsService.get.rejects({ error: 'boom' }); + xmlFormsService.getFormConfig.rejects({ error: 'boom' }); sinon.resetHistory(); store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id'); store.refreshState(); tick(); - expect(xmlFormsService.get.calledOnce).to.be.true; + expect(xmlFormsService.getFormConfig.calledOnce).to.be.true; expect(formService.render.notCalled).to.be.true; expect(consoleErrorMock.calledOnce).to.be.true; expect(consoleErrorMock.args[0]).to.deep.equal([ @@ -329,14 +324,14 @@ describe('TrainingCardsFormComponent', () => { })); it('should catch enketo errors', fakeAsync(() => { - xmlFormsService.get.resolves({ _id: 'training:a_form_id', some: 'content' }); + xmlFormsService.getFormConfig.resolves({ doc: { _id: 'training:a_form_id', some: 'content' } }); formService.render.rejects({ some: 'error' }); sinon.resetHistory(); store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id'); store.refreshState(); tick(); - expect(xmlFormsService.get.calledOnce).to.be.true; + expect(xmlFormsService.getFormConfig.calledOnce).to.be.true; expect(formService.render.calledOnce).to.be.true; expect(component.form).to.equal(null); expect(consoleErrorMock.calledOnce).to.be.true; diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts index 45ff01d8b46..f14bd781928 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts @@ -19,10 +19,11 @@ import { DbService } from '@mm-services/db.service'; import { Selectors } from '@mm-selectors/index'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { DuplicatesFoundError, FormService } from '@mm-services/form.service'; +import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormValidationError } from '@mm-services/enketo.service'; import { GlobalActions } from '@mm-actions/global'; import { TelemetryService } from '@mm-services/telemetry.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; -import events from 'enketo-core/src/js/event'; import { CONTACT_TYPES } from '@medic/constants'; @@ -44,6 +45,7 @@ describe('ContactsEdit component', () => { let telemetryService; let chtDatasourceService; let getContact; + let getFormConfig; let fileReaderService; const loadContactSummary = sinon.stub(); @@ -78,6 +80,7 @@ describe('ContactsEdit component', () => { saveContact: sinon.stub(), loadContactSummary: loadContactSummary, }; + getFormConfig = sinon.stub().resolves({ doc: {}, type: 'contact' }); stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; lineageModelGeneratorService = { contact: sinon.stub().resolves({ doc: {} }) }; @@ -116,6 +119,7 @@ describe('ContactsEdit component', () => { { provide: PerformanceService, useValue: performanceService }, { provide: TelemetryService, useValue: telemetryService }, { provide: CHTDatasourceService, useValue: chtDatasourceService }, + { provide: XmlFormsService, useValue: { getFormConfig } }, { provide: FileReaderService, useValue: fileReaderService }, { provide: HttpClient, useValue: {} }, ], @@ -306,11 +310,13 @@ describe('ContactsEdit component', () => { create_form: 'other_create', create_key: 'other_key', }); - dbGet - .withArgs('random_create') - .resolves({ _id: 'random_create', the: 'form' }) - .withArgs('other_create') - .resolves({ _id: 'other_create' }); + const randomConfig = { doc: { _id: 'random_create', the: 'form' }, type: 'contact' }; + const otherConfig = { doc: { _id: 'other_create' }, type: 'contact' }; + getFormConfig + .withArgs('contact', 'random_create') + .resolves(randomConfig) + .withArgs('contact', 'other_create') + .resolves(otherConfig); await createComponent(); await fixture.whenStable(); @@ -319,7 +325,7 @@ describe('ContactsEdit component', () => { expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'random_create', the: 'form' }, + formConfig: randomConfig, instanceData: { random: { type: 'contact', contact_type: 'random', parent: 'the_district' } }, titleKey: 'random', }); @@ -330,13 +336,13 @@ describe('ContactsEdit component', () => { await fixture.whenStable(); flushMicrotasks(); - expect(dbGet.args).to.deep.equal([['random_create'], ['other_create']]); + expect(getFormConfig.args).to.deep.equal([['contact', 'random_create'], ['contact', 'other_create']]); expect(getContact.args).to.deep.equal([[Qualifier.byUuid('the_district')], [Qualifier.byUuid('the_district')]]); expect(contactTypesService.get.callCount).to.equal(2); expect(formService.render.callCount).to.equal(2); expect(formService.render.args[1][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'other_create' }, + formConfig: otherConfig, instanceData: { other: { type: 'contact', contact_type: 'other', parent: 'the_district' } }, titleKey: 'other_key', }); @@ -353,7 +359,7 @@ describe('ContactsEdit component', () => { expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal([undefined]); - expect(dbGet.callCount).to.equal(0); + expect(getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(component.enketoContact).to.deep.equal(undefined); }); @@ -366,7 +372,7 @@ describe('ContactsEdit component', () => { expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['random']); - expect(dbGet.callCount).to.equal(0); + expect(getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(component.enketoContact).to.deep.equal(undefined); }); @@ -401,15 +407,14 @@ describe('ContactsEdit component', () => { create_form: 'person_create_form_id', create_key: 'person_create_key', }); - dbGet.rejects({ status: 404 }); + getFormConfig.rejects({ status: 404 }); await createComponent(); await fixture.whenStable(); expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['person']); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0]).to.deep.equal(['person_create_form_id']); + expect(getFormConfig.args).to.deep.equal([['contact', 'person_create_form_id']]); expect(formService.render.callCount).to.equal(0); expect(component.enketoContact).to.deep.equal(undefined); expect(component.contentError).to.equal(true); @@ -425,14 +430,15 @@ describe('ContactsEdit component', () => { getContact .withArgs(Qualifier.byUuid('the_district')) .resolves({ _id: 'the_district', type: CONTACT_TYPES.CLINIC }); - dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); + const formConfig = { doc: { _id: 'clinic_create_form_id', the: 'form' }, type: 'contact' }; + getFormConfig.resolves(formConfig); await createComponent(); await fixture.whenStable(); expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['clinic']); - expect(dbGet.calledOnceWithExactly('clinic_create_form_id')).to.be.true; + expect(getFormConfig.calledOnceWithExactly('contact', 'clinic_create_form_id')).to.be.true; expect(getContact.calledOnceWithExactly(Qualifier.byUuid('the_district'))).to.be.true; expect(component.enketoContact).to.deep.equal({ type: CONTACT_TYPES.CLINIC, @@ -442,7 +448,7 @@ describe('ContactsEdit component', () => { expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'clinic_create_form_id', the: 'form' }, + formConfig, instanceData: { clinic: { type: 'contact', contact_type: CONTACT_TYPES.CLINIC, parent: 'the_district' } }, titleKey: 'clinic_create_key', }); @@ -462,7 +468,8 @@ describe('ContactsEdit component', () => { create_form: 'district_create_form_id', create_key: 'district_create_key', }); - dbGet.resolves({ _id: 'district_create_form_id', the: 'form' }); + const formConfig = { doc: { _id: 'district_create_form_id', the: 'form' }, type: 'contact' }; + getFormConfig.resolves(formConfig); await createComponent(); await fixture.whenStable(); @@ -470,8 +477,7 @@ describe('ContactsEdit component', () => { expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['district_hospital']); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0]).to.deep.equal(['district_create_form_id']); + expect(getFormConfig.args).to.deep.equal([['contact', 'district_create_form_id']]); expect(component.enketoContact).to.deep.equal({ type: CONTACT_TYPES.DISTRICT_HOSPITAL, formInstance: undefined, @@ -480,8 +486,8 @@ describe('ContactsEdit component', () => { expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'district_create_form_id', the: 'form' }, - instanceData: { district_hospital: { type: 'contact', + formConfig, + instanceData: { district_hospital: { type: 'contact', contact_type: CONTACT_TYPES.DISTRICT_HOSPITAL, parent: '' } }, titleKey: 'district_create_key', }); @@ -512,7 +518,7 @@ describe('ContactsEdit component', () => { expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['missing_clinic_type']); - expect(dbGet.callCount).to.equal(0); + expect(getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(component.enketoContact).to.deep.equal(undefined); }); @@ -532,7 +538,7 @@ describe('ContactsEdit component', () => { expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['person_type']); - expect(dbGet.callCount).to.equal(0); + expect(getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(component.enketoContact).to.deep.equal(undefined); }); @@ -550,14 +556,13 @@ describe('ContactsEdit component', () => { create_form: 'patient_create_form', edit_key: 'patient_edit_key', }); - dbGet.rejects({ status: 404 }); + getFormConfig.rejects({ status: 404 }); await createComponent(); await fixture.whenStable(); expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['patient']); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0]).to.deep.equal(['patient_edit_form']); + expect(getFormConfig.args).to.deep.equal([['contact', 'patient_edit_form']]); expect(formService.render.callCount).to.equal(0); expect(component.enketoContact).to.deep.equal(undefined); expect(component.contentError).to.equal(true); @@ -576,18 +581,18 @@ describe('ContactsEdit component', () => { create_form: 'patient_create_form', edit_key: 'patient_edit_key', }); - dbGet.resolves({ _id: 'patient_edit_form', form: true }); + const formConfig = { doc: { _id: 'patient_edit_form', form: true }, type: 'contact' }; + getFormConfig.resolves(formConfig); await createComponent(); await fixture.whenStable(); expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['patient']); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0]).to.deep.equal(['patient_edit_form']); + expect(getFormConfig.args).to.deep.equal([['contact', 'patient_edit_form']]); expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'patient_edit_form', form: true }, + formConfig, instanceData: { patient: { type: 'patient', _id: 'the_patient' } }, titleKey: 'patient_edit_key', }); @@ -618,18 +623,18 @@ describe('ContactsEdit component', () => { create_form: 'a_clinic_type_create_form', edit_key: 'edit_key', }); - dbGet.resolves({ _id: 'a_clinic_type_create_form', data: true }); + const formConfig = { doc: { _id: 'a_clinic_type_create_form', data: true }, type: 'contact' }; + getFormConfig.resolves(formConfig); await createComponent(); await fixture.whenStable(); expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['a_clinic_type']); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0]).to.deep.equal(['a_clinic_type_create_form']); + expect(getFormConfig.args).to.deep.equal([['contact', 'a_clinic_type_create_form']]); expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'a_clinic_type_create_form', data: true }, + formConfig, instanceData: { a_clinic_type: { type: 'contact', contact_type: 'a_clinic_type', _id: 'the_clinic' } }, titleKey: 'edit_key', }); @@ -662,20 +667,20 @@ describe('ContactsEdit component', () => { edit_form: 'the correct_edit_form', edit_key: 'edit_key', }); - dbGet.resolves({ _id: 'the correct_edit_form', data: true }); + const formConfig = { doc: { _id: 'the correct_edit_form', data: true }, type: 'contact' }; + getFormConfig.resolves(formConfig); await createComponent(); await fixture.whenStable(); expect(contactTypesService.get.callCount).to.equal(1); expect(contactTypesService.get.args[0]).to.deep.equal(['the correct type']); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0]).to.deep.equal(['the correct_edit_form']); + expect(getFormConfig.args).to.deep.equal([['contact', 'the correct_edit_form']]); expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-form', - formDoc: { _id: 'the correct_edit_form', data: true }, - instanceData: { 'the correct type': { type: CONTACT_TYPES.CLINIC, + formConfig, + instanceData: { 'the correct type': { type: CONTACT_TYPES.CLINIC, contact_type: 'a_clinic_type', _id: 'the_clinic' } }, titleKey: 'edit_key', }); @@ -747,7 +752,7 @@ describe('ContactsEdit component', () => { create_form: 'person_create_form', create_key: 'person_create_key', }); - dbGet.resolves({ _id: 'person_create_form', form: true }); + getFormConfig.resolves({ doc: { _id: 'person_create_form', form: true }, type: 'contact' }); await createComponent(); tick(); @@ -772,7 +777,7 @@ describe('ContactsEdit component', () => { edit_form: 'person_edit_form', edit_key: 'person_edit_key', }); - dbGet.onCall(0).resolves({ _id: 'person_edit_form', form: true }); + getFormConfig.resolves({ doc: { _id: 'person_edit_form', form: true }, type: 'contact' }); const attachmentBlob = { attachment: 'blob' }; getAttachment.resolves(attachmentBlob); const base64 = 'base64'; @@ -814,7 +819,7 @@ describe('ContactsEdit component', () => { edit_form: 'person_edit_form', edit_key: 'person_edit_key', }); - dbGet.resolves({ _id: 'person_edit_form', form: true }); + getFormConfig.resolves({ doc: { _id: 'person_edit_form', form: true }, type: 'contact' }); await createComponent(); tick(); @@ -847,7 +852,7 @@ describe('ContactsEdit component', () => { edit_form: 'person_edit_form', edit_key: 'person_edit_key', }); - dbGet.resolves({ _id: 'person_edit_form', form: true }); + getFormConfig.resolves({ doc: { _id: 'person_edit_form', form: true }, type: 'contact' }); const expectedError = new Error('some error'); getAttachment.onFirstCall().rejects(expectedError); @@ -895,26 +900,24 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(0); }); - it('should not save when invalid', async () => { + it('should handle form validation errors silently', async () => { await createComponent(); await fixture.whenStable(); component.enketoContact = { - formInstance: { - validate: sinon.stub().resolves(false), - view: { html: { dispatchEvent: sinon.stub() } }, - }, + formInstance: { the: 'form instance' }, + type: 'some_contact', }; + formService.saveContact.rejects(new FormValidationError()); await component.save(); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); + // Only the initial reset - no error message is shown for form validation errors expect(setEnketoError.callCount).to.equal(1); expect(setEnketoError.args).to.deep.equal([[null]]); - expect(component.enketoContact.formInstance.validate.callCount).to.equal(1); - expect(formService.saveContact.callCount).to.equal(0); + expect(formService.saveContact.callCount).to.equal(1); expect(telemetryService.record.notCalled).to.be.true; - expect(component.enketoContact.formInstance.view.html.dispatchEvent).to.not.have.been.called; }); it('should catch save errors', async () => { @@ -922,10 +925,7 @@ describe('ContactsEdit component', () => { await fixture.whenStable(); component.enketoContact = { - formInstance: { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }, + formInstance: { the: 'form instance' }, type: 'some_contact', }; formService.saveContact.rejects({ some: 'error' }); @@ -934,15 +934,11 @@ describe('ContactsEdit component', () => { await component.save(); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); - expect(component.enketoContact.formInstance.validate.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(setEnketoError.callCount).to.equal(2); expect(telemetryService.record.notCalled).to.be.true; // Any duplicates should be cleared when the error is not DuplicatesFoundError expect(component.duplicates).to.be.empty; - expect( - component.enketoContact.formInstance.view.html.dispatchEvent - ).to.have.been.calledOnceWithExactly(events.BeforeSave()); }); it('when saving new contact', async () => { @@ -955,11 +951,8 @@ describe('ContactsEdit component', () => { getContact .withArgs(Qualifier.byUuid('the_district')) .resolves({ _id: 'the_district', type: CONTACT_TYPES.CLINIC }); - dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); - const form = { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }; + getFormConfig.resolves({ doc: { _id: 'clinic_create_form_id', the: 'form' }, type: 'contact' }); + const form = { the: 'form instance' }; formService.render.resolves(form); await createComponent(); @@ -981,23 +974,19 @@ describe('ContactsEdit component', () => { name: 'enketo:contacts:clinic_create_form_id:add:save', recordApdex: true, }); - expect(dbGet.calledOnceWithExactly('clinic_create_form_id')).to.be.true; + expect(getFormConfig.calledOnceWithExactly('contact', 'clinic_create_form_id')).to.be.true; expect(getContact.calledOnceWithExactly(Qualifier.byUuid('the_district'))).to.be.true; expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); - expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ - { docId: null, type: CONTACT_TYPES.CLINIC }, - { form, xmlVersion: undefined, duplicateCheck: undefined }, + expect(formService.saveContact.args).to.deep.equal([[ + { docId: null, type: CONTACT_TYPES.CLINIC }, + form, false - ]); + ]]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); expect(telemetryService.record.notCalled).to.be.true; - expect( - component.enketoContact.formInstance.view.html.dispatchEvent - ).to.have.been.calledOnceWithExactly(events.BeforeSave()); }); it('when editing existent contact of hardcoded type', async () => { @@ -1013,11 +1002,8 @@ describe('ContactsEdit component', () => { edit_form: 'person_edit_form_id', create_key: 'person_create_key', }); - dbGet.resolves({ _id: 'person_edit_form_id', the: 'form' }); - const form = { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }; + getFormConfig.resolves({ doc: { _id: 'person_edit_form_id', the: 'form' }, type: 'contact' }); + const form = { the: 'form instance' }; formService.render.resolves(form); await createComponent(); @@ -1033,7 +1019,7 @@ describe('ContactsEdit component', () => { expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal([ { docId: 'the_person', type: 'person', }, - { form, xmlVersion: undefined, duplicateCheck: undefined }, + form, false ]); expect(router.navigate.callCount).to.equal(1); @@ -1052,9 +1038,6 @@ describe('ContactsEdit component', () => { recordApdex: true, }); expect(telemetryService.record.notCalled).to.be.true; - expect( - component.enketoContact.formInstance.view.html.dispatchEvent - ).to.have.been.calledOnceWithExactly(events.BeforeSave()); }); it('when editing existent contact of configurable type', async () => { @@ -1070,11 +1053,8 @@ describe('ContactsEdit component', () => { create_form: 'patient_create_form_id', create_key: 'patient_create_key', }); - dbGet.resolves({ _id: 'patient_create_form_id', the: 'form' }); - const form = { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }; + getFormConfig.resolves({ doc: { _id: 'patient_create_form_id', the: 'form' }, type: 'contact' }); + const form = { the: 'form instance' }; formService.render.resolves(form); await createComponent(); @@ -1089,8 +1069,8 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal([ - { docId: 'the_patient', type: 'patient' }, - { form, xmlVersion: undefined, duplicateCheck: undefined }, + { docId: 'the_patient', type: 'patient' }, + form, false ]); expect(router.navigate.callCount).to.equal(1); @@ -1109,9 +1089,6 @@ describe('ContactsEdit component', () => { recordApdex: true, }); expect(telemetryService.record.notCalled).to.be.true; - expect( - component.enketoContact.formInstance.view.html.dispatchEvent - ).to.have.been.calledOnceWithExactly(events.BeforeSave()); }); it('should catch duplicate siblings', async () => { @@ -1124,11 +1101,8 @@ describe('ContactsEdit component', () => { getContact .withArgs(Qualifier.byUuid('the_district')) .resolves({ _id: 'the_district', type: CONTACT_TYPES.CLINIC }); - dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); - const form = { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }; + getFormConfig.resolves({ doc: { _id: 'clinic_create_form_id', the: 'form' }, type: 'contact' }); + const form = { the: 'form instance' }; formService.render.resolves(form); await createComponent(); @@ -1150,8 +1124,7 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(getContact.calledOnceWithExactly(Qualifier.byUuid('the_district'))).to.be.true; - expect(dbGet.calledOnceWithExactly('clinic_create_form_id')).to.be.true; - expect(component.enketoContact.formInstance.validate.callCount).to.equal(1); + expect(getFormConfig.calledOnceWithExactly('contact', 'clinic_create_form_id')).to.be.true; expect(formService.saveContact.callCount).to.equal(1); expect(setEnketoError.callCount).to.equal(2); expect(component.duplicates.length).to.equal(1); @@ -1170,11 +1143,8 @@ describe('ContactsEdit component', () => { getContact .withArgs(Qualifier.byUuid('the_district')) .resolves({ _id: 'the_district', type: CONTACT_TYPES.CLINIC }); - dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); - const form = { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }; + getFormConfig.resolves({ doc: { _id: 'clinic_create_form_id', the: 'form' }, type: 'contact' }); + const form = { the: 'form instance' }; formService.render.resolves(form); const setEnketoError = sinon.stub(GlobalActions.prototype, 'setEnketoError'); await createComponent(); @@ -1202,7 +1172,7 @@ describe('ContactsEdit component', () => { expect(component.duplicatesAcknowledged).to.equal(true); expect(formService.saveContact.args[0]).to.deep.equal([ { docId: null, type: CONTACT_TYPES.CLINIC }, - { form, xmlVersion: undefined, duplicateCheck: undefined }, + form, true ]); expect(telemetryService.record.calledOnceWithExactly( @@ -1230,11 +1200,8 @@ describe('ContactsEdit component', () => { getContact .withArgs(Qualifier.byUuid('the_district')) .resolves({ _id: 'the_district', type: CONTACT_TYPES.CLINIC }); - dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); - const form = { - validate: sinon.stub().resolves(true), - view: { html: { dispatchEvent: sinon.stub() } }, - }; + getFormConfig.resolves({ doc: { _id: 'clinic_create_form_id', the: 'form' }, type: 'contact' }); + const form = { the: 'form instance' }; formService.render.resolves(form); const setEnketoError = sinon.stub(GlobalActions.prototype, 'setEnketoError'); await createComponent(); @@ -1252,7 +1219,7 @@ describe('ContactsEdit component', () => { expect(component.duplicatesAcknowledged).to.equal(true); expect(formService.saveContact.args[0]).to.deep.equal([ { docId: null, type: CONTACT_TYPES.CLINIC }, - { form, xmlVersion: undefined, duplicateCheck: undefined }, + form, true ]); expect(telemetryService.record.notCalled).to.be.true; diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-report.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-report.component.spec.ts index 37b31ebf732..003efd9f4b9 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-report.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-report.component.spec.ts @@ -40,7 +40,7 @@ describe('contacts report component', () => { save: sinon.stub(), render: sinon.stub().resolves(), }; - xmlFormsService = { get: sinon.stub().resolves({ title: 'formTitle' }) }; + xmlFormsService = { getFormConfig: sinon.stub().resolves({ doc: { title: 'formTitle' } }) }; geoHandle = { cancel: sinon.stub() }; geolocationService = { init: sinon.stub().returns(geoHandle) }; stopPerformanceTrackStub = sinon.stub(); @@ -160,7 +160,7 @@ describe('contacts report component', () => { expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#contact-report', - formDoc: { title: 'formTitle' }, + formConfig: { doc: { title: 'formTitle' } }, instanceData: { source: 'contact', contact: { @@ -182,8 +182,8 @@ describe('contacts report component', () => { component.ngOnInit(); flush(); - expect(xmlFormsService.get.callCount).to.equal(1); - expect(xmlFormsService.get.args[0][0]).to.equal('pregnancy_danger_sign'); + expect(xmlFormsService.getFormConfig.callCount).to.equal(1); + expect(xmlFormsService.getFormConfig.args[0]).to.deep.equal(['report', 'pregnancy_danger_sign']); expect(formService.render.callCount).to.equal(1); routeSnapshot = { @@ -199,8 +199,8 @@ describe('contacts report component', () => { }); flush(); expect(formService.render.callCount).to.equal(2); - expect(xmlFormsService.get.callCount).to.equal(2); - expect(xmlFormsService.get.args[1][0]).to.equal('pregnancy_home_vist'); + expect(xmlFormsService.getFormConfig.callCount).to.equal(2); + expect(xmlFormsService.getFormConfig.args[1]).to.deep.equal(['report', 'pregnancy_home_vist']); })); }); diff --git a/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts b/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts index 6739e02a5e7..8da5e279c45 100644 --- a/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts +++ b/webapp/tests/karma/ts/modules/reports/reports-add.component.spec.ts @@ -43,7 +43,7 @@ describe('Reports Add Component', () => { dbService = { getAttachment: sinon.stub() }; fileReaderService = { base64: sinon.stub() }; getReportContentService = { getReportContent: sinon.stub().resolves() }; - xmlFormsService = { get: sinon.stub().resolves() }; + xmlFormsService = { getFormConfig: sinon.stub().resolves() }; lineageModelGeneratorService = { report: sinon.stub().resolves({ doc: {} }) }; geoHandle = { cancel: sinon.stub() }; geolocationService = { init: sinon.stub().returns(geoHandle) }; @@ -195,9 +195,9 @@ describe('Reports Add Component', () => { route.snapshot.params = { formId: 'my_form' }; const openReportContentStub = sinon.stub(ReportsActions.prototype, 'openReportContent'); getReportContentService.getReportContent.resolves(); - const xmlForm = { _id: 'my_form', some: 'content' }; + const formConfig = { doc: { _id: 'my_form', some: 'content' } }; const renderedForm = { rendered: 'form', model: {}, instance: {} }; - xmlFormsService.get.resolves(xmlForm); + xmlFormsService.getFormConfig.resolves(formConfig); formService.render.resolves(renderedForm); const setEnketoEditedStatusStub = sinon.stub(GlobalActions.prototype, 'setEnketoEditedStatus'); const setEnketoErrorStub = sinon.stub(GlobalActions.prototype, 'setEnketoError'); @@ -216,12 +216,12 @@ describe('Reports Add Component', () => { expect(openReportContentStub.args[0]).to.deep.equal([{ formInternalId: 'my_form' }]); expect(getReportContentService.getReportContent.calledOnce).to.be.true; expect(getReportContentService.getReportContent.args[0]).to.deep.equal([undefined]); - expect(xmlFormsService.get.calledOnce).to.be.true; - expect(xmlFormsService.get.args[0]).to.deep.equal(['my_form']); + expect(xmlFormsService.getFormConfig.calledOnce).to.be.true; + expect(xmlFormsService.getFormConfig.args[0]).to.deep.equal(['report', 'my_form']); expect(setEnketoEditedStatusStub.calledOnce).to.be.true; expect(setEnketoEditedStatusStub.args[0]).to.deep.equal([false]); expect(formService.render.calledOnce).to.be.true; - expect(formService.render.args[0][0].formDoc).to.deep.equal(xmlForm); + expect(formService.render.args[0][0].formConfig).to.deep.equal(formConfig); expect(component.form).to.equal(renderedForm); const markFormEdited = formService.render.args[0][0].editedListener; @@ -250,12 +250,12 @@ describe('Reports Add Component', () => { it('should catch form reading errors', fakeAsync(() => { sinon.resetHistory(); - xmlFormsService.get.rejects({ error: 'boom' }); + xmlFormsService.getFormConfig.rejects({ error: 'boom' }); component.ngAfterViewInit(); tick(); - expect(xmlFormsService.get.callCount).to.equal(1); + expect(xmlFormsService.getFormConfig.callCount).to.equal(1); expect(formService.render.callCount).to.equal(0); expect(error.callCount).to.equal(1); expect(error.args[0][0]).to.equal('Error setting selected doc'); @@ -264,13 +264,13 @@ describe('Reports Add Component', () => { it('should catch enketo errors', fakeAsync(() => { sinon.resetHistory(); getReportContentService.getReportContent.resolves(); - xmlFormsService.get.resolves({ _id: 'my_form', some: 'content' }); + xmlFormsService.getFormConfig.resolves({ doc: { _id: 'my_form', some: 'content' } }); formService.render.rejects({ some: 'error' }); component.ngAfterViewInit(); tick(); - expect(xmlFormsService.get.callCount).to.equal(1); + expect(xmlFormsService.getFormConfig.callCount).to.equal(1); expect(formService.render.callCount).to.equal(1); expect(component.form).to.equal(undefined); expect(error.callCount).to.equal(1); @@ -338,8 +338,8 @@ describe('Reports Add Component', () => { const model = { doc, formInternalId: doc.form }; const reportContent = { hello: 'world' }; getReportContentService.getReportContent.resolves(reportContent); - const xmlForm = { _id: 'my_form', some: 'content' }; - xmlFormsService.get.resolves(xmlForm); + const formConfig = { doc: { _id: 'my_form', some: 'content' } }; + xmlFormsService.getFormConfig.resolves(formConfig); const renderedForm = { rendered: 'form', model: {}, instance: {} }; formService.render.resolves(renderedForm); @@ -353,14 +353,13 @@ describe('Reports Add Component', () => { expect(openReportContent.args).to.deep.equal([[model]]); expect(setLoadingContent.args).to.deep.equal([[true], [false]]); expect(getReportContentService.getReportContent.calledOnceWithExactly(doc)).to.be.true; - expect(xmlFormsService.get.calledOnceWithExactly(model.formInternalId)).to.be.true; + expect(xmlFormsService.getFormConfig.calledOnceWithExactly('report', model.formInternalId)).to.be.true; expect(setEnketoEditedStatus.calledOnceWithExactly(false)).to.be.true; expect(formService.render.calledOnce).to.be.true; expect(formService.render.args[0][0]).to.deep.include({ - formDoc: xmlForm, + formConfig, editing: true, selector: '#report-form', - type: 'report', instanceData: reportContent }); expect(component.form).to.equal(renderedForm); @@ -569,7 +568,6 @@ describe('Reports Add Component', () => { expect(router.navigate.callCount).to.equal(0); expect(formService.save.callCount).to.equal(1); expect(formService.save.args[0]).to.deep.equal([ - 'some_form', { the: 'rendered form' }, geoHandle, undefined, //no report id @@ -616,7 +614,6 @@ describe('Reports Add Component', () => { expect(router.navigate.callCount).to.equal(0); expect(formService.save.callCount).to.equal(1); expect(formService.save.args[0]).to.deep.equal([ - 'delivery', { the: 'the form' }, geoHandle, undefined, //no report id diff --git a/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts b/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts index 4e266dc0770..95914fcbd03 100644 --- a/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts @@ -48,7 +48,7 @@ describe('TasksContentComponent', () => { stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; render = sinon.stub().resolves(); - xmlFormsService = { get: sinon.stub().resolves() }; + xmlFormsService = { getFormConfig: sinon.stub().resolves() }; getContact = sinon.stub().resolves({ _id: 'contact' }); route = { params: new Observable(obs => obs.next({ id: '123' })) }; setEnketoEditedStatus = sinon.stub(GlobalActions.prototype, 'setEnketoEditedStatus'); @@ -116,8 +116,8 @@ describe('TasksContentComponent', () => { content: 'nothing' }] }]; - const form = { _id: 'myform', title: 'My Form' }; - xmlFormsService.get.resolves(form); + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; + xmlFormsService.getFormConfig.resolves(formConfig); await compileComponent(); const spySubscriptionsUnsubscribe = sinon.spy(component.subscription, 'unsubscribe'); @@ -143,18 +143,17 @@ describe('TasksContentComponent', () => { content: 'nothing' }] }]; - const form = { _id: 'myform', title: 'My Form' }; - xmlFormsService.get.resolves(form); + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; + xmlFormsService.getFormConfig.resolves(formConfig); await compileComponent(); - expect(component.formId).to.equal('A'); + expect(xmlFormsService.getFormConfig.calledOnceWithExactly('task', 'A')).to.be.true; expect(render.callCount).to.equal(1); expect(render.args[0][0]).to.deep.include({ selector: '#task-report', - type: 'task', - formDoc: form, + formConfig, instanceData: 'nothing', }); @@ -193,8 +192,8 @@ describe('TasksContentComponent', () => { }, }] }]; - const form = { _id: 'myform', title: 'My Form' }; - xmlFormsService.get.resolves(form); + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; + xmlFormsService.getFormConfig.resolves(formConfig); geolocationService.init.returns({ just: 'an object reference', cancel: sinon.stub() }); await compileComponent(); @@ -230,8 +229,8 @@ describe('TasksContentComponent', () => { }] }]; const setSelectedTask = sinon.stub(TasksActions.prototype, 'setSelectedTask'); - const form = { _id: 'myform', title: 'My Form' }; - xmlFormsService.get.resolves(form); + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; + xmlFormsService.getFormConfig.resolves(formConfig); geolocationService.init.returns({ just: 'an object reference', cancel: sinon.stub() }); await compileComponent(); @@ -274,8 +273,8 @@ describe('TasksContentComponent', () => { form: 'A', }] }]; - const form = { _id: 'myform', title: 'My Form' }; - xmlFormsService.get.resolves(form); + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; + xmlFormsService.getFormConfig.resolves(formConfig); await compileComponent(); @@ -286,7 +285,7 @@ describe('TasksContentComponent', () => { it('should work when form not found', async () => { const consoleErrorMock = sinon.stub(console, 'error'); - xmlFormsService.get.rejects({ status: 404 }); + xmlFormsService.getFormConfig.rejects({ status: 404 }); tasks = [{ _id: '123', forId: 'dne', @@ -313,7 +312,7 @@ describe('TasksContentComponent', () => { await compileComponent(); - expect(component.formId).to.equal(null); + expect(component.form).to.equal(undefined); expect(component.loadingForm).to.equal(undefined); expect(render.callCount).to.equal(0); }); @@ -338,7 +337,7 @@ describe('TasksContentComponent', () => { await compileComponent(); - expect(component.formId).to.equal(null); + expect(component.form).to.equal(undefined); expect(component.loadingForm).to.equal(undefined); expect(render.callCount).to.equal(0); }); @@ -354,7 +353,7 @@ describe('TasksContentComponent', () => { content: 'nothing' }] }]; - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); await compileComponent(); @@ -376,8 +375,8 @@ describe('TasksContentComponent', () => { }, }] }; - const form = { _id: 'myform', title: 'My Form' }; - xmlFormsService.get.resolves(form); + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; + xmlFormsService.getFormConfig.resolves(formConfig); store.overrideSelector(Selectors.getTasksLoaded, false); store.refreshState(); @@ -408,21 +407,21 @@ describe('TasksContentComponent', () => { }); it('should do nothing for random action type', async () => { - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); await compileComponent([]); sinon.resetHistory(); await component.performAction(undefined); - expect(xmlFormsService.get.callCount).to.equal(0); + expect(xmlFormsService.getFormConfig.callCount).to.equal(0); expect((GlobalActions.prototype.setCancelCallback).callCount).to.equal(0); }); it('should set cancel callback correctly when not skipping details', async () => { - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); await compileComponent([]); sinon.resetHistory(); - component.form = 'someform'; + component.form = 'someform' as any; component.loadingForm = true; component.contentError = true; await component.performAction({}); @@ -437,7 +436,7 @@ describe('TasksContentComponent', () => { expect((TasksActions.prototype.setSelectedTask).args[0]).to.deep.equal([null]); expect(formService.unload.callCount).to.equal(1); - expect(component.form).to.equal(null); + expect(component.form).to.equal(undefined); expect(component.loadingForm).to.equal(false); expect(component.contentError).to.equal(false); expect((GlobalActions.prototype.clearNavigation).callCount).to.equal(1); @@ -446,11 +445,11 @@ describe('TasksContentComponent', () => { }); it('should set cancel callback correctly when skipping details', async () => { - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); await compileComponent([]); sinon.resetHistory(); - component.form = 'someform'; + component.form = 'someform' as any; component.loadingForm = true; component.contentError = true; await component.performAction({}, true); @@ -481,7 +480,7 @@ describe('TasksContentComponent', () => { const action = { type: 'contact', content: { parent_id: 'district_hospital_uuid', type: 'c_type' } }; await component.performAction(action); - expect(xmlFormsService.get.callCount).to.equal(0); + expect(xmlFormsService.getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'district_hospital_uuid', 'add', 'c_type']]); @@ -494,7 +493,7 @@ describe('TasksContentComponent', () => { const action = { type: 'contact', content: { type: 'c_type' } }; await component.performAction(action); - expect(xmlFormsService.get.callCount).to.equal(0); + expect(xmlFormsService.getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'add', 'c_type']]); @@ -515,7 +514,7 @@ describe('TasksContentComponent', () => { }; await component.performAction(action); - expect(xmlFormsService.get.callCount).to.equal(0); + expect(xmlFormsService.getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'my_contact']]); @@ -528,7 +527,7 @@ describe('TasksContentComponent', () => { const action = { type: 'contact', content: { edit_id: '123' } }; await component.performAction(action); - expect(xmlFormsService.get.callCount).to.equal(0); + expect(xmlFormsService.getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', '123', 'edit']]); @@ -541,7 +540,7 @@ describe('TasksContentComponent', () => { const action = { type: 'contact', content: { contact: { _id: 'my_contact' } } }; await component.performAction(action); - expect(xmlFormsService.get.callCount).to.equal(0); + expect(xmlFormsService.getFormConfig.callCount).to.equal(0); expect(formService.render.callCount).to.equal(0); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'my_contact']]); @@ -549,9 +548,9 @@ describe('TasksContentComponent', () => { }); it('should render form when action type is report', async () => { - const form = { _id: 'myform', title: 'My Form' }; + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; const action = { type: 'report', form: 'myform', content: { contact: { _id: 'my_contact' } } }; - xmlFormsService.get.resolves({ ...form }); + xmlFormsService.getFormConfig.resolves(formConfig); tasksForContactService.getLeafPlaceAncestor.resolves({ any: 'model' }); await compileComponent([]); @@ -563,13 +562,12 @@ describe('TasksContentComponent', () => { await component.performAction({ ...action }); - expect(xmlFormsService.get.callCount).to.equal(1); - expect(xmlFormsService.get.args[0]).to.deep.equal(['myform']); + expect(xmlFormsService.getFormConfig.callCount).to.equal(1); + expect(xmlFormsService.getFormConfig.args[0]).to.deep.equal(['task', 'myform']); expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#task-report', - type: 'task', - formDoc: form, + formConfig, instanceData: action.content, }); @@ -614,9 +612,9 @@ describe('TasksContentComponent', () => { }); it('should catch contact preloading errors', async () => { - const form = { _id: 'myform', title: 'My Form' }; + const formConfig = { doc: { _id: 'myform', title: 'My Form' } }; const action = { type: 'report', form: 'myform', content: { contact: { _id: 'the_contact' } } }; - xmlFormsService.get.resolves({ ...form }); + xmlFormsService.getFormConfig.resolves(formConfig); tasksForContactService.getLeafPlaceAncestor.rejects({ some: 'error' }); await compileComponent([]); @@ -629,13 +627,12 @@ describe('TasksContentComponent', () => { await component.performAction({ ...action }); - expect(xmlFormsService.get.callCount).to.equal(1); - expect(xmlFormsService.get.args[0]).to.deep.equal(['myform']); + expect(xmlFormsService.getFormConfig.callCount).to.equal(1); + expect(xmlFormsService.getFormConfig.args[0]).to.deep.equal(['task', 'myform']); expect(formService.render.callCount).to.equal(1); expect(formService.render.args[0][0]).to.deep.include({ selector: '#task-report', - type: 'task', - formDoc: { ...form }, + formConfig, instanceData: { ...action.content }, }); @@ -670,7 +667,7 @@ describe('TasksContentComponent', () => { }); it('should do nothing if already saving', async () => { - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); await compileComponent([]); store.overrideSelector(Selectors.getEnketoSavingStatus, true); @@ -682,7 +679,7 @@ describe('TasksContentComponent', () => { it('should catch save errors', async () => { const consoleErrorMock = sinon.stub(console, 'error'); - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); formService.save.rejects({ some: 'error' }); store.overrideSelector(Selectors.getEnketoError, 'error'); const geoHandle = { geo: 'handle', cancel: sinon.stub() }; @@ -690,8 +687,7 @@ describe('TasksContentComponent', () => { await compileComponent([]); sinon.resetHistory(); - component.formId = 'the form id'; - component.form = { the: 'form' }; + component.form = { the: 'form', config: { doc: { internalId: 'the form id' } } } as any; const saving = component.save(); expect(setEnketoSavingStatus.callCount).to.equal(1); @@ -702,7 +698,10 @@ describe('TasksContentComponent', () => { await saving; expect(formService.save.callCount).to.equal(1); - expect(formService.save.args[0]).to.deep.equal([ 'the form id', { the: 'form' }, geoHandle ]); + expect(formService.save.args[0]).to.deep.equal([ + { the: 'form', config: { doc: { internalId: 'the form id' } } }, + geoHandle + ]); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args[1]).to.deep.equal([false]); @@ -720,7 +719,7 @@ describe('TasksContentComponent', () => { }); it('should redirect correctly after save', async () => { - xmlFormsService.get.resolves({ id: 'myform', doc: { title: 'My Form' } }); + xmlFormsService.getFormConfig.resolves({ id: 'myform', doc: { title: 'My Form' } }); formService.save.resolves([]); const geoHandle = { geo: 'handle', cancel: sinon.stub() }; geolocationService.init.returns(geoHandle); @@ -728,8 +727,7 @@ describe('TasksContentComponent', () => { await compileComponent([]); sinon.resetHistory(); - component.formId = 'the form id'; - component.form = { the: 'form' }; + component.form = { the: 'form', config: { doc: { internalId: 'the form id' } } } as any; const saving = component.save(); @@ -740,7 +738,10 @@ describe('TasksContentComponent', () => { await saving; expect(formService.save.callCount).to.equal(1); - expect(formService.save.args[0]).to.deep.equal([ 'the form id', { the: 'form' }, geoHandle ]); + expect(formService.save.args[0]).to.deep.equal([ + { the: 'form', config: { doc: { internalId: 'the form id' } } }, + geoHandle + ]); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args[1]).to.deep.equal([false]); @@ -748,7 +749,9 @@ describe('TasksContentComponent', () => { expect(setEnketoEditedStatus.args[0]).to.deep.equal([false]); expect(formService.unload.callCount).to.equal(1); - expect(formService.unload.args[0]).to.deep.equal([{ the: 'form' }]); + expect(formService.unload.args[0]).to.deep.equal([ + { the: 'form', config: { doc: { internalId: 'the form id' } } } + ]); expect(clearNavigation.callCount).to.equal(1); expect(router.navigate.callCount).to.equal(1); @@ -779,7 +782,7 @@ describe('TasksContentComponent', () => { it('should call navigation cancel', async () => { await compileComponent(); const navigationCancel = sinon.stub(GlobalActions.prototype, 'navigationCancel'); - component.formId = 'the form id'; + component.form = { config: { doc: { internalId: 'the form id' } } } as any; component.navigationCancel(); expect(navigationCancel.callCount).to.equal(1); expect(navigationCancel.args[0]).to.deep.equal([]); From f10f949a889c4489dd464638726a09ffa885a031 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 16:08:49 -0500 Subject: [PATCH 24/41] Lock in more unit tests --- .../ts/services/attachment.service.spec.ts | 128 --- .../ts/services/contact-save.service.spec.ts | 731 --------------- .../enketo-translation.service.spec.ts | 850 ------------------ .../ts/services/form/form-config.spec.ts | 42 + .../ts/services/xml-forms.service.spec.ts | 204 ++--- 5 files changed, 131 insertions(+), 1824 deletions(-) delete mode 100644 webapp/tests/karma/ts/services/attachment.service.spec.ts delete mode 100644 webapp/tests/karma/ts/services/contact-save.service.spec.ts delete mode 100644 webapp/tests/karma/ts/services/enketo-translation.service.spec.ts create mode 100644 webapp/tests/karma/ts/services/form/form-config.spec.ts diff --git a/webapp/tests/karma/ts/services/attachment.service.spec.ts b/webapp/tests/karma/ts/services/attachment.service.spec.ts deleted file mode 100644 index 1bc93777af6..00000000000 --- a/webapp/tests/karma/ts/services/attachment.service.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import sinon from 'sinon'; -import { expect } from 'chai'; - -import { AttachmentService } from '@mm-services/attachment.service'; - -describe('Attachment service', () => { - let service; - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(AttachmentService); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('add', () => { - it('should work with no doc', () => { - const doc = undefined; - service.add(); - expect(doc).to.equal(undefined); - }); - - it('should add already encoded attachment', () => { - const doc = {}; - service.add(doc, 'attname', 'string', 'text/plain', true); - expect(doc).to.deep.equal({ - _attachments: { - attname: { - data: 'string', - content_type: 'text/plain' - } - } - }); - }); - - it('should encode and add attachment', () => { - const doc = { - _attachments: { - anAttachment: {}, - } - }; - service.add(doc, 'name', 'string', 'text/plain'); - expect(doc).to.deep.equal({ - _attachments: { - name: { - data: new Blob([ 'string' ], { type: 'text/plain' }), - content_type: 'text/plain' - }, - anAttachment: {} - } - }); - }); - - it('should overwrite existing attachment', () => { - const doc = { - _attachments: { - 'the_attachment.xml': { - data: 'whatever', - }, - } - }; - service.add(doc, 'the_attachment.xml', 'whaterver', 'font/woff2', true); - expect(doc).to.deep.equal({ - _attachments: { - 'the_attachment.xml': { - data: 'whaterver', - content_type: 'font/woff2' - }, - } - }); - }); - }); - - describe('remove', () => { - it('should work with no doc', () => { - service.remove(); - }); - - it('should work with doc with no attachments', () => { - const doc = { a: '1', b: 2 }; - service.remove(doc, 'attname'); - expect(doc).to.deep.equal( { a: '1', b: 2 }); - }); - - it('should work with doc with no attachment name', () => { - const doc = { a: '1', b: 2 }; - service.remove(doc); - expect(doc).to.deep.equal( { a: '1', b: 2 }); - }); - - it('should work with doc missing attachment', () => { - const doc = { - a: 1, - _attachments: { - att1: { data: 'a' }, - att2: { data: 'a' }, - }, - }; - service.remove(doc, 'noattachment'); - expect(doc).to.deep.equal({ - a: 1, - _attachments: { - att1: { data: 'a' }, - att2: { data: 'a' }, - }, - }); - }); - - it('should remove attachment', () => { - const doc = { - a: 1, - _attachments: { - att1: { data: 'a' }, - att2: { data: 'a' }, - }, - }; - service.remove(doc, 'att2'); - expect(doc).to.deep.equal({ - a: 1, - _attachments: { - att1: { data: 'a' }, - }, - }); - }); - }); -}); diff --git a/webapp/tests/karma/ts/services/contact-save.service.spec.ts b/webapp/tests/karma/ts/services/contact-save.service.spec.ts deleted file mode 100644 index ec045da2be4..00000000000 --- a/webapp/tests/karma/ts/services/contact-save.service.spec.ts +++ /dev/null @@ -1,731 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import sinon from 'sinon'; -import { expect } from 'chai'; -import { provideMockStore } from '@ngrx/store/testing'; -import { HttpClient } from '@angular/common/http'; -import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { AttachmentService } from '@mm-services/attachment.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; -import { Contact, Qualifier } from '@medic/cht-datasource'; -import * as FileManager from '../../../../src/js/enketo/file-manager.js'; - -describe('ContactSave service', () => { - - let service; - let enketoTranslationService; - let extractLineageService; - let attachmentService; - let clock; - let chtDatasourceService; - let getContact; - - beforeEach(() => { - enketoTranslationService = { - contactRecordToJs: sinon.stub(), - }; - - extractLineageService = { extract: sinon.stub() }; - attachmentService = { - add: sinon.stub(), - remove: sinon.stub() - }; - getContact = sinon.stub(); - chtDatasourceService = { bind: sinon.stub().withArgs(Contact.v1.get).returns(getContact) }; - TestBed.configureTestingModule({ - providers: [ - provideMockStore(), - { provide: EnketoTranslationService, useValue: enketoTranslationService }, - { provide: ExtractLineageService, useValue: extractLineageService }, - { provide: AttachmentService, useValue: attachmentService }, - { provide: CHTDatasourceService, useValue: chtDatasourceService }, - { provide: HttpClient, useValue: {} }, - ] - }); - - service = TestBed.inject(ContactSaveService); - }); - - afterEach(() => { - sinon.restore(); - clock?.restore(); - }); - - it('fetches and binds db types and minifies string contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'abc' } - }); - getContact.resolves({ _id: 'abc', name: 'gareth', parent: { _id: 'def' } }); - extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - - return service - .save(form, docId, type) - .then(({ preparedDocs: savedDocs }) => { - expect(getContact.calledOnceWithExactly(Qualifier.byUuid('abc'))).to.be.true; - - expect(savedDocs.length).to.equal(1); - expect(savedDocs[0].contact).to.deep.equal({ - _id: 'abc', - parent: { - _id: 'def' - } - }); - }); - }); - - it('fetches and binds db types and minifies object contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: { _id: 'abc', name: 'Richard' } } - }); - getContact.resolves({ _id: 'abc', name: 'Richard', parent: { _id: 'def' } }); - extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - - return service - .save(form, docId, type) - .then(({ preparedDocs: savedDocs }) => { - expect(getContact.calledOnceWithExactly(Qualifier.byUuid('abc'))).to.be.true; - - expect(savedDocs.length).to.equal(1); - expect(savedDocs[0].contact).to.deep.equal({ - _id: 'abc', - parent: { - _id: 'def' - } - }); - }); - }); - - it('should include parent ID in repeated children', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'NEW'}, - siblings: { - contact: { _id: 'sis1', type: 'sister', parent: 'PARENT', }, - }, - repeats: { - child_data: [ { _id: 'kid1', type: 'child', parent: 'PARENT', } ], - }, - }); - - extractLineageService.extract.callsFake(contact => { - contact.extracted = true; - return contact; - }); - - return service - .save(form, docId, type) - .then(({ preparedDocs: savedDocs }) => { - expect(savedDocs[0]._id).to.equal('main1'); - - expect(savedDocs[1]._id).to.equal('kid1'); - expect(savedDocs[1].parent._id).to.equal('main1'); - expect(savedDocs[1].parent.extracted).to.be.true; - - expect(savedDocs[2]._id).to.equal('sis1'); - expect(savedDocs[2].parent._id).to.equal('main1'); - expect(savedDocs[2].parent.extracted).to.be.true; - - expect(extractLineageService.extract.callCount).to.equal(3); - }); - }); - - it('should include form_version if provided', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'NEW'}, - siblings: { - contact: { _id: 'sis1', type: 'sister', parent: 'PARENT', }, - }, - repeats: { - child_data: [ { _id: 'kid1', type: 'child', parent: 'PARENT', } ], - }, - }); - - extractLineageService.extract.callsFake(contact => { - contact.extracted = true; - return contact; - }); - - const xmlVersion = { - time: 123456, - sha256: '654321' - }; - - return service - .save(form, docId, type, xmlVersion) - .then(({ preparedDocs: savedDocs }) => { - expect(savedDocs.length).to.equal(3); - for (const savedDoc of savedDocs) { - expect(savedDoc.form_version.time).to.equal(123456); - expect(savedDoc.form_version.sha256).to.equal('654321'); - } - }); - }); - - it('should copy old properties for existing contacts', () => { - const form = { getDataStr: () => '' }; - const docId = 'main1'; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { - _id: 'main1', - type: 'contact', - contact_type: 'some-contact-type', - contact: { _id: 'contact', name: 'Richard' }, - value: undefined, - } - }); - getContact - .withArgs(Qualifier.byUuid('main1')) - .resolves({ - _id: 'main1', - name: 'Richard', - parent: { _id: 'def' }, - value: 33, - some: 'additional', - data: 'is present', - }) - .withArgs(Qualifier.byUuid('contact')) - .resolves({ _id: 'contact', name: 'Richard', parent: { _id: 'def' } }); - - extractLineageService.extract - .withArgs(sinon.match({ _id: 'contact' })) - .returns({ _id: 'contact', parent: { _id: 'def' } }) - .withArgs(sinon.match({ _id: 'def' })) - .returns({ _id: 'def' }); - clock = sinon.useFakeTimers({now: 5000}); - - return service - .save(form, docId, type) - .then(({ preparedDocs: savedDocs }) => { - expect(getContact.callCount).to.equal(2); - expect(getContact.args[0]).to.deep.equal([Qualifier.byUuid('main1')]); - expect(getContact.args[1]).to.deep.equal([Qualifier.byUuid('contact')]); - - expect(savedDocs.length).to.equal(1); - expect(savedDocs[0]).to.deep.equal({ - _id: 'main1', - type: 'contact', - name: 'Richard', - contact_type: 'some-contact-type', - contact: { _id: 'contact', parent: { _id: 'def' } }, - parent: { _id: 'def' }, - value: 33, - some: 'additional', - data: 'is present', - reported_date: 5000, - }); - }); - }); - - describe('file attachments', () => { - it('should attach files from FileManager to main contact document', async () => { - // person-create form structure based on config/default/forms/contact/person-create.xml - const xmlPersonCreate = - '' + - '' + - 'PARENT' + - 'person' + - 'John Doe' + - '+254712345678' + - 'male' + - '' + - ''; - - const form = { getDataStr: () => xmlPersonCreate }; - const docId = null; - const type = 'person'; - - const mockFile = new File(['test file content'], 'test-photo.png', { type: 'image/png' }); - sinon.stub(FileManager, 'getCurrentFiles').returns([mockFile]); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'John Doe', phone: '+254712345678', sex: 'male' } - }); - - await service.save(form, docId, type); - - expect(attachmentService.add.calledOnce, 'AttachmentService.add should be called once').to.be.true; - - const addCall = attachmentService.add.getCall(0); - expect(addCall.args[0]._id, 'Should attach to the main document').to.equal('person1'); - expect(addCall.args[1], 'Should use correct attachment name pattern').to.equal('user-file-test-photo.png'); - expect(addCall.args[2], 'Should pass the file content').to.equal(mockFile); - expect(addCall.args[3], 'Should pass the file type').to.equal('image/png'); - expect(addCall.args[4], 'Should not be pre-encoded').to.be.false; - }); - - it('should sanitize file names by removing special characters', async () => { - const xmlPersonCreate = - '' + - '' + - 'PARENT' + - 'person' + - 'John Doe' + - '' + - ''; - - const form = { getDataStr: () => xmlPersonCreate }; - const docId = null; - const type = 'person'; - - // Test various special characters that should be removed - const fileWithSpaces = new File(['content'], 'my photo.png', { type: 'image/png' }); - const fileWithSpecialChars = new File(['content'], 'photo@#$%^&*().png', { type: 'image/png' }); - const fileWithParentheses = new File(['content'], 'photo (1).png', { type: 'image/png' }); - const fileWithAllowedChars = new File(['content'], 'my_photo-123.png', { type: 'image/png' }); - - sinon.stub(FileManager, 'getCurrentFiles').returns([ - fileWithSpaces, - fileWithSpecialChars, - fileWithParentheses, - fileWithAllowedChars - ]); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'John Doe' } - }); - - await service.save(form, docId, type); - - expect(attachmentService.add.callCount).to.equal(4); - - // File with spaces: "my photo.png" → "myphoto.png" - const call1 = attachmentService.add.getCall(0); - expect(call1.args[1], 'Should remove spaces').to.equal('user-file-myphoto.png'); - expect(call1.args[2]).to.equal(fileWithSpaces); - - // File with special characters: "photo@#$%^&*().png" → "photo.png" - const call2 = attachmentService.add.getCall(1); - expect(call2.args[1], 'Should remove special characters').to.equal('user-file-photo.png'); - expect(call2.args[2]).to.equal(fileWithSpecialChars); - - // File with parentheses: "photo (1).png" → "photo1.png" - const call3 = attachmentService.add.getCall(2); - expect(call3.args[1], 'Should remove parentheses and spaces').to.equal('user-file-photo1.png'); - expect(call3.args[2]).to.equal(fileWithParentheses); - - // File with allowed characters: "my_photo-123.png" → "my_photo-123.png" (unchanged) - const call4 = attachmentService.add.getCall(3); - expect(call4.args[1], 'Should keep allowed characters').to.equal('user-file-my_photo-123.png'); - expect(call4.args[2]).to.equal(fileWithAllowedChars); - }); - - it('should use a UUID as the file name when all characters are stripped from the file name', async () => { - const xmlPersonCreate = - '' + - '' + - 'PARENT' + - 'person' + - 'John Doe' + - 'श्रीधर.png' + - '' + - ''; - - const form = { getDataStr: () => xmlPersonCreate }; - const docId = null; - const type = 'person'; - - const devanagariFile = new File(['content'], 'श्रीधर.png', { type: 'image/png' }); - sinon.stub(FileManager, 'getCurrentFiles').returns([ devanagariFile ]); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'John Doe', photo: 'श्रीधर.png' } - }); - - const result = await service.save(form, docId, type); - - expect(attachmentService.add.callCount).to.equal(1); - const attachmentName = attachmentService.add.getCall(0).args[1]; - const uuidRegex = /^user-file-[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i; - expect(attachmentName, 'Should use a UUID when all non-extension characters are stripped').to.match(uuidRegex); - - const savedDoc = result.preparedDocs[0]; - const expectedFileName = attachmentName.replace('user-file-', ''); - expect(savedDoc.photo, 'photo field should be updated to match the UUID-based attachment name') - .to.equal(expectedFileName); - }); - - it('should sanitize field values in document to match sanitized attachment names', async () => { - const xmlPersonCreate = - '' + - '' + - 'PARENT' + - 'person' + - 'Jane Doe' + - 'Gui\'s Dog-13_0_24.png' + - 'my file (1).pdf' + - '' + - ''; - - const form = { getDataStr: () => xmlPersonCreate }; - const docId = null; - const type = 'person'; - - // Files with special characters that need sanitization - const photoFile = new File(['photo-content'], 'Gui\'s Dog-13_0_24.png', { type: 'image/png' }); - const documentFile = new File(['doc-content'], 'my file (1).pdf', { type: 'application/pdf' }); - - sinon.stub(FileManager, 'getCurrentFiles').returns([photoFile, documentFile]); - - // Form data contains the original unsanitized file names in the fields - enketoTranslationService.contactRecordToJs.returns({ - doc: { - _id: 'person2', - type: 'person', - name: 'Jane Doe', - photo: 'Gui\'s Dog-13_0_24.png', // Original file name with apostrophe - document: 'my file (1).pdf' // Original file name with spaces and parentheses - } - }); - - const result = await service.save(form, docId, type); - - // Verify attachments are created with sanitized names - expect(attachmentService.add.callCount).to.equal(2); - expect(attachmentService.add.getCall(0).args[1]).to.equal('user-file-GuisDog-13_0_24.png'); - expect(attachmentService.add.getCall(1).args[1]).to.equal('user-file-myfile1.pdf'); - - // Verify field values in the document are also sanitized to match attachment names - const savedDoc = result.preparedDocs[0]; - expect(savedDoc.photo, 'photo field should be sanitized to match attachment name') - .to.equal('GuisDog-13_0_24.png'); - expect(savedDoc.document, 'document field should be sanitized to match attachment name') - .to.equal('myfile1.pdf'); - }); - - it('should extract and attach binary field data from XML to main document', async () => { - const xmlWithBinaryField = - '' + - '' + - 'PARENT' + - 'person' + - 'Jane Smith' + - '+254712345679' + - 'female' + - '' + - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + - '' + - '' + - ''; - - const form = { getDataStr: () => xmlWithBinaryField }; - const docId = null; - const type = 'person'; - - sinon.stub(FileManager, 'getCurrentFiles').returns([]); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'Jane Smith', phone: '+254712345679', sex: 'female' } - }); - - await service.save(form, docId, type); - - expect(attachmentService.add.calledOnce, 'AttachmentService.add should be called once').to.be.true; - - const addCall = attachmentService.add.getCall(0); - expect(addCall.args[0]._id, 'Should attach to the main document').to.equal('person1'); - expect( - addCall.args[1], - 'Should use XPath-based attachment name for binary field' - ).to.equal('user-file/contact:person:create/person/signature'); - expect( - addCall.args[2], - 'Should pass the base64 content' - ).to.equal('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); - expect(addCall.args[3], 'Should use image/png as content type for binary fields').to.equal('image/png'); - expect(addCall.args[4], 'Should indicate content is already base64 encoded').to.be.true; - }); - - describe('attachment cleanup', () => { - it('should remove orphaned attachment when replaced with new file', async () => { - const xmlStr = - '' + - '' + - 'PARENT' + - 'person' + - 'John Doe' + - 'new-photo.png' + - '' + - ''; - - const form = { getDataStr: () => xmlStr }; - const docId = 'person1'; - const type = 'person'; - - const newFile = new File(['new photo content'], 'new-photo.png', { type: 'image/png' }); - sinon.stub(FileManager, 'getCurrentFiles').returns([newFile]); - - // Existing contact has an old attachment - getContact.withArgs(Qualifier.byUuid('person1')).resolves({ - _id: 'person1', - type: 'person', - name: 'John Doe', - photo: 'old-photo.png', - _attachments: { - 'user-file-old-photo.png': { content_type: 'image/png', data: 'old-data' } - } - }); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'John Doe', photo: 'new-photo.png' } - }); - - await service.save(form, docId, type); - - expect( - attachmentService.add.calledWith(sinon.match({ _id: 'person1' }), 'user-file-new-photo.png'), - 'Should add the new attachment' - ).to.be.true; - - expect( - attachmentService.remove.calledWith(sinon.match({ _id: 'person1' }), 'user-file-old-photo.png'), - 'Should remove the orphaned attachment' - ).to.be.true; - }); - - it('should keep existing attachment when field value still references it', async () => { - const xmlStr = - '' + - '' + - 'PARENT' + - 'person' + - 'John Updated' + - 'existing-photo.png' + - '' + - ''; - - const form = { getDataStr: () => xmlStr }; - const docId = 'person1'; - const type = 'person'; - - sinon.stub(FileManager, 'getCurrentFiles').returns([]); - - getContact.withArgs(Qualifier.byUuid('person1')).resolves({ - _id: 'person1', - type: 'person', - name: 'John Doe', - metadata: { - images: [ - { photo: 'existing-photo.png' } - ] - }, - _attachments: { - 'user-file-existing-photo.png': { content_type: 'image/png', data: 'photo-data' } - } - }); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { - _id: 'person1', - type: 'person', - name: 'John Updated', - metadata: { - images: [ - { photo: 'existing-photo.png' } - ] - } - } - }); - - await service.save(form, docId, type); - - expect( - attachmentService.remove.called, - 'Should not remove the referenced attachment' - ).to.be.false; - }); - - it('should remove attachment when field is cleared', async () => { - const xmlStr = - '' + - '' + - 'PARENT' + - 'person' + - 'John Doe' + - '' + - '' + - ''; - - const form = { getDataStr: () => xmlStr }; - const docId = 'person1'; - const type = 'person'; - - sinon.stub(FileManager, 'getCurrentFiles').returns([]); - - getContact.withArgs(Qualifier.byUuid('person1')).resolves({ - _id: 'person1', - type: 'person', - name: 'John Doe', - photo: 'old-photo.png', - _attachments: { - 'user-file-old-photo.png': { content_type: 'image/png', data: 'photo-data' } - } - }); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'John Doe', photo: '' } - }); - - await service.save(form, docId, type); - - expect( - attachmentService.remove.calledWith(sinon.match({ _id: 'person1' }), 'user-file-old-photo.png'), - 'Should remove the attachment when field is cleared' - ).to.be.true; - }); - - it('should handle multiple attachments with mixed actions', async () => { - const xmlStr = - '' + - '' + - 'PARENT' + - 'person' + - 'John Doe' + - 'keep-photo.png' + - 'new-doc.pdf' + - '' + - '' + - ''; - - const form = { getDataStr: () => xmlStr }; - const docId = 'person1'; - const type = 'person'; - - const newDocFile = new File(['new doc'], 'new-doc.pdf', { type: 'application/pdf' }); - sinon.stub(FileManager, 'getCurrentFiles').returns([newDocFile]); - - getContact.withArgs(Qualifier.byUuid('person1')).resolves({ - _id: 'person1', - type: 'person', - name: 'John Doe', - photo: 'keep-photo.png', - document: 'old-doc.pdf', - signature: 'old-sig.png', - _attachments: { - 'user-file-keep-photo.png': { content_type: 'image/png', data: 'photo-data' }, - 'user-file-old-doc.pdf': { content_type: 'application/pdf', data: 'doc-data' }, - 'user-file-old-sig.png': { content_type: 'image/png', data: 'sig-data' }, - } - }); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { - _id: 'person1', - type: 'person', - name: 'John Doe', - photo: 'keep-photo.png', // kept (field still references it) - document: 'new-doc.pdf', // replaced (new file uploaded) - signature: '', // cleared - } - }); - - await service.save(form, docId, type); - - expect( - attachmentService.add.calledWith(sinon.match({ _id: 'person1' }), 'user-file-new-doc.pdf'), - 'Should add the new document attachment' - ).to.be.true; - - expect( - attachmentService.remove.calledWith(sinon.match({ _id: 'person1' }), 'user-file-old-doc.pdf'), - 'Should remove the replaced document attachment' - ).to.be.true; - - expect( - attachmentService.remove.calledWith(sinon.match({ _id: 'person1' }), 'user-file-old-sig.png'), - 'Should remove the cleared signature attachment' - ).to.be.true; - - const removeArgs = attachmentService.remove.getCalls().map(call => call.args[1]); - expect(removeArgs, 'Should not remove the kept photo').to.not.include('user-file-keep-photo.png'); - - expect(attachmentService.remove.callCount, 'Should only remove 2 orphaned attachments').to.equal(2); - }); - }); - - it('should attach multiple attachments (file widgets and binary fields) to main document', async () => { - const xmlWithMultipleAttachments = - '' + - '' + - 'PARENT' + - 'person' + - 'Dr. Maria Garcia' + - '+254712345680' + - 'female' + - 'BASE64_PHOTO_DATA' + - 'BASE64_SIGNATURE_DATA' + - '' + - ''; - - const form = { getDataStr: () => xmlWithMultipleAttachments }; - const docId = null; - const type = 'person'; - - const mockFile1 = new File(['certificate content'], 'certificate.pdf', { type: 'application/pdf' }); - const mockFile2 = new File(['insurance ID content'], 'insurance-id.pdf', { type: 'application/pdf' }); - sinon.stub(FileManager, 'getCurrentFiles').returns([mockFile1, mockFile2]); - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'person1', type: 'person', name: 'Dr. Maria Garcia', phone: '+254712345680', sex: 'female' } - }); - - await service.save(form, docId, type); - - expect( - attachmentService.add.callCount, - 'AttachmentService.add should be called 4 times (2 file widgets + 2 binary fields)' - ).to.equal(4); - - attachmentService.add.getCalls().forEach(call => { - expect(call.args[0]._id, 'All attachments should attach to the main document').to.equal('person1'); - }); - - const fileWidget1Call = attachmentService.add.getCall(0); - expect(fileWidget1Call.args[1], 'First file widget attachment name').to.equal('user-file-certificate.pdf'); - expect(fileWidget1Call.args[2], 'First file widget content').to.equal(mockFile1); - expect(fileWidget1Call.args[3], 'First file widget content type').to.equal('application/pdf'); - expect(fileWidget1Call.args[4], 'File widget should not be pre-encoded').to.be.false; - - const fileWidget2Call = attachmentService.add.getCall(1); - expect(fileWidget2Call.args[1], 'Second file widget attachment name').to.equal('user-file-insurance-id.pdf'); - expect(fileWidget2Call.args[2], 'Second file widget content').to.equal(mockFile2); - expect(fileWidget2Call.args[3], 'Second file widget content type').to.equal('application/pdf'); - expect(fileWidget2Call.args[4], 'File widget should not be pre-encoded').to.be.false; - - const photoCall = attachmentService.add.getCall(2); - expect( - photoCall.args[1], - 'Photo binary field XPath-based name' - ).to.equal('user-file/contact:person:create/person/photo'); - expect(photoCall.args[2], 'Photo binary field content').to.equal('BASE64_PHOTO_DATA'); - expect(photoCall.args[3], 'Binary field content type').to.equal('image/png'); - expect(photoCall.args[4], 'Binary field should be pre-encoded').to.be.true; - - const signatureCall = attachmentService.add.getCall(3); - expect( - signatureCall.args[1], - 'Signature binary field XPath-based name' - ).to.equal('user-file/contact:person:create/person/signature'); - expect(signatureCall.args[2], 'Signature binary field content').to.equal('BASE64_SIGNATURE_DATA'); - expect(signatureCall.args[3], 'Binary field content type').to.equal('image/png'); - expect(signatureCall.args[4], 'Binary field should be pre-encoded').to.be.true; - }); - }); -}); diff --git a/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts b/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts deleted file mode 100644 index 078853c3d2a..00000000000 --- a/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts +++ /dev/null @@ -1,850 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { assert } from 'chai'; - -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import { CONTACT_TYPES } from '@medic/constants'; - -const serialize = (element) => { - if (element.nodeType !== Node.ELEMENT_NODE) { - element = element[0]; - } - const serializer = new XMLSerializer(); - const serialized = serializer.serializeToString(element); - return inlineXml(serialized); -}; - -const inlineXml = (xmlString) => xmlString - .replace(/^\s+|\n|\t|\s+$/g, '') - .replace(/>\s+<'); - -describe('EnketoTranslation service', () => { - let service; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(EnketoTranslationService); - }); - - describe('#contactRecordToJs()', () => { - it('should convert a simple record to JS', () => { - // given - const xml = - ` - - Denise Degraffenreid - +123456789 - eeb17d6d-5dde-c2c0-a0f2a91e2d232c51 - - - uuid:9bbd57b0-5557-4d69-915c-f8049c81f6d8 - - `; - - // when - const js = service.contactRecordToJs(xml); - - // then - assert.deepEqual(js, { - doc: { - name: 'Denise Degraffenreid', - phone: '+123456789', - parent: 'eeb17d6d-5dde-c2c0-a0f2a91e2d232c51', - }, - siblings: {} - }); - }); - - it('should convert a complex record without new instance to JS', () => { - // given - const xml = - ` - - A New Catchmnent Area - eeb17d6d-5dde-c2c0-48ac53f275043126 - abc-123-xyz-987 - - - - - - - uuid:ecded7c5-5c8d-4195-8e08-296de6557f1e - - `; - - // when - const js = service.contactRecordToJs(xml); - - // then - assert.deepEqual(js, { - doc: { - name: 'A New Catchmnent Area', - parent: 'eeb17d6d-5dde-c2c0-48ac53f275043126', - contact: 'abc-123-xyz-987', - }, - siblings: { - contact: { - name: '', - phone: '', - }, - }}); - }); - - it('should convert a complex record with new instance to JS', () => { - // given - const xml = - ` - - A New Catchmnent Area - eeb17d6d-5dde-c2c0-48ac53f275043126 - NEW - - - Jeremy Fisher - +123456789 - - - uuid:ecded7c5-5c8d-4195-8e08-296de6557f1e - - `; - - // when - const js = service.contactRecordToJs(xml); - - // then - assert.deepEqual(js, { - doc: { - name: 'A New Catchmnent Area', - parent: 'eeb17d6d-5dde-c2c0-48ac53f275043126', - contact: 'NEW', - }, - siblings: { - contact: { - name: 'Jeremy Fisher', - phone: '+123456789', - }, - }}); - }); - - it('should support repeated elements', () => { - // given - const xml = - ` - - A House in the Woods - eeb17d6d-5dde-c2c0-48ac53f275043126 - abc-123-xyz-987 - - - Mummy Bear - 123 - - - - Daddy Bear - - - Baby Bear - - - Goldilocks - - - - uuid:ecded7c5-5c8d-4195-8e08-296de6557f1e - - `; - - // when - const js = service.contactRecordToJs(xml); - - // then - assert.deepEqual(js, { - doc: { - name: 'A House in the Woods', - parent: 'eeb17d6d-5dde-c2c0-48ac53f275043126', - contact: 'abc-123-xyz-987', - }, - siblings: { - contact: { - name: 'Mummy Bear', - phone: '123', - }, - }, - repeats: { - child_data: [ - { name: 'Daddy Bear', }, - { name: 'Baby Bear', }, - { name: 'Goldilocks', }, - ], - }, - }); - }); - - it('should ignore text in repeated elements', () => { - // given - const xml = - ` - - A House in the Woods - eeb17d6d-5dde-c2c0-48ac53f275043126 - abc-123-xyz-987 - - - Mummy Bear - 123 - - - All text nodes should be ignored. - - Daddy Bear - - All text nodes should be ignored. - - Baby Bear - - All text nodes should be ignored. - - Goldilocks - - All text nodes should be ignored. - - - uuid:ecded7c5-5c8d-4195-8e08-296de6557f1e - - `; - - // when - const js = service.contactRecordToJs(xml); - - // then - assert.deepEqual(js, { - doc: { - name: 'A House in the Woods', - parent: 'eeb17d6d-5dde-c2c0-48ac53f275043126', - contact: 'abc-123-xyz-987', - }, - siblings: { - contact: { - name: 'Mummy Bear', - phone: '123', - }, - }, - repeats: { - child_data: [ - { name: 'Daddy Bear', }, - { name: 'Baby Bear', }, - { name: 'Goldilocks', }, - ], - }, - }); - }); - - it('should ignore first level elements with no children', () => { - const xml = - ` - - - A House in the Woods - eeb17d6d-5dde-c2c0-48ac53f275043126 - abc-123-xyz-987 - - - - Mummy Bear - 123 - - - uuid:ecded7c5-5c8d-4195-8e08-296de6557f1e - - - `; - - const js = service.contactRecordToJs(xml); - - assert.deepEqual(js, { - doc: { - name: 'A House in the Woods', - parent: 'eeb17d6d-5dde-c2c0-48ac53f275043126', - contact: 'abc-123-xyz-987', - }, - siblings: { - contact: { - name: 'Mummy Bear', - phone: '123', - }, - }, - }); - }); - }); - - describe('#reportRecordToJs()', () => { - it('should convert nested nodes to nested JSON', () => { - // given - const xml = - ` - - - - -47.15 - -126.72 - - Last Friday - - d1 - DISTRICT ONE - - - - 41 - 100 - - - paracetamol - 1g * 4, 1/7 - - - `; - - // when - const js = service.reportRecordToJs(xml); - - // then - assert.deepEqual(js, { - inputs: { - meta: { - location: { - lat: '-47.15', - long: '-126.72' - } - } - }, - date: 'Last Friday', - district: { - id: 'd1', - name: 'DISTRICT ONE', - }, - patient: { - condition: { - temperature: '41', - weight: '100', - }, - prescription: { - name: 'paracetamol', - dose: '1g * 4, 1/7', - }, - }, - }); - }); - - it('converts repeated fields to arrays - #3430', () => { - // given - const record = ` - - - - a - - - b - - - `; - - const form = ` - - - Repeat Bug - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - - // when - const js = service.reportRecordToJs(record, form); - - // then - assert.deepEqual(js, { - group_test: { - chp: [ - { other_chp: 'a' }, - { other_chp: 'b' } - ] - } - }); - }); - }); - - describe('#getHiddenFieldList()', () => { - it('returns of one an empty array if no fields are hidden', () => { - // given - const xml = - ` - Sally - 10 - `; - - // when - const hidden_fields = service.getHiddenFieldList(xml); - - // then - assert.deepEqual(hidden_fields, []); - }); - - it('returns an array containing fields tagged `hidden`', () => { - // given - const xml = - ` - Sally - S4L - S5L - 10 - `; - - // when - const hidden_fields = service.getHiddenFieldList(xml); - - // then - assert.deepEqual(hidden_fields, [ 'secret_code_name_one', 'secret_code_name_two' ]); - }); - - it('hides sections tagged `hidden`', () => { - // given - const xml = - ` - Sally - - a - b - - 10 - `; - - // when - const hidden_fields = service.getHiddenFieldList(xml); - - // then - assert.deepEqual(hidden_fields, [ 'secret' ]); - }); - - it('recurses to find `hidden` children', () => { - // given - const xml = - ` - Sally - - a - b - - 10 - `; - - // when - const hidden_fields = service.getHiddenFieldList(xml); - - // then - assert.deepEqual(hidden_fields, [ 'secret.first', 'lmp' ]); - }); - - it('should not duplicate hidden paths', () => { - const xml = - ` - Sally - - a - b - - 10 - 20 - 30 - 40 - `; - - // when - const hidden_fields = service.getHiddenFieldList(xml); - - // then - assert.deepEqual(hidden_fields, [ 'secret.first', 'lmp' ]); - }); - }); - - describe('#bindJsonToXml()', () => { - it('binds simple data', () => { - // given - const model = - ` - - - - - - - - - `; - const element = $($.parseXML(model)).children().first(); - const data = { - district_hospital: { - name: 'Davesville', - external_id: 'THING', - notes: 'Some notes', - type: CONTACT_TYPES.DISTRICT_HOSPITAL, - }, - }; - - // when - service.bindJsonToXml(element, data); - - // then - assert.equal(element.find('name').text(), 'Davesville'); - assert.equal(element.find('external_id').text(), 'THING'); - assert.equal(element.find('notes').text(), 'Some notes'); - }); - - it('binds embedded objects to id-only fields', () => { - // given - const model = - ` - - - - - - - - - - `; - const element = $($.parseXML(model)).children().first(); - const data = { - district_hospital: { - name: 'Davesville', - contact: { - _id: 'abc-123', - name: 'Dr. D', - }, - external_id: 'THING', - notes: 'Some notes', - type: CONTACT_TYPES.DISTRICT_HOSPITAL, - }, - }; - - // when - service.bindJsonToXml(element, data); - - // then - assert.equal(element.find('name').text(), 'Davesville'); - assert.equal(element.find('contact').text(), 'abc-123'); - assert.equal(element.find('external_id').text(), 'THING'); - assert.equal(element.find('notes').text(), 'Some notes'); - }); - - it('binds embedded objects to trees', () => { - // given - const model = - ` - - - - <_id/> - - - - - - - - - `; - const element = $($.parseXML(model)).children().first(); - const data = { - district_hospital: { - name: 'Davesville', - contact: { - _id: 'abc-123', - name: 'Dr. D', - }, - external_id: 'THING', - notes: 'Some notes', - type: CONTACT_TYPES.DISTRICT_HOSPITAL, - }, - }; - - // when - service.bindJsonToXml(element, data); - - // then - assert.equal(element.find('district_hospital > name').text(), 'Davesville'); - assert.equal(element.find('district_hospital > external_id').text(), 'THING'); - assert.equal(element.find('district_hospital > notes').text(), 'Some notes'); - - assert.equal(element.find('contact > _id').text(), 'abc-123'); - assert.equal(element.find('contact > name').text(), 'Dr. D'); - }); - - it('binds data 1:1 with its representation', () => { - const element = $($.parseXML( - ` - - - - <_id/> - - - - - - - - - ` - )).children().first(); - const data = { - district_hospital: { - name: 'Davesville', - contact: { - _id: 'abc-123', - }, - external_id: 'THING', - notes: 'Some notes', - type: CONTACT_TYPES.DISTRICT_HOSPITAL, - }, - }; - - service.bindJsonToXml(element, data); - - assert.equal( - element.find('contact > name').text(), - '', - 'The contact name should not get the value of the district hospital' - ); - }); - - it('preferentially binds to more specific data structures', () => { - const DEEP_TEST_VALUE = 'deep'; - - const element = $($.parseXML('')).children().first(); - const data = { - foo: { - smang: 'shallow5', - baz: { - smang: 'shallow6' - } - }, - smang: 'shallow1', - bar: { - baz: { - smang: DEEP_TEST_VALUE - }, - smang: 'shallow2' - }, - }; - - service.bindJsonToXml(element, data); - - assert.equal(element.find('smang').text(), DEEP_TEST_VALUE); - }); - - it('should bind arrays', () => { - const model = ` - - - - - - - - - - - - - - - - - - - `; - const element = $($.parseXML(model)).children().first(); - const data = { - foo: { - bar: 'barvalue', - baz: [ - { - one: 'baz1one', - two: 'baz1two', - three: { - four: [ - 'baz1four1', - 'baz1four2', - 'baz1four3' - ] - } - }, - { - one: 'baz2one', - two: 'baz2two', - three: { - four: [ - 'baz2four1', - 'baz2four2', - ] - } - }, - { - one: 'baz3one', - two: 'baz3two', - three: { - four: 'baz3four1' - } - }, - ], - omg: { - thing: [ - 'thing1', - 'thing2', - 'thing3', - { _id: 'id_of_thing_4' } - ] - }, - mixrepeat: [ - 'one', - 'two', - { property: 'propvalue' } - ] - } - }; - service.bindJsonToXml(element, data, (name) => { - return '>%, >inputs>%'.replace(/%/g, name); - }); - - assert.equal(element.find('bar').length, 1); - assert.equal(element.find('bar').text(), 'barvalue'); - - assert.equal(element.find('baz').length, 3); - assert.equal(serialize(element.find('baz')[0]), inlineXml(` - - baz1one - baz1two - - baz1four1 - baz1four2 - baz1four3 - - - `)); - - assert.equal(serialize(element.find('baz')[1]), inlineXml(` - - baz2one - baz2two - - baz2four1 - baz2four2 - - - `)); - - assert.equal(serialize(element.find('baz')[2]), inlineXml(` - - baz3one - baz3two - - baz3four1 - - - `)); - - assert.equal(element.find('omg').length, 1); - assert.equal(serialize(element.find('omg')), inlineXml(` - - thing1 - thing2 - thing3 - id_of_thing_4 - - `)); - - assert.equal(element.find('mixrepeat').length, 3); - assert.equal(serialize(element.find('mixrepeat')[0]), 'one'); - assert.equal(serialize(element.find('mixrepeat')[1]), 'two'); - assert.equal( - serialize(element.find('mixrepeat')[2]), - 'propvalue' - ); - }); - - it('should remove template-like attributes', () => { - const model = - ` - - - - - - - - - `; - const element = $($.parseXML(model)).children().first(); - const data = { - district_hospital: { - name: 'Davesville', - external_id: 'THING', - notes: 'Some notes', - type: CONTACT_TYPES.DISTRICT_HOSPITAL, - }, - }; - - service.bindJsonToXml(element, data); - - assert.equal(element.find('district_hospital')[0].hasAttribute('jr:template'), false); - assert.equal(element.find('district_hospital')[0].hasAttribute('template'), false); - - assert.equal(element.find('name')[0].hasAttribute('jr:template'), false); - assert.equal(element.find('name')[0].hasAttribute('template'), false); - - assert.equal(element.find('external_id')[0].hasAttribute('jr:template'), false); - assert.equal(element.find('external_id')[0].hasAttribute('template'), false); - - assert.equal(element.find('notes')[0].hasAttribute('jr:template'), false); - assert.equal(element.find('notes')[0].hasAttribute('template'), false); - }); - }); -}); diff --git a/webapp/tests/karma/ts/services/form/form-config.spec.ts b/webapp/tests/karma/ts/services/form/form-config.spec.ts new file mode 100644 index 00000000000..87d78a1cb19 --- /dev/null +++ b/webapp/tests/karma/ts/services/form/form-config.spec.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; + +import { FormConfig } from '@mm-services/form/form-config'; + +describe('FormConfig', () => { + it('parses repeatPaths from repeat[nodeset] elements in the xml', () => { + const xml = ` + + + + `; + + const config = new FormConfig({}, 'report', xml, '', ''); + + expect(config.repeatPaths).to.deep.equal(['/data/child', '/data/other/item']); + }); + + it('returns an empty repeatPaths array when there are no repeats', () => { + const config = new FormConfig({}, 'report', '', '', ''); + + expect(config.repeatPaths).to.deep.equal([]); + }); + + it('filters out repeats with an empty nodeset', () => { + const xml = ''; + + const config = new FormConfig({}, 'report', xml, '', ''); + + expect(config.repeatPaths).to.deep.equal(['/data/child']); + }); + + it('stores the doc, type, html and model as given', () => { + const doc = { _id: 'form:test', xmlVersion: '2.0' }; + + const config = new FormConfig(doc, 'contact', '', '
the html
', 'the model'); + + expect(config.doc).to.equal(doc); + expect(config.type).to.equal('contact'); + expect(config.html).to.equal('
the html
'); + expect(config.model).to.equal('the model'); + }); +}); diff --git a/webapp/tests/karma/ts/services/xml-forms.service.spec.ts b/webapp/tests/karma/ts/services/xml-forms.service.spec.ts index d50cfbd61fe..6aa16550f1b 100644 --- a/webapp/tests/karma/ts/services/xml-forms.service.spec.ts +++ b/webapp/tests/karma/ts/services/xml-forms.service.spec.ts @@ -1257,44 +1257,82 @@ describe('XmlForms service', () => { }); - describe('get', () => { + describe('getFormConfig', () => { - it('gets valid form by id with "xml" attachment', () => { - const internalId = 'birth'; - const expected = { - type: 'form', + beforeEach(() => { + dbGetAttachment.resolves('blob'); + fileReaderService.resolves('content'); + }); + + it('loads a contact form doc by _id and returns a FormConfig with the attachment contents', async () => { + const formId = 'form:contact:person'; + const formDoc = { + _id: formId, + internalId: 'contact:person', _attachments: { xml: { stub: true }, 'model.xml': { stub: true }, 'form.html': { stub: true }, - } + }, }; dbQuery.resolves([]); - dbGet.resolves(expected); + dbGet.resolves(formDoc); + dbGetAttachment.withArgs(formId, 'xml').resolves('xml-blob'); + dbGetAttachment.withArgs(formId, 'form.html').resolves('html-blob'); + dbGetAttachment.withArgs(formId, 'model.xml').resolves('model-blob'); + fileReaderService.withArgs('xml-blob').resolves(''); + fileReaderService.withArgs('html-blob').resolves('
html
'); + fileReaderService.withArgs('model-blob').resolves(''); const service = getService(); - return service.get(internalId).then(actual => { - expect(actual).to.deep.equal(expected); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0][0]).to.equal(`form:${internalId}`); - }); + + const config = await service.getFormConfig('contact', formId); + + expect(config.doc).to.deep.equal(formDoc); + expect(config.type).to.equal('contact'); + expect(config.html).to.equal('
html
'); + expect(config.model).to.equal(''); + expect(config.repeatPaths).to.deep.equal(['/data/children']); + expect(dbGet).to.have.been.calledOnceWithExactly(formId); + expect(dbGetAttachment.args).to.deep.equal([ + [formId, 'xml'], + [formId, 'form.html'], + [formId, 'model.xml'], + ]); }); - it('gets valid form by id with ".xml" file extension', () => { - const internalId = 'birth'; - const expected = { - type: 'form', + it('resolves a non-contact form doc via the internalId lookup', async () => { + const internalId = 'pregnancy'; + const formDoc = { + _id: `form:${internalId}`, + internalId, _attachments: { 'something.xml': { stub: true }, 'model.xml': { stub: true }, 'form.html': { stub: true }, - } + }, }; - dbGet.resolves(expected); dbQuery.resolves([]); + dbGet.resolves(formDoc); + dbGetAttachment.withArgs(formDoc._id, 'something.xml').resolves('xml-blob'); + dbGetAttachment.withArgs(formDoc._id, 'form.html').resolves('html-blob'); + dbGetAttachment.withArgs(formDoc._id, 'model.xml').resolves('model-blob'); + fileReaderService.withArgs('xml-blob').resolves(''); + fileReaderService.withArgs('html-blob').resolves('
html
'); + fileReaderService.withArgs('model-blob').resolves(''); const service = getService(); - return service.get(internalId).then(actual => { - expect(actual).to.equal(expected); - }); + + const config = await service.getFormConfig('report', internalId); + + expect(config.doc).to.deep.equal(formDoc); + expect(config.type).to.equal('report'); + expect(config.html).to.equal('
html
'); + expect(config.model).to.equal(''); + expect(dbGet).to.have.been.calledOnceWithExactly(formDoc._id); + expect(dbGetAttachment.args).to.deep.equal([ + [formDoc._id, 'something.xml'], + [formDoc._id, 'form.html'], + [formDoc._id, 'model.xml'], + ]); }); it('returns error, logs when getById fails', () => { @@ -1302,7 +1340,7 @@ describe('XmlForms service', () => { dbGet.rejects('getById fails'); dbQuery.rejects('getByView fails'); const service = getService(); - return service.get(internalId) + return service.getFormConfig('report', internalId) .then(() => { assert.fail('getById fails'); }) @@ -1319,7 +1357,7 @@ describe('XmlForms service', () => { dbGet.rejects({status: 404 }); dbQuery.rejects('getByView fails'); const service = getService(); - return service.get(internalId) + return service.getFormConfig('report', internalId) .then(() => { assert.fail('getByView fails'); }) @@ -1337,14 +1375,14 @@ describe('XmlForms service', () => { const expectedErrorTitle = 'Error in XMLFormService : hasRequiredAttachments : '; const expectedErrorDetail = `The form "${internalId}" doesn't have required attachments`; const expected = { + _id: `form:${internalId}`, type: 'form', _attachments: { 'something.txt': { stub: true } } }; dbGet.resolves(expected); dbQuery.resolves([]); const service = getService(); - return service - .get(internalId) + return service.getFormConfig('report', internalId) .then(() => { assert.fail('expected error to be thrown'); }) @@ -1356,9 +1394,10 @@ describe('XmlForms service', () => { }); }); - it('falls back to using view if no doc found', () => { + it('falls back to using view if no doc found', async () => { const internalId = 'birth'; const expected = { + _id: `form:${internalId}`, internalId, _attachments: { 'something.xml': { stub: true }, @@ -1374,15 +1413,14 @@ describe('XmlForms service', () => { ] }); const service = getService(); - return service.get(internalId).then(actual => { - expect(warn.callCount).to.equal(1); - expect(warn.args[0][0]).to.equal('Error in XMLFormService : getById : '); - expect(actual).to.deep.equal(expected); - expect(dbQuery.callCount).to.equal(1); - const options = dbQuery.args[0][0]; - expect(options.include_docs).to.equal(true); - expect(options.start_key).to.equal('form:'); - expect(options.end_key).to.equal('form:\ufff0'); + + const config = await service.getFormConfig('report', internalId); + + expect(warn.callCount).to.equal(1); + expect(warn.args[0][0]).to.equal('Error in XMLFormService : getById : '); + expect(config.doc).to.deep.equal(expected); + expect(dbQuery).to.have.been.calledOnceWithExactly({ + include_docs: true, start_key: 'form:', end_key: 'form:\ufff0' }); }); @@ -1403,7 +1441,7 @@ describe('XmlForms service', () => { }); const service = getService(); return service - .get(internalId) + .getFormConfig('report', internalId) .then(() => { assert.fail('expected error to be thrown'); }) @@ -1429,7 +1467,7 @@ describe('XmlForms service', () => { }); const service = getService(); return service - .get(internalId) + .getFormConfig('report', internalId) .then(() => { assert.fail('expected error to be thrown'); }) @@ -1442,95 +1480,31 @@ describe('XmlForms service', () => { expect(error.args[0][1]).to.equal(expectedErrorDetail); }); }); - }); - describe('getDocAndFormAttachment', () => { - - it('fails if no forms found', () => { - const internalId = 'birth'; - const expectedErrorTitle = 'Error in XMLFormService : getDocAndFormAttachment : '; - const expectedErrorDetail = `The form "${internalId}" doesn't have an xform attachment`; - dbQuery.resolves([]); - dbGet.resolves({ - _id: 'form:death', - _attachments: { - 'something.xml': { stub: true }, - 'model.xml': { stub: true }, - 'form.html': { stub: true }, - }, - internalId: 'birth' - }); - dbGetAttachment.rejects({ status: 404 }); - const service = getService(); - return service - .getDocAndFormAttachment(internalId) - .then(() => { - assert.fail('expected error to be thrown'); - }) - .catch(err => { - expect(err.message).to.equal(expectedErrorTitle + expectedErrorDetail); - expect(error.callCount).to.equal(1); - expect(error.args[0][0]).to.equal(expectedErrorTitle); - expect(error.args[0][1]).to.equal(expectedErrorDetail); - }); - }); - - it('fails if failed to get forms, but not 404', () => { - const internalId = 'birth'; - const expectedErrorTitle = 'Error in XMLFormService : getDocAndFormAttachment : '; - const expectedErrorDetail = `Failed to get the form "${internalId}" xform attachment`; - dbQuery.resolves([]); - dbGet.resolves({ - _id: 'form:death', - _attachments: { - 'something.xml': { stub: true }, - 'model.xml': { stub: true }, - 'form.html': { stub: true }, - }, - internalId: 'birth' - }); - dbGetAttachment.rejects(); - const service = getService(); - return service - .getDocAndFormAttachment(internalId) - .then(() => { - assert.fail('expected error to be thrown'); - }) - .catch(err => { - expect(err.message).to.equal(expectedErrorTitle + expectedErrorDetail); - expect(error.callCount).to.equal(1); - expect(error.args[0][0]).to.equal(expectedErrorTitle); - expect(error.args[0][1]).to.equal(expectedErrorDetail); - }); - }); - - it('returns doc and xml', () => { - const internalId = 'birth'; + it('rejects when the xform attachment is missing', async () => { + const formId = 'form:contact:person'; const formDoc = { - _id: 'form:death', + _id: formId, + internalId: 'contact:person', _attachments: { xml: { stub: true }, 'model.xml': { stub: true }, 'form.html': { stub: true }, }, - internalId: 'birth' }; + const expectedErrorMessage = 'Error in XMLFormService : getDocAndFormAttachment : ' + + `The form "${formId}" doesn't have an xform attachment`; dbQuery.resolves([]); dbGet.resolves(formDoc); - dbGetAttachment.resolves('someblob'); - fileReaderService.resolves('
'); + dbGetAttachment.withArgs(formId, 'xml').rejects({ status: 404 }); + dbGetAttachment.withArgs(formId, 'form.html').resolves('html-blob'); + dbGetAttachment.withArgs(formId, 'model.xml').resolves('model-blob'); + fileReaderService.resolves('content'); const service = getService(); - return service - .getDocAndFormAttachment(internalId) - .then((result) => { - expect(result.doc).to.deep.equal(formDoc); - expect(result.xml).to.deep.equal(''); - expect(dbGetAttachment.callCount).to.equal(1); - expect(dbGetAttachment.args[0][0]).to.equal('form:death'); - expect(dbGetAttachment.args[0][1]).to.equal('xml'); - expect(fileReaderService.callCount).to.equal(1); - expect(fileReaderService.args[0][0]).to.equal('someblob'); - }); + + await expect(service.getFormConfig('contact', formId)).to.be.rejectedWith(expectedErrorMessage); + + expect(error.calledWith(expectedErrorMessage)).to.be.true; }); }); From 046178a8db2f6265c832cb69d7513b24d6433ee4 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 16:21:43 -0500 Subject: [PATCH 25/41] Lock in prepop tests --- .../enketo-prepopulation-data.service.spec.ts | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) diff --git a/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts b/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts index cde07b17785..b9168050a84 100644 --- a/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts @@ -3,6 +3,16 @@ import sinon from 'sinon'; import { expect, assert } from 'chai'; import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulation-data.service'; +import { CONTACT_TYPES } from '@medic/constants'; + +const inlineXml = (xmlString) => xmlString + .replace(/^\s+|\n|\t|\s+$/g, '') + .replace(/>\s+<'); + +const serialize = (element) => { + const serialized = new XMLSerializer().serializeToString(element); + return inlineXml(serialized); +}; describe('EnketoPrepopulationData service', () => { let service; @@ -198,4 +208,327 @@ describe('EnketoPrepopulationData service', () => { expect(xml.find('inputs > user > name')[0].innerHTML).to.equal(user.name); expect(xml.find('pregnancy > person > last_name')[0].innerHTML).to.equal(data.person.last_name); }); + + describe('bindJsonToXml', () => { + it('binds simple data', () => { + const model = + ` + + + + + + + + + `; + const element = $($.parseXML(model)).children().first(); + const data = { + district_hospital: { + name: 'Davesville', + external_id: 'THING', + notes: 'Some notes', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + }, + }; + + service['bindJsonToXml'](element, data); + + assert.equal(element.find('name').text(), 'Davesville'); + assert.equal(element.find('external_id').text(), 'THING'); + assert.equal(element.find('notes').text(), 'Some notes'); + }); + + it('binds embedded objects to id-only fields', () => { + const model = + ` + + + + + + + + + + `; + const element = $($.parseXML(model)).children().first(); + const data = { + district_hospital: { + name: 'Davesville', + contact: { + _id: 'abc-123', + name: 'Dr. D', + }, + external_id: 'THING', + notes: 'Some notes', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + }, + }; + + service['bindJsonToXml'](element, data); + + assert.equal(element.find('name').text(), 'Davesville'); + assert.equal(element.find('contact').text(), 'abc-123'); + assert.equal(element.find('external_id').text(), 'THING'); + assert.equal(element.find('notes').text(), 'Some notes'); + }); + + it('binds embedded objects to trees', () => { + const model = + ` + + + + <_id/> + + + + + + + + + `; + const element = $($.parseXML(model)).children().first(); + const data = { + district_hospital: { + name: 'Davesville', + contact: { + _id: 'abc-123', + name: 'Dr. D', + }, + external_id: 'THING', + notes: 'Some notes', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + }, + }; + + service['bindJsonToXml'](element, data); + + assert.equal(element.find('district_hospital > name').text(), 'Davesville'); + assert.equal(element.find('district_hospital > external_id').text(), 'THING'); + assert.equal(element.find('district_hospital > notes').text(), 'Some notes'); + + assert.equal(element.find('contact > _id').text(), 'abc-123'); + assert.equal(element.find('contact > name').text(), 'Dr. D'); + }); + + it('binds data 1:1 with its representation', () => { + const element = $($.parseXML( + ` + + + + <_id/> + + + + + + + + + ` + )).children().first(); + const data = { + district_hospital: { + name: 'Davesville', + contact: { + _id: 'abc-123', + }, + external_id: 'THING', + notes: 'Some notes', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + }, + }; + + service['bindJsonToXml'](element, data); + + assert.equal( + element.find('contact > name').text(), + '', + 'The contact name should not get the value of the district hospital' + ); + }); + + it('preferentially binds to more specific data structures', () => { + const DEEP_TEST_VALUE = 'deep'; + + const element = $($.parseXML('')).children().first(); + const data = { + foo: { + smang: 'shallow5', + baz: { + smang: 'shallow6' + } + }, + smang: 'shallow1', + bar: { + baz: { + smang: DEEP_TEST_VALUE + }, + smang: 'shallow2' + }, + }; + + service['bindJsonToXml'](element, data); + + assert.equal(element.find('smang').text(), DEEP_TEST_VALUE); + }); + + it('should bind arrays', () => { + const model = ` + + + + + + + + + + + + + + + + + + + `; + const element = $($.parseXML(model)).children().first(); + const data = { + foo: { + bar: 'barvalue', + baz: [ + { + one: 'baz1one', + two: 'baz1two', + three: { four: ['baz1four1', 'baz1four2', 'baz1four3'] } + }, + { + one: 'baz2one', + two: 'baz2two', + three: { four: ['baz2four1', 'baz2four2'] } + }, + { + one: 'baz3one', + two: 'baz3two', + three: { four: 'baz3four1' } + }, + ], + omg: { + thing: [ + 'thing1', + 'thing2', + 'thing3', + { _id: 'id_of_thing_4' } + ] + }, + mixrepeat: [ + 'one', + 'two', + { property: 'propvalue' } + ] + } + }; + service['bindJsonToXml'](element, data, (name) => { + return '>%, >inputs>%'.replace(/%/g, name); + }); + + assert.equal(element.find('bar').length, 1); + assert.equal(element.find('bar').text(), 'barvalue'); + + assert.equal(element.find('baz').length, 3); + assert.equal(serialize(element.find('baz')[0]), inlineXml(` + + baz1one + baz1two + + baz1four1 + baz1four2 + baz1four3 + + + `)); + + assert.equal(serialize(element.find('baz')[1]), inlineXml(` + + baz2one + baz2two + + baz2four1 + baz2four2 + + + `)); + + assert.equal(serialize(element.find('baz')[2]), inlineXml(` + + baz3one + baz3two + + baz3four1 + + + `)); + + assert.equal(element.find('omg').length, 1); + assert.equal(serialize(element.find('omg')[0]), inlineXml(` + + thing1 + thing2 + thing3 + id_of_thing_4 + + `)); + + assert.equal(element.find('mixrepeat').length, 3); + assert.equal(serialize(element.find('mixrepeat')[0]), 'one'); + assert.equal(serialize(element.find('mixrepeat')[1]), 'two'); + assert.equal( + serialize(element.find('mixrepeat')[2]), + 'propvalue' + ); + }); + + it('should remove template-like attributes', () => { + const model = + ` + + + + + + + + + `; + const element = $($.parseXML(model)).children().first(); + const data = { + district_hospital: { + name: 'Davesville', + external_id: 'THING', + notes: 'Some notes', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + }, + }; + + service['bindJsonToXml'](element, data); + + assert.equal(element.find('district_hospital')[0].hasAttribute('jr:template'), false); + assert.equal(element.find('district_hospital')[0].hasAttribute('template'), false); + + assert.equal(element.find('name')[0].hasAttribute('jr:template'), false); + assert.equal(element.find('name')[0].hasAttribute('template'), false); + + assert.equal(element.find('external_id')[0].hasAttribute('jr:template'), false); + assert.equal(element.find('external_id')[0].hasAttribute('template'), false); + + assert.equal(element.find('notes')[0].hasAttribute('jr:template'), false); + assert.equal(element.find('notes')[0].hasAttribute('template'), false); + }); + }); }); From 897543234ac3ab55147c41aa9a8a3e724b8ed63b Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 17:40:33 -0500 Subject: [PATCH 26/41] Lock in form tests --- .../karma/ts/services/form.service.spec.ts | 1575 ++++++----------- 1 file changed, 575 insertions(+), 1000 deletions(-) diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index 6ee36ff43dd..639c3737794 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -1,9 +1,8 @@ import { fakeAsync, flush, TestBed } from '@angular/core/testing'; import sinon from 'sinon'; -import { assert, expect } from 'chai'; +import { expect } from 'chai'; import { provideMockStore } from '@ngrx/store/testing'; import * as _ from 'lodash-es'; -import { cloneDeep } from 'lodash-es'; import { toBik_text } from 'bikram-sambat'; import * as moment from 'moment'; @@ -12,12 +11,9 @@ import { Form2smsService } from '@mm-services/form2sms.service'; import { SearchService } from '@mm-services/search.service'; import { SettingsService } from '@mm-services/settings.service'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FileReaderService } from '@mm-services/file-reader.service'; import { UserContactService } from '@mm-services/user-contact.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; -import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulation-data.service'; -import { AttachmentService } from '@mm-services/attachment.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; import { ZScoreService } from '@mm-services/z-score.service'; import { DuplicatesFoundError, FormService, WebappEnketoFormContext } from '@mm-services/form.service'; @@ -31,9 +27,7 @@ import * as medicXpathExtensions from '../../../../src/js/enketo/medic-xpath-ext import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; import { EnketoService } from '@mm-services/enketo.service'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import * as FileManager from '../../../../src/js/enketo/file-manager.js'; +import { FormConfig } from '@mm-services/form/form-config'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; import { DeduplicateService } from '@mm-services/deduplicate.service'; @@ -61,11 +55,21 @@ describe('Form service', () => { const VISIT_MODEL = loadXML('visit'); const VISIT_MODEL_WITH_CONTACT_SUMMARY = loadXML('visit-contact-summary'); + const USER_SETTINGS = { name: 'Jim', language: 'en' }; + const USER_CONTACT = { _id: '123', phone: '555' }; + + const mockFormConfig = ( + formInternalId: string, + { type = 'report', html = '
my form
', model = VISIT_MODEL, xml = '' }: any = {} + ) => new FormConfig(mockEnketoDoc(formInternalId), type, xml, html, model); + const reportEnketoForm = ( + form, + { internalId = 'V', xmlVersion, xml = '' }: any = {} + ) => ({ form, config: new FormConfig({ internalId, xmlVersion }, 'report', xml, '
', VISIT_MODEL) }); let service; let setLastChangedDoc; - let enketoInit; let dbGetAttachment; let getReport; let getContact; @@ -74,21 +78,14 @@ describe('Form service', () => { let Form2Sms; let UserContact; let UserSettings; - let createObjectURL; - let FileReader; let TranslateFrom; let form; - let AddAttachment; - let removeAttachment; - let EnketoForm; - let EnketoPrepopulationData; + let enketoService; let Search; let LineageModelGenerator; let transitionsService; let translateService; let xmlFormsService; - let xmlFormGet; - let xmlFormGetWithAttachment; let zScoreService; let zScoreUtil; let chtDatasourceService; @@ -98,7 +95,6 @@ describe('Form service', () => { let consoleErrorMock; let consoleWarnMock; let feedbackService; - let extractLineageService; let targetAggregatesService; let contactViewModelGeneratorService; let deduplicateService; @@ -109,7 +105,6 @@ describe('Form service', () => { let userContactSummaryService; beforeEach(() => { - enketoInit = sinon.stub(); dbGetAttachment = sinon.stub(); getReport = sinon.stub(); getContact = sinon.stub(); @@ -118,8 +113,6 @@ describe('Form service', () => { Form2Sms = sinon.stub(); UserContact = sinon.stub(); UserSettings = sinon.stub(); - createObjectURL = sinon.stub(); - FileReader = { utf8: sinon.stub() }; TranslateFrom = sinon.stub(); form = { validate: sinon.stub(), @@ -129,7 +122,6 @@ describe('Form service', () => { $: { on: sinon.stub() }, html: document.createElement('div'), }, - init: enketoInit, langs: { setAll: () => { }, $formLanguages: $(''), @@ -137,24 +129,18 @@ describe('Form service', () => { calc: { update: () => { } }, output: { update: () => { } }, }; - AddAttachment = sinon.stub(); - removeAttachment = sinon.stub(); - EnketoForm = sinon.stub(); - EnketoPrepopulationData = sinon.stub(); + enketoService = { + renderForm: sinon.stub().resolves(form), + saveContact: sinon.stub(), + saveReport: sinon.stub(), + unload: sinon.stub(), + getCurrentForm: sinon.stub(), + }; Search = sinon.stub(); LineageModelGenerator = { contact: sinon.stub() }; - xmlFormGet = sinon.stub().resolves({ _id: 'abc' }); - xmlFormGetWithAttachment = sinon.stub().resolves({ doc: { _id: 'abc', xml: '' } }); xmlFormsService = { - get: xmlFormGet, - getDocAndFormAttachment: xmlFormGetWithAttachment, canAccessForm: sinon.stub(), - HTML_ATTACHMENT_NAME: 'form.html', - MODEL_ATTACHMENT_NAME: 'model.xml', }; - window.EnketoForm = EnketoForm; - window.URL.createObjectURL = createObjectURL; - EnketoForm.returns(form); transitionsService = { applyTransitions: sinon.stub().resolvesArg(0) }; translateService = { instant: sinon.stub().returnsArg(0), @@ -178,7 +164,6 @@ describe('Form service', () => { consoleErrorMock = sinon.stub(console, 'error'); consoleWarnMock = sinon.stub(console, 'warn'); feedbackService = { submit: sinon.stub() }; - extractLineageService = { extract: ExtractLineageService.prototype.extract }; targetAggregatesService = { getTargetDocs: sinon.stub() }; contactViewModelGeneratorService = { loadReports: sinon.stub() }; userContactSummaryService = { get: sinon.stub() }; @@ -204,12 +189,9 @@ describe('Form service', () => { { provide: SearchService, useValue: { search: Search } }, { provide: SettingsService, useValue: { get: sinon.stub().resolves({}) } }, { provide: LineageModelGeneratorService, useValue: LineageModelGenerator }, - { provide: FileReaderService, useValue: FileReader }, { provide: UserContactService, useValue: { get: UserContact } }, { provide: UserSettingsService, useValue: { getWithLanguage: UserSettings } }, { provide: TranslateFromService, useValue: { get: TranslateFrom } }, - { provide: EnketoPrepopulationDataService, useValue: { get: EnketoPrepopulationData } }, - { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: XmlFormsService, useValue: xmlFormsService }, { provide: ZScoreService, useValue: zScoreService }, { provide: CHTDatasourceService, useValue: chtDatasourceService }, @@ -217,7 +199,7 @@ describe('Form service', () => { { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: FeedbackService, useValue: feedbackService }, - { provide: ExtractLineageService, useValue: extractLineageService }, + { provide: EnketoService, useValue: enketoService }, { provide: TargetAggregatesService, useValue: targetAggregatesService }, { provide: ContactViewModelGeneratorService, useValue: contactViewModelGeneratorService }, { provide: DeduplicateService, useValue: deduplicateService }, @@ -227,7 +209,7 @@ describe('Form service', () => { ], }); - UserSettings.resolves({ name: 'Jim', language: 'en' }); + UserSettings.resolves(USER_SETTINGS); TranslateFrom.returns('translated'); window.CHTCore = {}; }); @@ -273,7 +255,7 @@ describe('Form service', () => { it('renders error when user does not have associated contact', () => { UserContact.resolves(); return service - .render(new WebappEnketoFormContext('#', 'report', { })) + .render(new WebappEnketoFormContext('#', mockFormConfig('myform'))) .then(() => { expect.fail('Should throw error'); }) @@ -285,28 +267,23 @@ describe('Form service', () => { }); it('return error when form initialisation fails', fakeAsync(async () => { - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); UserContact.resolves({ contact_id: '123-user-contact' }); xmlFormsService.canAccessForm.resolves(true); ContactSummary.resolves({ context: { pregnant: false } }); userContactSummaryService.get.resolves({ context: { chw: true } }); - Search.resolves([{ _id: 'some_report' }]); LineageModelGenerator.contact.resolves({ lineage: [{ _id: 'some_parent' }] }); const instanceData = { contact: { _id: '123-patient-contact' } }; - EnketoPrepopulationData.returns(''); - const expectedErrorTitle = `Failed during the form "myform" rendering : `; const expectedErrorDetail = ['nope', 'still nope']; - const expectedErrorMessage = expectedErrorTitle + JSON.stringify(expectedErrorDetail); - enketoInit.returns(expectedErrorDetail); + const expectedErrorMessage = `Failed during the form "myform" rendering : ${JSON.stringify(expectedErrorDetail)}`; + enketoService.renderForm.rejects(new Error(JSON.stringify(expectedErrorDetail))); - const formContext = new WebappEnketoFormContext('#div', 'report', mockEnketoDoc('myform'), instanceData); + const formContext = new WebappEnketoFormContext( + '#div', + mockFormConfig('myform', { model: VISIT_MODEL_WITH_CONTACT_SUMMARY }), + instanceData + ); try { await service.render(formContext); @@ -334,74 +311,47 @@ describe('Form service', () => { shouldEvaluateExpression: true }, ]); - expect(enketoInit.callCount).to.equal(1); + expect(enketoService.renderForm.calledOnce).to.be.true; expect(error.message).to.equal(expectedErrorMessage); expect(consoleErrorMock.callCount).to.equal(0); } })); - it('return form when everything works', () => { - expect(form.editStatus).to.be.undefined; + it('return form when everything works', async () => { UserContact.resolves({ contact_id: '123' }); xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); - enketoInit.returns([]); - FileReader.utf8.resolves(''); - EnketoPrepopulationData.returns(''); - return service.render(new WebappEnketoFormContext('#div', 'task', mockEnketoDoc('myform'))).then(() => { - expect(UserContact.callCount).to.equal(1); - expect(EnketoPrepopulationData.callCount).to.equal(1); - expect(FileReader.utf8.callCount).to.equal(2); - expect(FileReader.utf8.args[0][0]).to.equal('
my form
'); - expect(FileReader.utf8.args[1][0]).to.equal(VISIT_MODEL); - expect(enketoInit.callCount).to.equal(1); - expect(form.editStatus).to.equal(false); - expect(dbGetAttachment.callCount).to.equal(2); - expect(dbGetAttachment.args[0][0]).to.equal('form:myform'); - expect(dbGetAttachment.args[0][1]).to.equal('form.html'); - expect(dbGetAttachment.args[1][0]).to.equal('form:myform'); - expect(dbGetAttachment.args[1][1]).to.equal('model.xml'); - }); + const formContext = new WebappEnketoFormContext( + '#div', + mockFormConfig('myform', { type: 'task' }) + ); + + const result = await service.render(formContext); + + expect(result).to.equal(form); + expect(UserContact).to.have.been.calledOnceWithExactly(); + expect(enketoService.renderForm).to.have.been.calledOnceWithExactly(formContext, USER_SETTINGS); }); - it('passes xml instance data through to Enketo', () => { + it('passes xml instance data through to Enketo', async () => { const data = '123'; UserContact.resolves({ contact_id: '123' }); xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - EnketoPrepopulationData.returns(data); - const formContext = new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'), data); - return service.render(formContext).then(() => { - expect(EnketoForm.callCount).to.equal(1); - expect(EnketoForm.args[0][1].modelStr).to.equal('my model'); - expect(EnketoForm.args[0][1].instanceStr).to.equal(data); - }); + const formConfig = mockFormConfig('myform', { model: 'my model' }); + const formContext = new WebappEnketoFormContext('div', formConfig, data); + + const result = await service.render(formContext); + + expect(result).to.equal(form); + expect(enketoService.renderForm).to.have.been.calledOnceWithExactly(formContext, USER_SETTINGS); }); - it('passes contact summary data to enketo', () => { - const data = '123'; + it('passes contact summary data to enketo', async () => { UserContact.resolves({ _id: '456', contact_id: '123', facility_id: '789' }); xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - EnketoPrepopulationData.returns(data); const instanceData = { contact: { _id: 'fffff', @@ -417,107 +367,49 @@ describe('Form service', () => { contactViewModelGeneratorService.loadReports.resolves([{ _id: 'somereport' }]); targetAggregatesService.getTargetDocs.resolves([{ _id: 't1' }, { _id: 't2' }]); LineageModelGenerator.contact.resolves({ lineage: [{ _id: 'someparent' }] }); - const formContext = new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); + const formContext = new WebappEnketoFormContext( + 'div', + mockFormConfig('myform', { model: VISIT_MODEL_WITH_CONTACT_SUMMARY }), + instanceData + ); service.setUserContext(['facility'], 'contact'); - return service.render(formContext).then(() => { - expect(EnketoForm.callCount).to.equal(1); - const summary = EnketoForm.args[0][1].external; - expect(summary.length).to.equal(2); - expect(summary[0].id).to.equal('contact-summary'); - expect(new XMLSerializer().serializeToString(summary[0].xml)) - .to.equal('true'); - expect(summary[1].id).to.equal('user-contact-summary'); - expect(new XMLSerializer().serializeToString(summary[1].xml)) - .to.equal('true'); - expect(contactViewModelGeneratorService.loadReports.callCount).to.equal(1); - expect(contactViewModelGeneratorService.loadReports.args[0]).to.deep.equal( - [{ doc: instanceData.contact }, []] - ); - expect(LineageModelGenerator.contact.callCount).to.equal(1); - expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); - expect(targetAggregatesService.getTargetDocs.callCount).to.equal(1); - expect(targetAggregatesService.getTargetDocs.args[0]).to.deep.equal([ - { _id: 'fffff', patient_id: '44509' }, ['facility'], 'contact' - ]); - expect(ContactSummary.callCount).to.equal(1); - expect(ContactSummary.args[0][0]._id).to.equal('fffff'); - expect(ContactSummary.args[0][1].length).to.equal(1); - expect(ContactSummary.args[0][1][0]._id).to.equal('somereport'); - expect(ContactSummary.args[0][2].length).to.equal(1); - expect(ContactSummary.args[0][2][0]._id).to.equal('someparent'); - expect(ContactSummary.args[0][3]).to.deep.equal([{ _id: 't1' }, { _id: 't2' }]); - }); - }); - it('handles arrays and escaping characters', () => { - const data = '123'; - UserContact.resolves({ - _id: '456', - contact_id: '123', - facility_id: '789' - }); - xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - EnketoPrepopulationData.returns(data); - const instanceData = { - contact: { - _id: 'fffff' + await service.render(formContext); + + expect(enketoService.renderForm.args).to.deep.equal([[ + { + ...formContext, + contactSummary: { id: 'contact-summary', context: { pregnant: true } }, + userContactSummary: { id: 'user-contact-summary', context: { chw: true } } }, - inputs: { - patient_id: 123, - name: 'sharon' - } - }; - ContactSummary.resolves({ - context: { - pregnant: true, - previousChildren: [{ dob: 2016 }, { dob: 2013 }, { dob: 2010 }], - notes: `always reserved "characters" & 'words'` - } - }); - userContactSummaryService.get.resolves({ context: { chw: true, forms: false } }); - LineageModelGenerator.contact.resolves({ lineage: [] }); - const formContext = new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); - return service.render(formContext).then(() => { - expect(EnketoForm.callCount).to.equal(1); - expect(EnketoForm.args[0][1].external.length).to.equal(2); - const summary = EnketoForm.args[0][1].external[0]; - expect(summary.id).to.equal('contact-summary'); - const xmlStr = new XMLSerializer().serializeToString(summary.xml); - expect(xmlStr).to.equal('true2016' + - '20132010always <uses> reserved "' + - 'characters" & \'words\''); - - const userSummary = EnketoForm.args[0][1].external[1]; - expect(userSummary.id).to.equal('user-contact-summary'); - expect(new XMLSerializer().serializeToString(userSummary.xml)) - .to.equal('truefalse'); - - expect(ContactSummary.callCount).to.equal(1); - expect(ContactSummary.args[0][0]._id).to.equal('fffff'); - }); + USER_SETTINGS + ]]); + expect(contactViewModelGeneratorService.loadReports.callCount).to.equal(1); + expect(contactViewModelGeneratorService.loadReports.args[0]).to.deep.equal( + [{ doc: instanceData.contact }, []] + ); + expect(LineageModelGenerator.contact.callCount).to.equal(1); + expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); + expect(targetAggregatesService.getTargetDocs.callCount).to.equal(1); + expect(targetAggregatesService.getTargetDocs.args[0]).to.deep.equal([ + { _id: 'fffff', patient_id: '44509' }, ['facility'], 'contact' + ]); + expect(ContactSummary.callCount).to.equal(1); + expect(ContactSummary.args[0][0]._id).to.equal('fffff'); + expect(ContactSummary.args[0][1].length).to.equal(1); + expect(ContactSummary.args[0][1][0]._id).to.equal('somereport'); + expect(ContactSummary.args[0][2].length).to.equal(1); + expect(ContactSummary.args[0][2][0]._id).to.equal('someparent'); + expect(ContactSummary.args[0][3]).to.deep.equal([{ _id: 't1' }, { _id: 't2' }]); }); - it('does not get contact summary when the form has no instance for it', () => { - const data = '123'; + it('does not get contact summary when the form has no instance for it', async () => { UserContact.resolves({ _id: '456', contact_id: '123', facility_id: '789' }); xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); - enketoInit.returns([]); - FileReader.utf8.resolves(''); - EnketoPrepopulationData.resolves(data); const instanceData = { contact: { _id: 'fffff' @@ -527,17 +419,21 @@ describe('Form service', () => { name: 'sharon' } }; - const formContext = new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); - return service.render(formContext).then(() => { - expect(EnketoForm.callCount).to.equal(1); - expect(EnketoForm.args[0][1].external).to.deep.equal([]); - expect(ContactSummary.callCount).to.equal(0); - expect(userContactSummaryService.get.callCount).to.equal(0); - expect(LineageModelGenerator.contact.callCount).to.equal(0); - }); + const formContext = new WebappEnketoFormContext( + 'div', + mockFormConfig('myform', { model: VISIT_MODEL }), + instanceData + ); + + await service.render(formContext); + + expect(enketoService.renderForm).to.have.been.calledOnceWithExactly(formContext, USER_SETTINGS); + expect(ContactSummary.callCount).to.equal(0); + expect(userContactSummaryService.get.callCount).to.equal(0); + expect(LineageModelGenerator.contact.callCount).to.equal(0); }); - it('ContactSummary receives empty lineage if contact doc is missing', () => { + it('ContactSummary receives empty lineage if contact doc is missing', async () => { LineageModelGenerator.contact.rejects({ code: 404 }); UserContact.resolves({ @@ -546,14 +442,6 @@ describe('Form service', () => { facility_id: '789' }); xmlFormsService.canAccessForm.resolves(true); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - EnketoPrepopulationData.returns('123'); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); const instanceData = { contact: { _id: 'fffff', @@ -561,58 +449,39 @@ describe('Form service', () => { } }; ContactSummary.resolves({ context: { pregnant: true } }); - Search.resolves([{ _id: 'somereport' }]); - const formContext = new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); - return service.render(formContext).then(() => { - expect(LineageModelGenerator.contact.callCount).to.equal(1); - expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); - expect(ContactSummary.callCount).to.equal(1); - expect(ContactSummary.args[0][2].length).to.equal(0); - expect(consoleWarnMock.callCount).to.equal(1); - expect(consoleWarnMock.args[0][0].startsWith('Enketo failed to get lineage of contact')).to.be.true; - }); + const formContext = new WebappEnketoFormContext( + 'div', + mockFormConfig('myform', { model: VISIT_MODEL_WITH_CONTACT_SUMMARY }), + instanceData + ); + + await service.render(formContext); + + expect(LineageModelGenerator.contact.callCount).to.equal(1); + expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); + expect(ContactSummary.callCount).to.equal(1); + expect(ContactSummary.args[0][2].length).to.equal(0); + expect(consoleWarnMock.callCount).to.equal(1); + expect(consoleWarnMock.args[0][0].startsWith('Enketo failed to get lineage of contact')).to.be.true; }); it('should execute the unload process before rendering the second form', async () => { - enketoInit.returns([]); - FileReader.utf8.resolves(''); - EnketoPrepopulationData.returns(''); UserContact.resolves({ contact_id: '123' }); xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
first form
') - .onSecondCall().resolves(VISIT_MODEL); + enketoService.getCurrentForm.returns(undefined); - await service.render(new WebappEnketoFormContext('#div', 'report', mockEnketoDoc('firstForm'))); - expect(form.resetView.notCalled).to.be.true; + await service.render(new WebappEnketoFormContext('#div', mockFormConfig('firstForm'))); + expect(enketoService.unload.calledOnceWithExactly(undefined)).to.be.true; + expect(enketoService.renderForm.calledOnce).to.be.true; expect(UserContact.calledOnce).to.be.true; - expect(EnketoPrepopulationData.calledOnce).to.be.true; - expect(FileReader.utf8.calledTwice).to.be.true; - expect(FileReader.utf8.args[0][0]).to.equal('
first form
'); - expect(FileReader.utf8.args[1][0]).to.equal(VISIT_MODEL); - expect(enketoInit.calledOnce).to.be.true; - expect(form.editStatus).to.be.false; - expect(dbGetAttachment.calledTwice).to.be.true; - expect(dbGetAttachment.args[0]).to.have.members(['form:firstForm', 'form.html']); - expect(dbGetAttachment.args[1]).to.have.members(['form:firstForm', 'model.xml']); sinon.resetHistory(); - dbGetAttachment - .onFirstCall().resolves('
second form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + enketoService.getCurrentForm.returns(form); - await service.render(new WebappEnketoFormContext('#div', 'report', mockEnketoDoc('secondForm'))); - expect(form.resetView.calledOnce).to.be.true; + await service.render(new WebappEnketoFormContext('#div', mockFormConfig('secondForm'))); + expect(enketoService.unload).to.have.been.calledOnceWithExactly(form); + expect(enketoService.renderForm.calledOnce).to.be.true; expect(UserContact.calledOnce).to.be.true; - expect(EnketoPrepopulationData.calledOnce).to.be.true; - expect(FileReader.utf8.calledTwice).to.be.true; - expect(FileReader.utf8.args[0][0]).to.equal('
second form
'); - expect(FileReader.utf8.args[1][0]).to.equal(VISIT_MODEL_WITH_CONTACT_SUMMARY); - expect(enketoInit.calledOnce).to.be.true; - expect(form.editStatus).to.be.false; - expect(dbGetAttachment.calledTwice).to.be.true; - expect(dbGetAttachment.args[0]).to.have.members(['form:secondForm', 'form.html']); - expect(dbGetAttachment.args[1]).to.have.members(['form:secondForm', 'model.xml']); }); it('should throw exception if fails to get user settings', fakeAsync(async () => { @@ -620,40 +489,34 @@ describe('Form service', () => { const data = '123'; UserContact.resolves({ contact_id: '123' }); xmlFormsService.canAccessForm.resolves(true); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - EnketoPrepopulationData.returns(data); - const renderForm = sinon.spy(EnketoService.prototype, 'renderForm'); try { - await service.render(new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'), data)); + await service.render(new WebappEnketoFormContext( + 'div', + mockFormConfig('myform', { model: 'my model' }), + data + )); flush(); expect.fail('Should throw error'); } catch (error) { expect(error.message).to.equal('Failed during the form "myform" rendering : invalid user'); expect(UserContact.calledOnce).to.be.true; - expect(renderForm.notCalled).to.be.true; - expect(enketoInit.notCalled).to.be.true; + expect(enketoService.renderForm.notCalled).to.be.true; expect(consoleErrorMock.callCount).to.equal(0); } })); it('should throw exception when user does not have access to form', fakeAsync(async () => { - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); UserContact.resolves({ contact_id: '123-user-contact' }); xmlFormsService.canAccessForm.resolves(false); ContactSummary.resolves({ context: { pregnant: false } }); userContactSummaryService.get.resolves({ context: { chw: true } }); try { - await service.render(new WebappEnketoFormContext('div', 'report', mockEnketoDoc('myform'))); + await service.render(new WebappEnketoFormContext( + 'div', + mockFormConfig('myform', { model: VISIT_MODEL }) + )); flush(); expect.fail('Should throw error'); } catch (error) { @@ -674,7 +537,7 @@ describe('Form service', () => { undefined, { doc: undefined, contactSummary: undefined, shouldEvaluateExpression: true }, ]); - expect(enketoInit.notCalled).to.be.true; + expect(enketoService.renderForm.notCalled).to.be.true; expect(consoleErrorMock.notCalled).to.be.true; expect(feedbackService.submit.notCalled).to.be.true; } @@ -687,119 +550,95 @@ describe('Form service', () => { service = TestBed.inject(FormService); }); - it('creates report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); + it('creates report', async () => { trainingCardsService.getTrainingCardDocId.returns('training:user-jim:'); dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - actual = actual[0]; + UserContact.resolves(USER_CONTACT); + const report = { + _id: 'report-uuid', + form: 'V', + type: 'data_record', + content_type: 'xml', + contact: { _id: '123' }, + from: '555', + fields: { name: 'Sally', lmp: '10' }, + }; + enketoService.saveReport.resolves([{ ...report }]); + const reportForm = reportEnketoForm(form); - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbBulkDocs.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - expect(actual._id).to.match(/(\w+-)\w+/); - expect(actual._id.startsWith('training:user-jim:')).to.be.false; - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(removeAttachment.args[0]).excludingEvery('_rev').to.deep.equal([actual, 'content']); - expect(setLastChangedDoc.callCount).to.equal(1); - expect(setLastChangedDoc.args[0]).to.deep.equal([actual]); - }); + const [actual] = await service.save(reportForm, null); + + expect(actual).excluding('_rev').to.deep.equal(report); + expect(enketoService.saveReport).to.have.been.calledOnceWithExactly(reportForm, { contact: USER_CONTACT }); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly([actual]); + expect(UserContact).to.have.been.calledOnceWithExactly(); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(actual); }); - it('creates training', () => { - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); + it('creates training', async () => { trainingCardsService.isTrainingCardForm.returns(true); trainingCardsService.getTrainingCardDocId.returns('training:user-jim:'); - form.validate.resolves(true); dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); - UserContact.resolves({ _id: '123', phone: '555' }); + UserContact.resolves(USER_CONTACT); + const training = { + _id: 'report-uuid', + form: 'training:a_new_training', + type: 'data_record', + content_type: 'xml', + contact: { _id: '123' }, + from: '555', + fields: { name: 'Sally', lmp: '10' }, + }; + enketoService.saveReport.resolves([{ ...training }]); + const trainingForm = reportEnketoForm(form, { internalId: 'training:a_new_training' }); - return service - .save('training:a_new_training', form) - .then(actual => { - actual = actual[0]; - - expect(form.validate.calledOnce).to.be.true; - expect(form.getDataStr.calledOnce).to.be.true; - expect(dbBulkDocs.calledOnce).to.be.true; - expect(UserContact.calledOnce).to.be.true; - expect(actual._id.startsWith('training:user-jim:')).to.be.true; - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('training:a_new_training'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('training:a_new_training'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(removeAttachment.args[0]).excludingEvery('_rev').to.deep.equal([actual, 'content']); - }); + const [actual] = await service.save(trainingForm, null); + + expect(actual).excluding('_rev').to.deep.equal({ ...training, _id: 'training:user-jim:' }); + expect(enketoService.saveReport).to.have.been.calledOnceWithExactly(trainingForm, { contact: USER_CONTACT }); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly([actual]); + expect(UserContact).to.have.been.calledOnceWithExactly(); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(actual); }); - it('creates training when user has no contact', () => { - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); + it('creates training when user has no contact', async () => { trainingCardsService.isTrainingCardForm.returns(true); trainingCardsService.getTrainingCardDocId.returns('training:user-jim:'); - form.validate.resolves(true); dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); UserContact.resolves(null); + const training = { + _id: 'report-uuid', + form: 'training:a_new_training', + type: 'data_record', + content_type: 'xml', + fields: { name: 'Sally', lmp: '10' }, + }; + enketoService.saveReport.resolves([{ ...training }]); + const trainingForm = reportEnketoForm(form, { internalId: 'training:a_new_training' }); - return service - .save('training:a_new_training', form) - .then(actual => { - actual = actual[0]; - - expect(form.validate.calledOnce).to.be.true; - expect(form.getDataStr.calledOnce).to.be.true; - expect(dbBulkDocs.calledOnce).to.be.true; - expect(UserContact.calledOnce).to.be.true; - expect(actual._id.startsWith('training:user-jim:')).to.be.true; - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('training:a_new_training'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact).to.be.null; - expect(actual.from).to.be.undefined; - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('training:a_new_training'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(removeAttachment.args[0]).excludingEvery('_rev').to.deep.equal([actual, 'content']); - }); + const [actual] = await service.save(trainingForm, null); + + expect(actual).excluding('_rev').to.deep.equal({ ...training, _id: 'training:user-jim:' }); + expect(enketoService.saveReport).to.have.been.calledOnceWithExactly(trainingForm, { contact: null }); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly([actual]); + expect(UserContact).to.have.been.calledOnceWithExactly(); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(actual); }); describe('Geolocation recording', () => { - it('saves geolocation data into a new report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); + it('saves geolocation data into a new report', async () => { dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); - xmlFormGetWithAttachment.resolves({ doc: { _id: 'V' }, xml: '' }); - - UserContact.resolves({ _id: '123', phone: '555' }); + UserContact.resolves(USER_CONTACT); + const report = { + _id: 'report-uuid', + form: 'V', + type: 'data_record', + content_type: 'xml', + contact: { _id: '123' }, + from: '555', + fields: { name: 'Sally', lmp: '10' }, + }; + enketoService.saveReport.resolves([{ ...report }]); const geoData = { latitude: 1, longitude: 2, @@ -809,75 +648,58 @@ describe('Form service', () => { heading: 6, speed: 7 }; - return service.save('V', form, () => Promise.resolve(geoData)).then(actual => { - actual = actual[0]; + const reportForm = reportEnketoForm(form); - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbBulkDocs.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - expect(actual._id).to.match(/(\w+-)\w+/); - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(actual.geolocation).to.deep.equal(geoData); - expect(actual.geolocation_log.length).to.equal(1); - expect(actual.geolocation_log[0].timestamp).to.be.greaterThan(0); - expect(actual.geolocation_log[0].recording).to.deep.equal(geoData); - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); + const [actual] = await service.save(reportForm, () => Promise.resolve(geoData)); + + expect(actual).excludingEvery(['_rev', 'timestamp']).to.deep.equal({ + ...report, + geolocation: geoData, + geolocation_log: [{ recording: geoData }] }); + expect(enketoService.saveReport).to.have.been.calledOnceWithExactly(reportForm, { contact: USER_CONTACT }); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly([actual]); + expect(UserContact).to.have.been.calledOnceWithExactly(); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(actual); + expect(actual._rev).to.equal('1-abc'); + expect(actual.geolocation_log[0].timestamp).to.be.greaterThan(0); }); - it('saves a geolocation error into a new report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); + it('saves a geolocation error into a new report', async () => { dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); - xmlFormGetWithAttachment.resolves({ doc: { _id: 'V' }, xml: '' }); - UserContact.resolves({ _id: '123', phone: '555' }); + UserContact.resolves(USER_CONTACT); + const report = { + _id: 'report-uuid', + form: 'V', + type: 'data_record', + content_type: 'xml', + contact: { _id: '123' }, + from: '555', + fields: { name: 'Sally', lmp: '10' }, + }; + enketoService.saveReport.resolves([report]); const geoError = { code: 42, message: 'some bad geo' }; - return service.save('V', form, () => Promise.reject(geoError)).then(actual => { - actual = actual[0]; + const reportForm = reportEnketoForm(form); - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbBulkDocs.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - expect(actual._id).to.match(/(\w+-)\w+/); - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(actual.geolocation).to.deep.equal(geoError); - expect(actual.geolocation_log.length).to.equal(1); - expect(actual.geolocation_log[0].timestamp).to.be.greaterThan(0); - expect(actual.geolocation_log[0].recording).to.deep.equal(geoError); - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); + const [actual] = await service.save(reportForm, () => Promise.reject(geoError)); + + expect(actual).excludingEvery(['_rev', 'timestamp']).to.deep.equal({ + ...report, + geolocation: geoError, + geolocation_log: [{ recording: geoError }] }); + expect(enketoService.saveReport).to.have.been.calledOnceWithExactly(reportForm, { contact: USER_CONTACT }); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly([actual]); + expect(UserContact).to.have.been.calledOnceWithExactly(); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(actual); + expect(actual._rev).to.equal('1-abc'); + expect(actual.geolocation_log[0].timestamp).to.be.greaterThan(0); }); - it('overwrites existing geolocation info on edit with new info and appends to the log', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); + it('overwrites existing geolocation info on edit with new info and appends to the log', async () => { const originalGeoData = { latitude: 1, longitude: 2, @@ -891,7 +713,7 @@ describe('Form service', () => { timestamp: 12345, recording: originalGeoData }; - getReport.resolves({ + const originalReport = { _id: '6', _rev: '1-abc', form: 'V', @@ -902,7 +724,20 @@ describe('Form service', () => { reported_date: 500, geolocation: originalGeoData, geolocation_log: [originalGeoLogEntry] - }); + }; + getReport.resolves(originalReport); + const report = { + _id: '6', + _rev: '1-abc', + form: 'V', + type: 'data_record', + content_type: 'xml', + reported_date: 500, + fields: { name: 'Sally', lmp: '10' }, + geolocation: originalGeoData, + geolocation_log: [originalGeoLogEntry], + }; + enketoService.saveReport.resolves([report]); dbBulkDocs.resolves([{ ok: true, id: '6', rev: '2-abc' }]); const geoData = { latitude: 10, @@ -913,72 +748,27 @@ describe('Form service', () => { heading: 15, speed: 16 }; - return service.save('V', form, () => Promise.resolve(geoData), '6').then(actual => { - actual = actual[0]; + const reportForm = reportEnketoForm(form); - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(getReport.calledOnceWithExactly(Qualifier.byUuid('6'))).to.be.true; - expect(dbBulkDocs.callCount).to.equal(1); - expect(actual._id).to.equal('6'); - expect(actual._rev).to.equal('2-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.reported_date).to.equal(500); - expect(actual.content_type).to.equal('xml'); - expect(actual.geolocation).to.deep.equal(geoData); - expect(actual.geolocation_log.length).to.equal(2); - expect(actual.geolocation_log[0]).to.deep.equal(originalGeoLogEntry); - expect(actual.geolocation_log[1].timestamp).to.be.greaterThan(0); - expect(actual.geolocation_log[1].recording).to.deep.equal(geoData); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(setLastChangedDoc.callCount).to.equal(1); - expect(setLastChangedDoc.args[0]).to.deep.equal([actual]); - }); - }); - }); + const [actual] = await service.save(reportForm, () => Promise.resolve(geoData), '6'); - it('creates report with erroring geolocation', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); - dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); - UserContact.resolves({ _id: '123', phone: '555' }); - const geoError = { - code: 42, - message: 'geolocation failed for some reason' - }; - return service.save('V', form, () => Promise.reject(geoError)).then(actual => { - actual = actual[0]; - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbBulkDocs.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - expect(actual._id).to.match(/(\w+-)\w+/); - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(actual.geolocation).to.deep.equal(geoError); - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); + expect(actual).excludingEvery(['_rev', 'timestamp']).to.deep.equal({ + ...report, + geolocation: geoData, + geolocation_log: [originalGeoLogEntry, { recording: geoData }] + }); + expect(enketoService.saveReport).to.have.been.calledOnceWithExactly(reportForm, originalReport); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly([actual]); + expect(UserContact).to.not.have.been.called; + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(actual); + expect(actual._rev).to.equal('2-abc'); + expect(actual.geolocation_log[0].timestamp).to.equal(12345); + expect(actual.geolocation_log[1].timestamp).to.be.greaterThan(0); }); }); it('updates report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); getReport.resolves({ _id: '6', _rev: '1-abc', @@ -989,12 +779,20 @@ describe('Form service', () => { type: DOC_TYPES.DATA_RECORD, reported_date: 500, }); + enketoService.saveReport.resolves([{ + _id: '6', + _rev: '1-abc', + form: 'V', + type: 'data_record', + content_type: 'xml', + reported_date: 500, + fields: { name: 'Sally', lmp: '10' }, + }]); dbBulkDocs.resolves([{ ok: true, id: '6', rev: '2-abc' }]); - return service.save('V', form, null, '6').then(actual => { + return service.save(reportEnketoForm(form), null, '6').then(actual => { actual = actual[0]; - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); + expect(enketoService.saveReport.calledOnce).to.be.true; expect(getReport.calledOnceWithExactly(Qualifier.byUuid('6'))).to.be.true; expect(dbBulkDocs.callCount).to.equal(1); expect(actual._id).to.equal('6'); @@ -1005,92 +803,117 @@ describe('Form service', () => { expect(actual.type).to.equal('data_record'); expect(actual.reported_date).to.equal(500); expect(actual.content_type).to.equal('xml'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(removeAttachment.args[0]).excludingEvery('_rev').to.deep.equal([actual, 'content']); expect(setLastChangedDoc.callCount).to.equal(1); expect(setLastChangedDoc.args[0]).to.deep.equal([actual]); }); }); - it('creates extra docs', () => { - + it('creates extra docs', async () => { const startTime = Date.now() - 1; - - form.validate.resolves(true); - const content = loadXML('extra-docs'); - form.getDataStr.returns(content); dbBulkDocs.callsFake(docs => { return Promise.resolve(docs.map(doc => { return { ok: true, id: doc._id, rev: `1-${doc._id}-abc` }; })); }); - UserContact.resolves({ _id: '123', phone: '555' }); - - return service.save('V', form, null, null).then(actual => { - const endTime = Date.now() + 1; + UserContact.resolves(USER_CONTACT); + enketoService.saveReport.resolves([ + { + _id: 'report-uuid', + form: 'V', + type: 'data_record', + content_type: 'xml', + contact: { _id: '123' }, + from: '555', + hidden_fields: ['secret_code_name', 'doc1', 'doc2'], + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + doc1: { some_property_1: 'some_value_1', type: 'thing_1' }, + doc2: { some_property_2: 'some_value_2', type: 'thing_2' }, + }, + }, + { _id: 'thing1-uuid', reported_date: Date.now(), type: 'thing_1', some_property_1: 'some_value_1' }, + { _id: 'thing2-uuid', reported_date: Date.now(), type: 'thing_2', some_property_2: 'some_value_2' }, + ]); - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbBulkDocs.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - - expect(actual.length).to.equal(3); - - const actualReport = actual[0]; - expect(actualReport._id).to.match(/(\w+-)\w+/); - expect(actualReport._rev).to.equal(`1-${actualReport._id}-abc`); - expect(actualReport.fields.name).to.equal('Sally'); - expect(actualReport.fields.lmp).to.equal('10'); - expect(actualReport.fields.secret_code_name).to.equal('S4L'); - expect(actualReport.form).to.equal('V'); - expect(actualReport.type).to.equal('data_record'); - expect(actualReport.content_type).to.equal('xml'); - expect(actualReport.contact._id).to.equal('123'); - expect(actualReport.from).to.equal('555'); - expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'doc1', 'doc2']); - - expect(actualReport.fields.doc1).to.deep.equal({ - some_property_1: 'some_value_1', - type: 'thing_1', - }); - expect(actualReport.fields.doc2).to.deep.equal({ - some_property_2: 'some_value_2', - type: 'thing_2', - }); + const actual = await service.save(reportEnketoForm(form), null, null); + const endTime = Date.now() + 1; + + expect(enketoService.saveReport.calledOnce).to.be.true; + expect(dbBulkDocs.callCount).to.equal(1); + expect(UserContact.callCount).to.equal(1); + + expect(actual.length).to.equal(3); + + const actualReport = actual[0]; + expect(actualReport._id).to.match(/(\w+-)\w+/); + expect(actualReport._rev).to.equal(`1-${actualReport._id}-abc`); + expect(actualReport.fields.name).to.equal('Sally'); + expect(actualReport.fields.lmp).to.equal('10'); + expect(actualReport.fields.secret_code_name).to.equal('S4L'); + expect(actualReport.form).to.equal('V'); + expect(actualReport.type).to.equal('data_record'); + expect(actualReport.content_type).to.equal('xml'); + expect(actualReport.contact._id).to.equal('123'); + expect(actualReport.from).to.equal('555'); + expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'doc1', 'doc2']); + + expect(actualReport.fields.doc1).to.deep.equal({ + some_property_1: 'some_value_1', + type: 'thing_1', + }); + expect(actualReport.fields.doc2).to.deep.equal({ + some_property_2: 'some_value_2', + type: 'thing_2', + }); - const actualThing1 = actual[1]; - expect(actualThing1._id).to.match(/(\w+-)\w+/); - expect(actualThing1._rev).to.equal(`1-${actualThing1._id}-abc`); - expect(actualThing1.reported_date).to.be.within(startTime, endTime); - expect(actualThing1.some_property_1).to.equal('some_value_1'); + const actualThing1 = actual[1]; + expect(actualThing1._id).to.match(/(\w+-)\w+/); + expect(actualThing1._rev).to.equal(`1-${actualThing1._id}-abc`); + expect(actualThing1.reported_date).to.be.within(startTime, endTime); + expect(actualThing1.some_property_1).to.equal('some_value_1'); - const actualThing2 = actual[2]; - expect(actualThing2._id).to.match(/(\w+-)\w+/); - expect(actualThing2._rev).to.equal(`1-${actualThing2._id}-abc`); - expect(actualThing2.reported_date).to.be.within(startTime, endTime); - expect(actualThing2.some_property_2).to.equal('some_value_2'); + const actualThing2 = actual[2]; + expect(actualThing2._id).to.match(/(\w+-)\w+/); + expect(actualThing2._rev).to.equal(`1-${actualThing2._id}-abc`); + expect(actualThing2.reported_date).to.be.within(startTime, endTime); + expect(actualThing2.some_property_2).to.equal('some_value_2'); - expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); + expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); - expect(setLastChangedDoc.callCount).to.equal(1); - expect(setLastChangedDoc.args[0]).to.deep.equal([actualReport]); - }); + expect(setLastChangedDoc.callCount).to.equal(1); + expect(setLastChangedDoc.args[0]).to.deep.equal([actualReport]); }); - it('creates extra docs with geolocation', () => { - + it('creates extra docs with geolocation', async () => { const startTime = Date.now() - 1; - - form.validate.resolves(true); - const content = loadXML('extra-docs'); - form.getDataStr.returns(content); dbBulkDocs.resolves([ { ok: true, id: '6', rev: '1-abc' }, { ok: true, id: '7', rev: '1-def' }, { ok: true, id: '8', rev: '1-ghi' } ]); - UserContact.resolves({ _id: '123', phone: '555' }); + UserContact.resolves(USER_CONTACT); + enketoService.saveReport.resolves([ + { + _id: 'report-uuid', + form: 'V', + type: 'data_record', + content_type: 'xml', + contact: { _id: '123' }, + from: '555', + hidden_fields: ['secret_code_name', 'doc1', 'doc2'], + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + doc1: { some_property_1: 'some_value_1', type: 'thing_1' }, + doc2: { some_property_2: 'some_value_2', type: 'thing_2' }, + }, + }, + { _id: 'thing1-uuid', reported_date: Date.now(), type: 'thing_1', some_property_1: 'some_value_1' }, + { _id: 'thing2-uuid', reported_date: Date.now(), type: 'thing_2', some_property_2: 'some_value_2' }, + ]); const geoData = { latitude: 1, longitude: 2, @@ -1100,95 +923,85 @@ describe('Form service', () => { heading: 6, speed: 7 }; - return service.save('V', form, () => Promise.resolve(geoData)).then(actual => { - const endTime = Date.now() + 1; - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbBulkDocs.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - - expect(actual.length).to.equal(3); - - const actualReport = actual[0]; - expect(actualReport._id).to.match(/(\w+-)\w+/); - expect(actualReport.fields.name).to.equal('Sally'); - expect(actualReport.fields.lmp).to.equal('10'); - expect(actualReport.fields.secret_code_name).to.equal('S4L'); - expect(actualReport.form).to.equal('V'); - expect(actualReport.type).to.equal('data_record'); - expect(actualReport.content_type).to.equal('xml'); - expect(actualReport.contact._id).to.equal('123'); - expect(actualReport.from).to.equal('555'); - expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'doc1', 'doc2']); - - expect(actualReport.fields.doc1).to.deep.equal({ - some_property_1: 'some_value_1', - type: 'thing_1', - }); - expect(actualReport.fields.doc2).to.deep.equal({ - some_property_2: 'some_value_2', - type: 'thing_2', - }); + const actual = await service.save(reportEnketoForm(form), () => Promise.resolve(geoData)); + const endTime = Date.now() + 1; + + expect(enketoService.saveReport.calledOnce).to.be.true; + expect(dbBulkDocs.callCount).to.equal(1); + expect(UserContact.callCount).to.equal(1); + + expect(actual.length).to.equal(3); + + const actualReport = actual[0]; + expect(actualReport._id).to.match(/(\w+-)\w+/); + expect(actualReport.fields.name).to.equal('Sally'); + expect(actualReport.fields.lmp).to.equal('10'); + expect(actualReport.fields.secret_code_name).to.equal('S4L'); + expect(actualReport.form).to.equal('V'); + expect(actualReport.type).to.equal('data_record'); + expect(actualReport.content_type).to.equal('xml'); + expect(actualReport.contact._id).to.equal('123'); + expect(actualReport.from).to.equal('555'); + expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'doc1', 'doc2']); + + expect(actualReport.fields.doc1).to.deep.equal({ + some_property_1: 'some_value_1', + type: 'thing_1', + }); + expect(actualReport.fields.doc2).to.deep.equal({ + some_property_2: 'some_value_2', + type: 'thing_2', + }); - expect(actualReport.geolocation).to.deep.equal(geoData); + expect(actualReport.geolocation).to.deep.equal(geoData); - const actualThing1 = actual[1]; - expect(actualThing1._id).to.match(/(\w+-)\w+/); - expect(actualThing1.reported_date).to.be.above(startTime); - expect(actualThing1.reported_date).to.be.below(endTime); - expect(actualThing1.some_property_1).to.equal('some_value_1'); - expect(actualThing1.geolocation).to.deep.equal(geoData); + const actualThing1 = actual[1]; + expect(actualThing1._id).to.match(/(\w+-)\w+/); + expect(actualThing1.reported_date).to.be.above(startTime); + expect(actualThing1.reported_date).to.be.below(endTime); + expect(actualThing1.some_property_1).to.equal('some_value_1'); + expect(actualThing1.geolocation).to.deep.equal(geoData); - const actualThing2 = actual[2]; - expect(actualThing2._id).to.match(/(\w+-)\w+/); - expect(actualThing2.reported_date).to.be.above(startTime); - expect(actualThing2.reported_date).to.be.below(endTime); - expect(actualThing2.some_property_2).to.equal('some_value_2'); + const actualThing2 = actual[2]; + expect(actualThing2._id).to.match(/(\w+-)\w+/); + expect(actualThing2.reported_date).to.be.above(startTime); + expect(actualThing2.reported_date).to.be.below(endTime); + expect(actualThing2.some_property_2).to.equal('some_value_2'); - expect(actualThing2.geolocation).to.deep.equal(geoData); + expect(actualThing2.geolocation).to.deep.equal(geoData); - expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); - }); + expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); }); describe('Saving attachments', () => { it('should save attachments', async () => { - const file = { name: 'my_file', type: 'image', foo: 'bar' }; - sinon - .stub(FileManager, 'getCurrentFiles') - .returns([file]); - - form.validate.resolves(true); - const content = loadXML('file-field'); - - form.getDataStr.returns(content); - dbGetAttachment.resolves(''); UserContact.resolves({ _id: 'my-user', phone: '8989' }); dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); + enketoService.saveReport.resolves([{ + _id: 'report-uuid', + _attachments: { 'user-file-my_file': { content_type: 'image', data: 'abc' } }, + }]); // @ts-ignore const saveDocsSpy = sinon.spy(FormService.prototype, 'saveDocs'); - await service.save('my-form', form, () => Promise.resolve(true)); - expect(AddAttachment.calledOnce).to.be.true; + await service.save(reportEnketoForm(form, { internalId: 'my-form' }), () => Promise.resolve(true)); + + expect(enketoService.saveReport.calledOnce).to.be.true; expect(saveDocsSpy.calledOnce).to.be.true; - expect(AddAttachment.args[0][1]).to.equal(`user-file-${file.name}`); - expect(AddAttachment.args[0][2]).to.deep.equal(file); - expect(AddAttachment.args[0][3]).to.equal(file.type); + const savedDoc = dbBulkDocs.args[0][0][0]; + expect(savedDoc._attachments['user-file-my_file']).to.deep.include({ content_type: 'image' }); expect(globalActions.setSnackbarContent.notCalled).to.be.true; }); it('should throw exception if attachments are big', () => { translateService.get.returnsArg(0); - form.validate.resolves(true); - dbGetAttachment.resolves(''); UserContact.resolves({ _id: 'my-user', phone: '8989' }); // @ts-ignore const saveDocsStub = sinon.stub(FormService.prototype, 'saveDocs'); - // @ts-ignore - const xmlToDocsStub = sinon.stub(EnketoService.prototype, 'xmlToDocs').resolves([ + enketoService.saveReport.resolves([ { _id: '1a' }, { _id: '1b', _attachments: {} }, { @@ -1208,42 +1021,61 @@ describe('Form service', () => { ]); return service - .save('my-form', form, () => Promise.resolve(true)) + .save(reportEnketoForm(form, { internalId: 'my-form' }), () => Promise.resolve(true)) .then(() => expect.fail('Should have thrown exception.')) .catch(error => { - expect(xmlToDocsStub.calledOnce); + expect(enketoService.saveReport.calledOnce).to.be.true; expect(error.message).to.equal('enketo.error.max_attachment_size'); - expect(saveDocsStub.notCalled); - expect(globalActions.setSnackbarContent.calledOnce); + expect(saveDocsStub.notCalled).to.be.true; + expect(globalActions.setSnackbarContent.calledOnce).to.be.true; expect(globalActions.setSnackbarContent.args[0]).to.have.members(['enketo.error.max_attachment_size']); }); }); it('should pass docs to transitions and save results', () => { - form.validate.resolves(true); - const content = - ` - Sally - 10 - - repeater - some_value_1 - - - repeater - some_value_2 - - - repeater - some_value_3 - - `; - form.getDataStr.returns(content); - dbBulkDocs.callsFake(docs => Promise.resolve(docs.map(doc => ({ ok: true, id: doc._id, rev: '2' })))); - UserContact.resolves({ _id: '123', phone: '555' }); + UserContact.resolves(USER_CONTACT); + enketoService.saveReport.resolves([ + { + _id: 'report-uuid', + contact: {}, + content_type: 'xml', + fields: { + name: 'Sally', lmp: '10', + repeat_doc: [ + { type: 'repeater', some_property: 'some_value_1' }, + { type: 'repeater', some_property: 'some_value_2' }, + { type: 'repeater', some_property: 'some_value_3' }, + ], + }, + hidden_fields: ['repeat_doc'], + form: 'V', + form_version: { time: '1', sha256: 'imahash' }, + from: '555', + _attachments: undefined, + type: DOC_TYPES.DATA_RECORD, + }, + { + _id: 'thing1-uuid', + form_version: { time: '1', sha256: 'imahash' }, + type: 'repeater', + some_property: 'some_value_1', + }, + { + _id: 'thing2-uuid', + form_version: { time: '1', sha256: 'imahash' }, + type: 'repeater', + some_property: 'some_value_2', + }, + { + _id: 'thing3-uuid', + form_version: { time: '1', sha256: 'imahash' }, + type: 'repeater', + some_property: 'some_value_3', + }, + ]); const geoHandle = sinon.stub().resolves({ geo: 'data' }); transitionsService.applyTransitions.callsFake((docs) => { const clones = _.cloneDeep(docs); // cloning for clearer assertions, as the main array gets mutated @@ -1251,14 +1083,13 @@ describe('Form service', () => { clones.push({ _id: 'new doc', type: 'existent doc updated by the transition' }); return Promise.resolve(clones); }); - xmlFormGetWithAttachment.resolves({ - doc: { _id: 'abc', xmlVersion: { time: '1', sha256: 'imahash' } }, + const enketoForm = reportEnketoForm(form, { + xmlVersion: { time: '1', sha256: 'imahash' }, xml: `` }); - return service.save('V', form, geoHandle).then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); + return service.save(enketoForm, geoHandle).then(actual => { + expect(enketoService.saveReport.calledOnce).to.be.true; expect(dbBulkDocs.callCount).to.equal(1); expect(transitionsService.applyTransitions.callCount).to.equal(1); expect(UserContact.callCount).to.equal(1); @@ -1291,23 +1122,27 @@ describe('Form service', () => { form: 'V', form_version: { time: '1', sha256: 'imahash' }, from: '555', + _attachments: undefined, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: DOC_TYPES.DATA_RECORD, }, { + form_version: { time: '1', sha256: 'imahash' }, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: 'repeater', some_property: 'some_value_1', }, { + form_version: { time: '1', sha256: 'imahash' }, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: 'repeater', some_property: 'some_value_2', }, { + form_version: { time: '1', sha256: 'imahash' }, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: 'repeater', @@ -1343,12 +1178,14 @@ describe('Form service', () => { form: 'V', form_version: { time: '1', sha256: 'imahash' }, from: '555', + _attachments: undefined, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: DOC_TYPES.DATA_RECORD, transitioned: true, }, { + form_version: { time: '1', sha256: 'imahash' }, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: 'repeater', @@ -1356,6 +1193,7 @@ describe('Form service', () => { transitioned: true, }, { + form_version: { time: '1', sha256: 'imahash' }, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: 'repeater', @@ -1363,6 +1201,7 @@ describe('Form service', () => { transitioned: true, }, { + form_version: { time: '1', sha256: 'imahash' }, geolocation: { geo: 'data' }, geolocation_log: [{ recording: { geo: 'data' } }], type: 'repeater', @@ -1380,390 +1219,124 @@ describe('Form service', () => { }); describe('saveContact', () => { - let clock; - let extractLineageService; - let enketoTranslationService; + const enketoForm = { form: {}, config: { doc: {} } }; + const bulkDocsResult = [{ ok: true, id: 'main1', rev: '1-abc' }]; - beforeEach(() => { - extractLineageService = { extract: sinon.stub() }; - enketoTranslationService = { - contactRecordToJs: sinon.stub(), - }; - - getDuplicates.resolvesArg(2); + beforeEach(() => service = TestBed.inject(FormService)); - TestBed.configureTestingModule({ - providers: [ - provideMockStore(), - { - provide: DbService, - useValue: { - get: () => ({ getAttachment: dbGetAttachment, bulkDocs: dbBulkDocs }) - } - }, - { provide: ContactSummaryService, useValue: { get: ContactSummary } }, - { provide: Form2smsService, useValue: { transform: Form2Sms } }, - { provide: SearchService, useValue: { search: Search } }, - { provide: SettingsService, useValue: { get: sinon.stub().resolves({}) } }, - { provide: LineageModelGeneratorService, useValue: LineageModelGenerator }, - { provide: FileReaderService, useValue: FileReader }, - { provide: UserContactService, useValue: { get: UserContact } }, - { provide: UserSettingsService, useValue: { getWithLanguage: UserSettings } }, - { provide: TranslateFromService, useValue: { get: TranslateFrom } }, - { provide: EnketoPrepopulationDataService, useValue: { get: EnketoPrepopulationData } }, - { provide: ExtractLineageService, useValue: extractLineageService }, - { provide: EnketoTranslationService, useValue: enketoTranslationService }, - { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, - { provide: XmlFormsService, useValue: xmlFormsService }, - { provide: ZScoreService, useValue: zScoreService }, - { provide: CHTDatasourceService, useValue: chtDatasourceService }, - { provide: TransitionsService, useValue: transitionsService }, - { provide: TranslateService, useValue: translateService }, - { provide: TrainingCardsService, useValue: trainingCardsService }, - { provide: FeedbackService, useValue: feedbackService }, - { provide: DeduplicateService, useValue: deduplicateService }, - { provide: ContactsService, useValue: contactsService }, - { provide: UserContactSummaryService, useValue: userContactSummaryService }, - ], - }); - - service = TestBed.inject(FormService); - }); - - afterEach(() => { - clock?.restore(); - }); - - it('fetches and binds db types and minifies string contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; + it('resolves type fields as default data and saves the prepared docs for a new contact', async () => { const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'abc' } - }); - dbBulkDocs.resolves([]); - getContact.resolves({ _id: 'abc', name: 'gareth', parent: { _id: 'def' } }); - extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - - return service - .saveContact({ docId, type }, { form }) - .then(() => { - assert.isTrue(getContact.calledOnceWithExactly(Qualifier.byUuid('abc'))); - - assert.equal(dbBulkDocs.callCount, 1); - - const savedDocs = dbBulkDocs.args[0][0]; - - assert.equal(savedDocs.length, 1); - assert.deepEqual(savedDocs[0].contact, { - _id: 'abc', - parent: { - _id: 'def' - } - }); - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); - }); + const preparedDocs = [{ _id: 'main1', type }]; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs }); + dbBulkDocs.resolves(bulkDocsResult); + + const result = await service.saveContact({ docId: null, type }, enketoForm, false); + + expect(result).to.deep.equal({ docId: 'main1', bulkDocsResult }); + expect(enketoService.saveContact) + .to.have.been.calledOnceWithExactly(enketoForm, { type: 'contact', contact_type: type }); + expect(getContact).to.not.have.been.called; + expect(transitionsService.applyTransitions).to.have.been.calledOnceWithExactly(preparedDocs); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly(preparedDocs); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(preparedDocs[0]); }); - it('fetches and binds db types and minifies object contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; + it('fetches the existing contact to use as default data when editing', async () => { const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: { _id: 'abc', name: 'Richard' } } - }); - dbBulkDocs.resolves([]); - getContact.resolves({ _id: 'abc', name: 'Richard', parent: { _id: 'def' } }); - extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - - return service - .saveContact({ docId, type }, { form }) - .then(() => { - assert.isTrue(getContact.calledOnceWithExactly(Qualifier.byUuid('abc'))); - - assert.equal(dbBulkDocs.callCount, 1); - - const savedDocs = dbBulkDocs.args[0][0]; - - assert.equal(savedDocs.length, 1); - assert.deepEqual(savedDocs[0].contact, { - _id: 'abc', - parent: { - _id: 'def' - } - }); - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); - }); - }); - - it('should include parent ID in repeated children', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'NEW' }, - siblings: { - contact: { _id: 'sis1', type: 'sister', parent: 'PARENT', }, - }, - repeats: { - child_data: [{ _id: 'kid1', type: 'child', parent: 'PARENT', }], - }, - }); - - extractLineageService.extract.callsFake(contact => { - contact.extracted = true; - return contact; - }); - - dbBulkDocs.resolves([]); - - return service - .saveContact({ docId, type }, { form }) - .then(() => { - assert.isTrue(dbBulkDocs.calledOnce); - - const savedDocs = dbBulkDocs.args[0][0]; - - assert.equal(savedDocs[0]._id, 'main1'); - - assert.equal(savedDocs[1]._id, 'kid1'); - assert.equal(savedDocs[1].parent._id, 'main1'); - assert.equal(savedDocs[1].parent.extracted, true); - - assert.equal(savedDocs[2]._id, 'sis1'); - assert.equal(savedDocs[2].parent._id, 'main1'); - assert.equal(savedDocs[2].parent.extracted, true); - - assert.equal(extractLineageService.extract.callCount, 3); - - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); - }); - }); - - it('should copy old properties for existing contacts', () => { - const form = { getDataStr: () => '' }; - const docId = 'main1'; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ - doc: { - _id: 'main1', - type: 'contact', - contact_type: 'some-contact-type', - contact: { _id: 'contact', name: 'Richard' }, - value: undefined, - } - }); - dbBulkDocs.resolves([]); - getContact - .withArgs(Qualifier.byUuid('main1')) - .resolves({ - _id: 'main1', - name: 'Richard', - parent: { _id: 'def' }, - value: 33, - some: 'additional', - data: 'is present', - }) - .withArgs(Qualifier.byUuid('contact')) - .resolves({ _id: 'contact', name: 'Richard', parent: { _id: 'def' } }); - - extractLineageService.extract - .withArgs(sinon.match({ _id: 'contact' })) - .returns({ _id: 'contact', parent: { _id: 'def' } }) - .withArgs(sinon.match({ _id: 'def' })) - .returns({ _id: 'def' }); - clock = sinon.useFakeTimers({now: 5000}); - - return service - .saveContact({ docId, type }, { form }) - .then(() => { - assert.equal(getContact.callCount, 2); - assert.deepEqual(getContact.args[0], [Qualifier.byUuid('main1')]); - assert.deepEqual(getContact.args[1], [Qualifier.byUuid('contact')]); - - assert.equal(dbBulkDocs.callCount, 1); - - const savedDocs = dbBulkDocs.args[0][0]; - - assert.equal(savedDocs.length, 1); - assert.deepEqual(savedDocs[0], { - _id: 'main1', - type: 'contact', - name: 'Richard', - contact_type: 'some-contact-type', - contact: { _id: 'contact', parent: { _id: 'def' } }, - parent: { _id: 'def' }, - value: 33, - some: 'additional', - data: 'is present', - reported_date: 5000, - }); - - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); - }); + const existingContact = { _id: 'main1', name: 'Richard' }; + getContact.resolves(existingContact); + const preparedDocs = [{ _id: 'main1', type }]; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs }); + dbBulkDocs.resolves(bulkDocsResult); + + const result = await service.saveContact({ docId: 'main1', type }, enketoForm, false); + + expect(result).to.deep.equal({ docId: 'main1', bulkDocsResult }); + expect(enketoService.saveContact) + .to.have.been.calledOnceWithExactly(enketoForm, existingContact); + expect(getContact.calledOnceWithExactly(Qualifier.byUuid('main1'))).to.be.true; + expect(transitionsService.applyTransitions).to.have.been.calledOnceWithExactly(preparedDocs); + expect(dbBulkDocs).to.have.been.calledOnceWithExactly(preparedDocs); + expect(setLastChangedDoc).to.have.been.calledOnceWithExactly(preparedDocs[0]); }); - it('should pass the contacts to transitions service before saving and save modified contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; + it('uses the primary doc (matching the contact type) as the last changed doc', async () => { const type = 'some-contact-type'; + const primaryDoc = { _id: 'main1', type }; + const preparedDocs = [{ _id: 'other', type: 'other' }, primaryDoc]; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs }); + dbBulkDocs.resolves([{ ok: true, id: 'other' }, { ok: true, id: 'main1' }]); - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: { _id: 'abc', name: 'Richard' } } - }); - dbBulkDocs.resolves([]); - getContact.resolves({ _id: 'abc', name: 'Richard', parent: { _id: 'def' } }); - extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - transitionsService.applyTransitions.callsFake((docs) => { - const clonedDocs = cloneDeep(docs); // don't mutate so we can assert - clonedDocs[0].transitioned = true; - clonedDocs.push({ this: 'is a new doc' }); - return Promise.resolve(clonedDocs); - }); - clock = sinon.useFakeTimers({now: 1000}); + await service.saveContact({ docId: null, type }, enketoForm, false); - return service - .saveContact({ docId, type }, { form }) - .then(() => { - assert.isTrue(getContact.calledOnceWithExactly(Qualifier.byUuid('abc'))); - - assert.equal(transitionsService.applyTransitions.callCount, 1); - assert.deepEqual(transitionsService.applyTransitions.args[0], [[ - { - _id: 'main1', - contact: { _id: 'abc', parent: { _id: 'def' } }, - contact_type: type, - type: 'contact', - parent: undefined, - reported_date: 1000 - } - ]]); - - assert.equal(dbBulkDocs.callCount, 1); - const savedDocs = dbBulkDocs.args[0][0]; - - assert.equal(savedDocs.length, 2); - assert.deepEqual(savedDocs, [ - { - _id: 'main1', - contact: { _id: 'abc', parent: { _id: 'def' } }, - contact_type: type, - type: 'contact', - parent: undefined, - reported_date: 1000, - transitioned: true, - }, - { this: 'is a new doc' }, - ]); - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); - }); + expect(setLastChangedDoc.calledOnce).to.be.true; + expect(setLastChangedDoc.args[0]).to.deep.equal([primaryDoc]); }); - it('should throw an error with duplicates found', async function () { - const form = { getDataStr: () => '' }; - const docId = null; + it('should throw an error with duplicates found', async () => { const type = 'some-contact-type'; - - getContact.resolves({}); - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } - }); - extractLineageService.extract.returns({ _id: 'parent1' }); - transitionsService.applyTransitions.callsFake((docs) => { - docs[0].transitioned = true; - return Promise.resolve(docs); - }); - dbBulkDocs.resolves([]); - clock = sinon.useFakeTimers(1000); - + const primaryDoc = { _id: 'main1', type }; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs: [primaryDoc] }); const duplicates = [ - { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - }, - { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } + { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, type }, + { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, type }, ]; - contactsService.getSiblings.resolves(duplicates); + const siblings = [{ _id: 'sib1' }, { _id: 'sib2' }]; + contactsService.getSiblings.resolves(siblings); + getDuplicates.resolves(duplicates); - await expect(service.saveContact( - { docId, type, }, - { form, xmlVersion: undefined, duplicateCheck: undefined }, - false - )).to.be.rejectedWith(DuplicatesFoundError, 'Duplicates found') + await expect(service.saveContact({ docId: null, type }, enketoForm, false)) + .to.be.rejectedWith(DuplicatesFoundError, 'Duplicates found') .and.eventually.have.property('duplicates', duplicates); + expect(getDuplicates).to.have.been.calledOnceWithExactly(primaryDoc, type, siblings, undefined); expect(performanceService.track.calledOnceWithExactly()).to.be.true; expect(performanceTracking.stop.calledOnceWithExactly({ name: `enketo:contacts:${type}:duplicate_check` })).to.be.true; + expect(dbBulkDocs.notCalled).to.be.true; }); - it('should pass duplicate check when duplicates are acknowledged', async function () { - const form = { getDataStr: () => '' }; - const docId = null; + it('should pass the configured duplicate_check to the deduplicate service', async () => { const type = 'some-contact-type'; + const duplicateCheck = { expression: 'some expression' }; + const mainDoc = { _id: 'main1', type }; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs: [mainDoc] }); + contactsService.getSiblings.resolves([{ _id: 'sib1' }]); + getDuplicates.resolves([]); + dbBulkDocs.resolves(bulkDocsResult); + const enketoForm = { form: {}, config: { doc: { duplicate_check: duplicateCheck } } }; + + await service.saveContact({ docId: null, type }, enketoForm, false); + + expect(getDuplicates).to.have.been.calledOnceWithExactly(mainDoc, type, [{ _id: 'sib1' }], duplicateCheck); + expect(dbBulkDocs.calledOnce).to.be.true; + }); - getContact.resolves({}); - enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } - }); - extractLineageService.extract.returns({ _id: 'parent1' }); - transitionsService.applyTransitions.callsFake((docs) => { - docs[0].transitioned = true; - return Promise.resolve(docs); - }); + it('should skip the duplicate check when duplicates are acknowledged', async () => { + const type = 'some-contact-type'; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs: [{ _id: 'main1', type }] }); + dbBulkDocs.resolves(bulkDocsResult); - dbBulkDocs.resolves([]); - clock = sinon.useFakeTimers(1000); + await service.saveContact({ docId: null, type }, enketoForm, true); + + expect(performanceService.track.notCalled).to.be.true; + expect(contactsService.getSiblings.notCalled).to.be.true; + expect(getDuplicates.notCalled).to.be.true; + expect(dbBulkDocs.calledOnce).to.be.true; + }); + + it('should throw when bulkDocs reports a failure', async () => { + const type = 'some-contact-type'; + enketoService.saveContact.resolves({ docId: 'main1', preparedDocs: [{ _id: 'main1', type }] }); + dbBulkDocs.resolves([{ ok: false, id: 'main1', message: 'conflict' }]); + + await expect(service.saveContact({ docId: null, type }, enketoForm, true)) + .to.be.rejectedWith(Error, 'Some documents did not save correctly: main1 with conflict; '); - contactsService.getSiblings.resolves([{ - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - }, - { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - },]); - - await service.saveContact({ docId, type, }, { form, xmlVersion: undefined, duplicateCheck: undefined }, true); - assert.equal(transitionsService.applyTransitions.callCount, 1); - assert.deepEqual(transitionsService.applyTransitions.args[0], [[ - { - _id: 'main1', - name: 'Main', - type: 'contact', - contact_type: type, - parent: { _id: 'parent1' }, - reported_date: 1000, - contact: undefined, - transitioned: true - } - ]]); expect(performanceService.track.notCalled).to.be.true; - expect(performanceTracking.stop.notCalled).to.be.true; + expect(contactsService.getSiblings.notCalled).to.be.true; + expect(getDuplicates.notCalled).to.be.true; + expect(dbBulkDocs.calledOnce).to.be.true; }); }); @@ -1812,47 +1385,49 @@ describe('Form service', () => { }); describe('WebappEnketoFormContext', () => { + const formConfigForType = (type, doc: any = {}) => new FormConfig(doc, type, '
', '
', ''); + it('should construct object correctly', () => { - const context = new WebappEnketoFormContext('#sel', 'task', { doc: 1 }, { data: 1 }); + const config = formConfigForType('task', { doc: 1 }); + const context = new WebappEnketoFormContext('#sel', config, { data: 1 }); expect(context).to.deep.include({ selector: '#sel', - type: 'task', - formDoc: { doc: 1 }, + formConfig: config, instanceData: { data: 1 }, }); }); it('shouldEvaluateExpression should return false for tasks', () => { - const ctx = new WebappEnketoFormContext('a', 'task', {}, {}); + const ctx = new WebappEnketoFormContext('a', formConfigForType('task')); expect(ctx.shouldEvaluateExpression()).to.eq(false); }); it('shouldEvaluateExpression should return false for editing reports', () => { - const ctx = new WebappEnketoFormContext('a', 'report', {}, {}); + const ctx = new WebappEnketoFormContext('a', formConfigForType('report')); ctx.editing = true; expect(ctx.shouldEvaluateExpression()).to.eq(false); }); it('shouldEvaluateExpression should return true for reports and contact forms', () => { - const ctxReport = new WebappEnketoFormContext('a', 'report', {}, {}); + const ctxReport = new WebappEnketoFormContext('a', formConfigForType('report')); expect(ctxReport.shouldEvaluateExpression()).to.eq(true); - const ctxContact = new WebappEnketoFormContext('a', 'contact', {}, {}); + const ctxContact = new WebappEnketoFormContext('a', formConfigForType('contact')); expect(ctxContact.shouldEvaluateExpression()).to.eq(true); }); it('requiresContact should return true when type is not contact', () => { - const ctxReport = new WebappEnketoFormContext('a', 'report', {}, {}); + const ctxReport = new WebappEnketoFormContext('a', formConfigForType('report')); expect(ctxReport.requiresContact()).to.eq(true); }); it('requiresContact should return false when type is contact', () => { - const ctxReport = new WebappEnketoFormContext('a', 'contact', {}, {}); + const ctxReport = new WebappEnketoFormContext('a', formConfigForType('contact')); expect(ctxReport.requiresContact()).to.eq(false); }); it('requiresContact should return false for training forms', () => { - const ctxTraining = new WebappEnketoFormContext('a', 'training-card', {}, {}); + const ctxTraining = new WebappEnketoFormContext('a', formConfigForType('training-card')); expect(ctxTraining.requiresContact()).to.eq(false); }); }); From 2fd0db6b411d1eeb58974360f85b11cefe83e0ba Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 22:00:56 -0500 Subject: [PATCH 27/41] Lock in enekto tests --- .../karma/ts/services/enketo.service.spec.ts | 1511 ++++++++--------- 1 file changed, 705 insertions(+), 806 deletions(-) diff --git a/webapp/tests/karma/ts/services/enketo.service.spec.ts b/webapp/tests/karma/ts/services/enketo.service.spec.ts index e2b7dcf832a..e51a29f5cca 100644 --- a/webapp/tests/karma/ts/services/enketo.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo.service.spec.ts @@ -2,22 +2,22 @@ import { fakeAsync, flush, TestBed } from '@angular/core/testing'; import sinon from 'sinon'; import { expect } from 'chai'; import { provideMockStore } from '@ngrx/store/testing'; -import * as _ from 'lodash-es'; import { HttpClient } from '@angular/common/http'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { DbService } from '@mm-services/db.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulation-data.service'; -import { AttachmentService } from '@mm-services/attachment.service'; import { TranslateService } from '@mm-services/translate.service'; -import { EnketoService } from '@mm-services/enketo.service'; +import { EnketoService, FormValidationError } from '@mm-services/enketo.service'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; +import { FormConfig } from '@mm-services/form/form-config'; import * as FileManager from '../../../../src/js/enketo/file-manager.js'; import { WebappEnketoFormContext } from '@mm-services/form.service'; -import { Qualifier, Report } from '@medic/cht-datasource'; +import { REPORT_ATTACHMENT_NAME } from '@mm-services/get-report-content.service'; import { DOC_TYPES } from '@medic/constants'; import events from 'enketo-core/src/js/event'; +import { Qualifier } from '@medic/cht-datasource'; describe('Enketo service', () => { // return a mock form ready for putting in #dbContent @@ -34,16 +34,21 @@ describe('Enketo service', () => { const VISIT_MODEL = loadXML('visit'); const VISIT_MODEL_WITH_CONTACT_SUMMARY = loadXML('visit-contact-summary'); + const buildFormConfig = ({ + doc = mockEnketoDoc('myform'), + type = 'report', + xml = '', + html = $('
my form
'), + model = VISIT_MODEL, + }: Record = {}) => new FormConfig(doc, type, xml, html, model); + let service; let enketoInit; let dbGetAttachment; - let getReport; let createObjectURL; let TranslateFrom; let form; - let AddAttachment; - let removeAttachment; let EnketoForm; let EnketoPrepopulationData; let translateService; @@ -53,7 +58,6 @@ describe('Enketo service', () => { beforeEach(() => { enketoInit = sinon.stub(); dbGetAttachment = sinon.stub(); - getReport = sinon.stub(); createObjectURL = sinon.stub(); TranslateFrom = sinon.stub(); form = { @@ -72,8 +76,6 @@ describe('Enketo service', () => { calc: { update: () => { } }, output: { update: () => { } }, }; - AddAttachment = sinon.stub(); - removeAttachment = sinon.stub(); EnketoForm = sinon.stub(); EnketoPrepopulationData = sinon.stub(); window.EnketoForm = EnketoForm; @@ -84,9 +86,7 @@ describe('Enketo service', () => { get: sinon.stub(), }; extractLineageService = { extract: ExtractLineageService.prototype.extract }; - chtDatasourceService = { - bind: sinon.stub().withArgs(Report.v1.get).returns(getReport) - }; + chtDatasourceService = { bind: sinon.stub() }; TestBed.configureTestingModule({ providers: [ @@ -99,7 +99,6 @@ describe('Enketo service', () => { }, { provide: TranslateFromService, useValue: { get: TranslateFrom } }, { provide: EnketoPrepopulationDataService, useValue: { get: EnketoPrepopulationData } }, - { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: TranslateService, useValue: translateService }, { provide: ExtractLineageService, useValue: extractLineageService }, { provide: CHTDatasourceService, useValue: chtDatasourceService }, @@ -128,16 +127,12 @@ describe('Enketo service', () => { const formContext = { selector: $('
'), - formDoc: mockEnketoDoc('myform') - }; - const doc = { - html: $('
my form
'), - model: VISIT_MODEL, + formConfig: buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL }), }; const userSettings = { language: 'en' }; try { - await service.renderForm(formContext, doc, userSettings); + await service.renderForm(formContext, userSettings); flush(); expect.fail('Should throw error'); } catch (error) { @@ -152,14 +147,10 @@ describe('Enketo service', () => { EnketoPrepopulationData.returns(''); const formContext = { selector: $('
'), - formDoc: mockEnketoDoc('myform') - }; - const doc = { - html: $('
my form
'), - model: VISIT_MODEL, + formConfig: buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL }), }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoPrepopulationData.callCount).to.equal(1); expect(enketoInit.callCount).to.equal(1); expect(form.editStatus).to.equal(false); @@ -174,14 +165,10 @@ describe('Enketo service', () => { const wrapper = $('
'); const formContext = { selector: wrapper, - formDoc: mockEnketoDoc('myform') - }; - const doc = { - html: $('
'), - model: VISIT_MODEL, + formConfig: buildFormConfig({ html: $('
'), model: VISIT_MODEL }), }; const userSettings = { language: 'en' }; - await service.renderForm(formContext, doc, userSettings); + await service.renderForm(formContext, userSettings); await Promise.resolve(); // need to wait for async get attachment to complete const img = wrapper.find('img').first(); expect(img.css('visibility')).to.satisfy(val => { @@ -202,14 +189,10 @@ describe('Enketo service', () => { const wrapper = $('
'); const formContext = { selector: wrapper, - formDoc: mockEnketoDoc('myform') - }; - const doc = { - html: $('
'), - model: VISIT_MODEL, + formConfig: buildFormConfig({ html: $('
'), model: VISIT_MODEL }), }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { flush(); const img = wrapper.find('img').first(); expect(img.attr('src')).to.equal(undefined); @@ -231,15 +214,11 @@ describe('Enketo service', () => { EnketoPrepopulationData.returns(data); const formContext = { selector: $('
'), - formDoc: mockEnketoDoc('myform'), + formConfig: buildFormConfig({ html: $('
my form
'), model: 'my model' }), instanceData: data }; - const doc = { - html: $('
my form
'), - model: 'my model', - }; const userSettings = { name: 'Jim', language: 'sw' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); expect(EnketoForm.args[0][2].language).to.equal('sw'); }); @@ -252,15 +231,11 @@ describe('Enketo service', () => { EnketoPrepopulationData.returns(data); const formContext = { selector: $('
'), - formDoc: mockEnketoDoc('myform'), + formConfig: buildFormConfig({ html: $('
my form
'), model: 'my model' }), instanceData: data }; - const doc = { - html: $('
my form
'), - model: 'my model', - }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); expect(EnketoForm.args[0][1].modelStr).to.equal('my model'); expect(EnketoForm.args[0][1].instanceStr).to.equal(data); @@ -279,15 +254,11 @@ describe('Enketo service', () => { EnketoPrepopulationData.returns(data); const formContext = { selector: $('
'), - formDoc: mockEnketoDoc('myform'), + formConfig: buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL }), instanceData }; - const doc = { - html: $('
my form
'), - model: VISIT_MODEL, - }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); expect(EnketoForm.args[0][1].modelStr).to.equal(VISIT_MODEL); expect(EnketoForm.args[0][1].instanceStr).to.equal(data); @@ -308,14 +279,14 @@ describe('Enketo service', () => { }; enketoInit.returns([]); EnketoPrepopulationData.returns(data); - const formContext = new WebappEnketoFormContext('#div', 'report', mockEnketoDoc('myform'), instanceData); - formContext.contactSummary = { id: 'contact-summary', context: { pregnant: true } }; - const doc = { + const formConfig = buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL_WITH_CONTACT_SUMMARY, - }; + }); + const formContext = new WebappEnketoFormContext('#div', formConfig, instanceData); + formContext.contactSummary = { id: 'contact-summary', context: { pregnant: true } }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); expect(EnketoForm.args[0][1].external.length).to.equal(1); const summary = EnketoForm.args[0][1].external[0]; @@ -339,14 +310,14 @@ describe('Enketo service', () => { }; enketoInit.returns([]); EnketoPrepopulationData.returns(data); - const formContext = new WebappEnketoFormContext('#div', 'report', mockEnketoDoc('myform'), instanceData); - formContext.userContactSummary = { id: 'user-contact-summary', context: { chw: true } }; - const doc = { + const formConfig = buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL_WITH_CONTACT_SUMMARY, - }; + }); + const formContext = new WebappEnketoFormContext('#div', formConfig, instanceData); + formContext.userContactSummary = { id: 'user-contact-summary', context: { chw: true } }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); expect(EnketoForm.args[0][1].external.length).to.equal(1); const summary = EnketoForm.args[0][1].external[0]; @@ -370,15 +341,15 @@ describe('Enketo service', () => { }; enketoInit.returns([]); EnketoPrepopulationData.returns(data); - const formContext = new WebappEnketoFormContext('#div', 'report', mockEnketoDoc('myform'), instanceData); - formContext.contactSummary = { id: 'contact-summary', context: { pregnant: true } }; - formContext.userContactSummary = { id: 'user-contact-summary', context: { chw: true } }; - const doc = { + const formConfig = buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL_WITH_CONTACT_SUMMARY, - }; + }); + const formContext = new WebappEnketoFormContext('#div', formConfig, instanceData); + formContext.contactSummary = { id: 'contact-summary', context: { pregnant: true } }; + formContext.userContactSummary = { id: 'user-contact-summary', context: { chw: true } }; const userSettings = { language: 'en' }; - return service.renderForm(formContext, doc, userSettings).then(() => { + return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); expect(EnketoForm.args[0][1].external.length).to.equal(2); const contactSummary = EnketoForm.args[0][1].external[0]; @@ -416,19 +387,14 @@ describe('Enketo service', () => { it('should translate titleKey when provided', async () => { const formContext = { selector: $('
'), - formDoc, + formConfig: buildFormConfig({ doc: formDoc, html: $('
my form
'), model: VISIT_MODEL }), instanceData, editedListener: callbackMock, valuechangeListener: callbackMock, titleKey: 'contact.type.health_center.new', }; - const doc = { - html: $('
my form
'), - model: VISIT_MODEL, - title: 'New Area', - }; const userSettings = { language: 'en' }; - await service.renderForm(formContext, doc, userSettings); + await service.renderForm(formContext, userSettings); expect(service.setFormTitle.callCount).to.be.equal(1); expect(service.setFormTitle.args[0][1]) @@ -438,18 +404,13 @@ describe('Enketo service', () => { it('should fallback to translate document title when the titleKey is not available', async () => { const formContext = { selector: $('
'), - formDoc, + formConfig: buildFormConfig({ doc: formDoc, html: $('
my form
'), model: VISIT_MODEL }), instanceData, editedListener: callbackMock, valuechangeListener: callbackMock, }; - const doc = { - html: $('
my form
'), - model: VISIT_MODEL, - title: 'New Area', - }; const userSettings = { language: 'en' }; - await service.renderForm(formContext, doc, userSettings); + await service.renderForm(formContext, userSettings); expect(service.setFormTitle.callCount).to.be.equal(1); expect(service.setFormTitle.args[0][1]).to.be.equal('translated sentence New Area'); @@ -457,816 +418,754 @@ describe('Enketo service', () => { }); }); - describe('completeNewReport', () => { + describe('saveReport', () => { beforeEach(() => { service = TestBed.inject(EnketoService); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); }); - it('rejects on invalid form', () => { - const inputRelevant = { dataset: { relevant: 'true' } }; - const inputNonRelevant = { dataset: { relevant: 'false' } }; - const inputNoDataset = {}; - const toArray = sinon.stub().returns([inputRelevant, inputNoDataset, inputNonRelevant]); - // @ts-ignore - sinon.stub($.fn, 'find').returns({ toArray }); + const saveReport = (defaultData, { xml = '', doc = {} }: Record = {}) => { + const config = buildFormConfig({ type: 'report', xml, doc }); + return service.saveReport({ config, form }, defaultData); + }; + + it('rejects on invalid form', async () => { form.validate.resolves(false); - form.relevant = { update: sinon.stub() }; const dispatchEventStub = sinon.stub(form.view.html, 'dispatchEvent'); - return service - .completeNewReport('V', form, {}, { _id: '123', phone: '555' }) - .then(() => expect.fail('expected to reject')) - .catch(actual => { - expect(actual.message).to.equal('Form is invalid'); - expect(form.validate.callCount).to.equal(1); - expect(inputRelevant.dataset.relevant).to.equal('true'); - expect(inputNonRelevant.dataset.relevant).to.equal('false'); - // @ts-ignore - expect(inputNoDataset.dataset).to.be.undefined; - expect(dispatchEventStub).to.not.have.been.called; - }); - }); - it('creates report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); - const dispatchEventStub = sinon.stub(form.view.html, 'dispatchEvent'); - return service - .completeNewReport('V', form, { doc: { } }, { _id: '123', phone: '555' }) - .then(actual => { - actual = actual[0]; - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(actual._id).to.match(/(\w+-)\w+/); - expect(actual._id.startsWith('training:user-jim:')).to.be.false; - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(removeAttachment.args[0]).excludingEvery('_rev').to.deep.equal([actual, 'content']); - expect(dispatchEventStub).to.have.been.calledOnceWithExactly(events.BeforeSave()); - }); - }); + await expect(saveReport({ contact: { _id: '123', phone: '555' } })) + .to.be.rejectedWith(FormValidationError, 'Form is invalid'); - it('saves form version if found', () => { - const formDoc = { - doc: { _id: 'abc', xmlVersion: { time: '1', sha256: 'imahash' } }, - xml: '
' - }; - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - actual = actual[0]; - expect(actual.form_version).to.deep.equal({ time: '1', sha256: 'imahash' }); - }); + expect(form.validate.callCount).to.equal(1); + expect(dispatchEventStub).to.not.have.been.called; }); - it('creates report with hidden fields', () => { + it('builds the report doc', async () => { + const dispatchEventStub = sinon.stub(form.view.html, 'dispatchEvent'); form.validate.resolves(true); - const content = loadXML('hidden-field'); - form.getDataStr.returns(content); - return service - .completeNewReport('V', form, { doc: { } }, { _id: '123', phone: '555' }) - .then(actual => { - actual = actual[0]; - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(actual._id).to.match(/(\w+-)\w+/); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.fields.secret_code_name).to.equal('S4L'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.content_type).to.equal('xml'); - expect(actual.contact._id).to.equal('123'); - expect(actual.from).to.equal('555'); - expect(actual.hidden_fields).to.deep.equal(['secret_code_name']); - }); - }); + form.getDataStr.returns(loadXML('sally-lmp')); - it('creates extra docs', () => { - const startTime = Date.now() - 1; + const [report, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { doc: { internalId: 'V', xmlVersion: { time: '1', sha256: 'imahash' } } } + ); - form.validate.resolves(true); - const content = loadXML('extra-docs'); - form.getDataStr.returns(content); - - return service - .completeNewReport('V', form, { doc: { } }, { _id: '123', phone: '555' }) - .then(actual => { - const endTime = Date.now() + 1; - - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(3); - - const actualReport = actual[0]; - expect(actualReport._id).to.match(/(\w+-)\w+/); - expect(actualReport.fields.name).to.equal('Sally'); - expect(actualReport.fields.lmp).to.equal('10'); - expect(actualReport.fields.secret_code_name).to.equal('S4L'); - expect(actualReport.form).to.equal('V'); - expect(actualReport.type).to.equal('data_record'); - expect(actualReport.content_type).to.equal('xml'); - expect(actualReport.contact._id).to.equal('123'); - expect(actualReport.from).to.equal('555'); - expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'doc1', 'doc2']); - - expect(actualReport.fields.doc1).to.deep.equal({ - some_property_1: 'some_value_1', - type: 'thing_1', - }); - expect(actualReport.fields.doc2).to.deep.equal({ - some_property_2: 'some_value_2', - type: 'thing_2', - }); - - const actualThing1 = actual[1]; - expect(actualThing1._id).to.match(/(\w+-)\w+/); - expect(actualThing1.reported_date).to.be.within(startTime, endTime); - expect(actualThing1.some_property_1).to.equal('some_value_1'); - - const actualThing2 = actual[2]; - expect(actualThing2._id).to.match(/(\w+-)\w+/); - expect(actualThing2.reported_date).to.be.within(startTime, endTime); - expect(actualThing2.some_property_2).to.equal('some_value_2'); - - expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); - }); + expect(form.validate.callCount).to.equal(1); + expect(form.getDataStr.callCount).to.equal(1); + expect(additional).to.be.empty; + expect(report).excluding(['_id', 'reported_date']).to.deep.equal({ + contact: { _id: '123' }, + content_type: 'xml', + fields: { + lmp: '10', + name: 'Sally' + }, + form: 'V', + form_version: { + sha256: 'imahash', + time: '1' + }, + from: '555', + hidden_fields: [], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(report._id).to.not.be.empty; + expect(report.reported_date).to.be.a('number'); + expect(dispatchEventStub).to.have.been.calledOnceWithExactly(events.BeforeSave()); }); - it('creates extra docs with references', () => { + it('removes the legacy content field and attachment', async () => { form.validate.resolves(true); - const content = loadXML('extra-docs-with-references'); - form.getDataStr.returns(content); - - return service - .completeNewReport('V', form, { doc: { } }, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(3); - const reportId = actual[0]._id; - const doc1_id = actual[1]._id; - const doc2_id = actual[2]._id; - - const actualReport = actual[0]; - - expect(actualReport._id).to.match(/(\w+-)\w+/); - expect(actualReport.fields.name).to.equal('Sally'); - expect(actualReport.fields.lmp).to.equal('10'); - expect(actualReport.fields.secret_code_name).to.equal('S4L'); - expect(actualReport.fields.my_self_0).to.equal(reportId); - expect(actualReport.fields.my_child_01).to.equal(doc1_id); - expect(actualReport.fields.my_child_02).to.equal(doc2_id); - expect(actualReport.form).to.equal('V'); - expect(actualReport.type).to.equal('data_record'); - expect(actualReport.content_type).to.equal('xml'); - expect(actualReport.contact._id).to.equal('123'); - expect(actualReport.from).to.equal('555'); - expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'doc1', 'doc2']); - - expect(actualReport.fields.doc1).to.deep.equal({ - type: 'thing_1', - some_property_1: 'some_value_1', - my_self_1: doc1_id, - my_parent_1: reportId, - my_sibling_1: doc2_id - }); - expect(actualReport.fields.doc2).to.deep.equal({ - type: 'thing_2', - some_property_2: 'some_value_2', - my_self_2: doc2_id, - my_parent_2: reportId, - my_sibling_2: doc1_id - }); - - const actualThing1 = actual[1]; - expect(actualThing1._id).to.match(/(\w+-)\w+/); - expect(actualThing1.some_property_1).to.equal('some_value_1'); - expect(actualThing1.my_self_1).to.equal(doc1_id); - expect(actualThing1.my_parent_1).to.equal(reportId); - expect(actualThing1.my_sibling_1).to.equal(doc2_id); - - const actualThing2 = actual[2]; - expect(actualThing2._id).to.match(/(\w+-)\w+/); - expect(actualThing2.some_property_2).to.equal('some_value_2'); - expect(actualThing2.my_self_2).to.equal(doc2_id); - expect(actualThing2.my_parent_2).to.equal(reportId); - expect(actualThing2.my_sibling_2).to.equal(doc1_id); - - expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); - }); - }); + form.getDataStr.returns(''); - it('creates extra docs with repeats', () => { - form.validate.resolves(true); - const content = loadXML('extra-docs-with-repeat'); - form.getDataStr.returns(content); - - return service - .completeNewReport('V', form, { doc: { } }, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - const reportId = actual[0]._id; - - const actualReport = actual[0]; - - expect(actualReport._id).to.match(/(\w+-)\w+/); - expect(actualReport.fields.name).to.equal('Sally'); - expect(actualReport.fields.lmp).to.equal('10'); - expect(actualReport.fields.secret_code_name).to.equal('S4L'); - expect(actualReport.form).to.equal('V'); - expect(actualReport.type).to.equal('data_record'); - expect(actualReport.content_type).to.equal('xml'); - expect(actualReport.contact._id).to.equal('123'); - expect(actualReport.from).to.equal('555'); - expect(actualReport.hidden_fields).to.have.members(['secret_code_name', 'repeat_doc']); - - for (let i = 1; i <= 3; ++i) { - const repeatDocN = actual[i]; - expect(repeatDocN._id).to.match(/(\w+-)\w+/); - expect(repeatDocN.my_parent).to.equal(reportId); - expect(repeatDocN.some_property).to.equal('some_value_' + i); - } + const [report] = await saveReport( + { + _id: 'existing-report', + [REPORT_ATTACHMENT_NAME]: 'xml', + contact: { _id: '123', phone: '555' }, + _attachments: { [REPORT_ATTACHMENT_NAME]: { content_type: 'application/octet-stream', data: 'legacy' } }, + }, + { doc: { internalId: 'V' } } + ); - expect(_.uniq(_.map(actual, '_id')).length).to.equal(4); - }); + expect(report._id).to.equal('existing-report'); + expect(report[REPORT_ATTACHMENT_NAME]).to.be.undefined; + // content was the only attachment, so _attachments is dropped entirely + expect(report._attachments).to.be.undefined; }); - it('db-doc-ref with repeats', () => { + it('records hidden fields', async () => { form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-repeat'); - form.getDataStr.returns(content); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } - }; + form.getDataStr.returns(loadXML('hidden-field')); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - const doc = actual[0]; - - expect(doc).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.secret_code_name': 'S4L', - 'fields.repeat_section[0].extra': 'data1', - 'fields.repeat_section[0].repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[1].extra': 'data2', - 'fields.repeat_section[1].repeat_doc_ref': actual[2]._id, - 'fields.repeat_section[2].extra': 'data3', - 'fields.repeat_section[2].repeat_doc_ref': actual[3]._id, - }); - }); + const [report] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { doc: { internalId: 'V' } } + ); + + expect(report.fields.secret_code_name).to.equal('S4L'); + expect(report.hidden_fields).to.deep.equal(['secret_code_name']); }); - it('db-doc-ref with deep repeats', () => { + it('creates db-doc sub-docs and lists them as hidden fields', async () => { form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-deep-repeat'); - form.getDataStr.returns(content); + form.getDataStr.returns(loadXML('extra-docs')); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } - }; + const [report, thing1, thing2, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { doc: { internalId: 'V' } } + ); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - const doc = actual[0]; - - expect(doc).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.secret_code_name': 'S4L', - 'fields.repeat_section[0].extra': 'data1', - 'fields.repeat_section[0].some.deep.structure.repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[1].extra': 'data2', - 'fields.repeat_section[1].some.deep.structure.repeat_doc_ref': actual[2]._id, - 'fields.repeat_section[2].extra': 'data3', - 'fields.repeat_section[2].some.deep.structure.repeat_doc_ref': actual[3]._id, - }); - }); + expect(additional).to.be.empty; + const doc1 = { some_property_1: 'some_value_1', type: 'thing_1' }; + const doc2 = { some_property_2: 'some_value_2', type: 'thing_2' }; + expect(report).excluding(['_id', 'reported_date']).to.deep.equal({ + contact: { _id: '123' }, + content_type: 'xml', + fields: { + doc1, + doc2, + lmp: '10', + name: 'Sally', + secret_code_name: 'S4L' + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'doc1', 'doc2'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(report._id).to.not.be.empty; + expect(report.reported_date).to.be.a('number'); + + expect(thing1).excluding(['_id', 'reported_date']).to.deep.equal({ ...doc1, form_version: undefined }); + expect(thing1._id).to.not.be.empty; + expect(thing1.reported_date).to.be.a('number'); + expect(thing2).excluding(['_id', 'reported_date']).to.deep.equal({ ...doc2, form_version: undefined }); + expect(thing2._id).to.not.be.empty; + expect(thing2.reported_date).to.be.a('number'); }); - it('db-doc-ref with deep repeats and non-db-doc repeats', () => { + it('populates db-doc-ref elements with the referenced doc id', async () => { form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-deep-repeats-extra-repeats'); - form.getDataStr.returns(content); + form.getDataStr.returns(loadXML('extra-docs-with-references')); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } + const [report, thing1, thing2, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const doc1 = { + my_parent_1: report._id, + my_self_1: thing1._id, + my_sibling_1: thing2._id, + some_property_1: 'some_value_1', + type: 'thing_1' + }; + const doc2 = { + my_parent_2: report._id, + my_self_2: thing2._id, + my_sibling_2: thing1._id, + some_property_2: 'some_value_2', + type: 'thing_2' }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + doc1, + doc2, + my_child_01: thing1._id, + my_child_02: thing2._id, + my_self_0: report._id, + lmp: '10', + name: 'Sally', + secret_code_name: 'S4L' + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'doc1', 'doc2'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(report._id).to.not.be.empty; + expect(report.reported_date).to.be.a('number'); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - const doc = actual[0]; - - expect(doc).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.secret_code_name': 'S4L', - 'fields.repeat_section[0].extra': 'data1', - 'fields.repeat_section[0].some.deep.structure.repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[1].extra': 'data2', - 'fields.repeat_section[1].some.deep.structure.repeat_doc_ref': actual[2]._id, - 'fields.repeat_section[2].extra': 'data3', - 'fields.repeat_section[2].some.deep.structure.repeat_doc_ref': actual[3]._id, - }); - }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ + ...doc1, _id: report.fields.my_child_01, form_version: undefined + }); + expect(thing1._id).to.not.be.empty; + expect(thing1.reported_date).to.be.a('number'); + expect(thing2).excluding(['reported_date']).to.deep.equal({ + ...doc2, _id: report.fields.my_child_02, form_version: undefined + }); + expect(thing2._id).to.not.be.empty; + expect(thing2.reported_date).to.be.a('number'); }); - it('db-doc-ref with repeats and local references', () => { + it('populates db-doc-ref elements inside repeats', async () => { form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-repeats-with-local-references'); - form.getDataStr.returns(content); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } - }; + form.getDataStr.returns(loadXML('db-doc-ref-in-repeat')); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - const doc = actual[0]; - - expect(doc).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.secret_code_name': 'S4L', - 'fields.repeat_section[0].extra': 'data1', - 'fields.repeat_section[0].repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[1].extra': 'data2', - 'fields.repeat_section[1].repeat_doc_ref': actual[2]._id, - 'fields.repeat_section[2].extra': 'data3', - 'fields.repeat_section[2].repeat_doc_ref': actual[3]._id, - }); - }); - }); + const [report, thing1, thing2, thing3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: '', doc: { internalId: 'V' } } + ); - it('db-doc-ref with deep repeats and local references', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-deep-repeats-with-local-references'); - form.getDataStr.returns(content); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } + expect(additional).to.be.empty; + const doc1 = { + my_parent: report._id, + some_property: 'some_value_1', + type: 'repeater', }; + const doc2 = { + my_parent: report._id, + some_property: 'some_value_2', + type: 'repeater', + }; + const doc3 = { + my_parent: report._id, + some_property: 'some_value_3', + type: 'repeater', + }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + repeat_section: [ + { extra: 'data1', repeat_doc: doc1, repeat_doc_ref: thing1._id }, + { extra: 'data2', repeat_doc: doc2, repeat_doc_ref: thing2._id }, + { extra: 'data3', repeat_doc: doc3, repeat_doc_ref: thing3._id } + ], + lmp: '10', + name: 'Sally', + secret_code_name: 'S4L' + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section.repeat_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(report._id).to.not.be.empty; + expect(report.reported_date).to.be.a('number'); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - const doc = actual[0]; - - expect(doc).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.secret_code_name': 'S4L', - 'fields.repeat_section[0].extra': 'data1', - 'fields.repeat_section[0].some.deep.structure.repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[1].extra': 'data2', - 'fields.repeat_section[1].some.deep.structure.repeat_doc_ref': actual[2]._id, - 'fields.repeat_section[2].extra': 'data3', - 'fields.repeat_section[2].some.deep.structure.repeat_doc_ref': actual[3]._id, - }); - }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ + ...doc1, _id: report.fields.repeat_section[0].repeat_doc_ref, form_version: undefined + }); + expect(thing1._id).to.not.be.empty; + expect(thing1.reported_date).to.be.a('number'); + expect(thing2).excluding(['reported_date']).to.deep.equal({ + ...doc2, _id: report.fields.repeat_section[1].repeat_doc_ref, form_version: undefined + }); + expect(thing2._id).to.not.be.empty; + expect(thing2.reported_date).to.be.a('number'); + expect(thing3).excluding(['reported_date']).to.deep.equal({ + ...doc3, _id: report.fields.repeat_section[2].repeat_doc_ref, form_version: undefined + }); + expect(thing3._id).to.not.be.empty; + expect(thing3.reported_date).to.be.a('number'); }); - it('db-doc-ref with repeats with refs outside of repeat', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-outside-of-repeat'); - form.getDataStr.returns(content); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } - }; + describe('attachments', () => { + let getCurrentFiles; - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(2); - const doc = actual[0]; - - expect(doc).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.secret_code_name': 'S4L', - 'fields.repeat_section[0].extra': 'data1', - 'fields.repeat_section[0].repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[1].extra': 'data2', - 'fields.repeat_section[1].repeat_doc_ref': actual[1]._id, - 'fields.repeat_section[2].extra': 'data3', - 'fields.repeat_section[2].repeat_doc_ref': actual[1]._id, - }); - }); - }); + beforeEach(() => { + getCurrentFiles = FileManager.getCurrentFiles as sinon.SinonStub; + }); - it('db-doc-ref with repeats with db-doc as repeat', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-same-as-repeat'); - form.getDataStr.returns(content); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } - }; + it('builds file attachments from the current files', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('file-field')); + const file0 = { name: 'my_image', type: 'image' }; + const file1 = { name: 'my_file', type: 'file' }; + getCurrentFiles.returns([file0, file1]); + + const [report] = await saveReport( + { contact: { _id: 'my-user', phone: '8989' } }, + { doc: { internalId: 'my-form' } } + ); + + const imageAttachment = report._attachments['user-file-my_image']; + expect(imageAttachment.content_type).to.equal('image'); + expect(imageAttachment.data).to.be.an.instanceof(Blob); + const fileAttachment = report._attachments['user-file-my_file']; + expect(fileAttachment.content_type).to.equal('file'); + expect(fileAttachment.data).to.be.an.instanceof(Blob); + }); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - - expect(actual[0]).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - 'fields.repeat_doc_ref': actual[1]._id, // this ref is outside any repeat - }); - expect(actual[1]).to.deep.include({ - extra: 'data1', - type: 'repeater', - some_property: 'some_value_1', - my_parent: actual[0]._id, - repeat_doc_ref: actual[1]._id, - }); - expect(actual[2]).to.deep.include({ - extra: 'data2', - type: 'repeater', - some_property: 'some_value_2', - my_parent: actual[0]._id, - repeat_doc_ref: actual[2]._id, - }); - expect(actual[3]).to.deep.nested.include({ - extra: 'data3', - type: 'repeater', - some_property: 'some_value_3', - my_parent: actual[0]._id, - 'child.repeat_doc_ref': actual[3]._id, - }); - }); - }); + it('builds binary attachments and clears the binary field value', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('binary-field')); - it('db-doc-ref with repeats with invalid ref', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-broken-ref'); - form.getDataStr.returns(content); - const formDoc = { - xml: ` - - - - `, - doc: { _id: 'abc' } - }; + const [report] = await saveReport( + { contact: { _id: 'my-user', phone: '8989' } }, + { doc: { internalId: 'my-form' } } + ); - return service - .completeNewReport('V', form, formDoc, { _id: '123', phone: '555' }) - .then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - - expect(actual.length).to.equal(4); - - expect(actual[0]).to.deep.nested.include({ - form: 'V', - 'fields.name': 'Sally', - 'fields.lmp': '10', - }); - expect(actual[1]).to.deep.include({ - extra: 'data1', - type: 'repeater', - some_property: 'some_value_1', - my_parent: actual[0]._id, - repeat_doc_ref: 'value1', - }); - expect(actual[2]).to.deep.include({ - extra: 'data2', - type: 'repeater', - some_property: 'some_value_2', - my_parent: actual[0]._id, - repeat_doc_ref: 'value2', - }); - expect(actual[3]).to.deep.include({ - extra: 'data3', - type: 'repeater', - some_property: 'some_value_3', - my_parent: actual[0]._id, - repeat_doc_ref: 'value3', - }); + expect(report.fields).to.deep.equal({ + name: 'Mary', + age: '10', + gender: 'f', + my_file: '', + }); + expect(report._attachments['user-file/my-form/my_file']).to.deep.equal({ + data: 'some image data', + content_type: 'image/png', }); + }); + + it('retains custom attachments and referenced file attachments, dropping unreferenced ones', async () => { + form.validate.resolves(true); + // The field references the "referenced.png" file attachment; nothing references "orphan.png". + form.getDataStr.returns('referenced.png'); + + const [report] = await saveReport( + { + contact: { _id: '123', phone: '555' }, + _attachments: { + 'some-custom-attachment': { content_type: 'text/plain', data: 'c' }, + 'user-file-referenced.png': { content_type: 'image/png', data: 'a' }, + 'user-file-orphan.png': { content_type: 'image/png', data: 'b' }, + }, + }, + { doc: { internalId: 'my-form' } } + ); + + // Custom (non user-file) attachments are kept + expect(report._attachments['some-custom-attachment']).to.deep.equal({ content_type: 'text/plain', data: 'c' }); + // user-file attachments still referenced by a field are kept + expect(report._attachments['user-file-referenced.png']).to.deep.equal({ content_type: 'image/png', data: 'a' }); + // user-file attachments no longer referenced by any field are dropped + expect(report._attachments['user-file-orphan.png']).to.be.undefined; + }); }); }); - describe('completeExistingReport', () => { + describe('saveContact', () => { + let getContact; + beforeEach(() => { + getContact = sinon.stub(); + chtDatasourceService.bind.returns(getContact); service = TestBed.inject(EnketoService); + sinon.stub(FileManager, 'getCurrentFiles').returns([]); + form.validate.resolves(true); }); - it('rejects on invalid form', () => { - const inputRelevant = { dataset: { relevant: 'true' } }; - const inputNonRelevant = { dataset: { relevant: 'false' } }; - const inputNoDataset = {}; - const toArray = sinon.stub().returns([inputRelevant, inputNoDataset, inputNonRelevant]); - // @ts-ignore - sinon.stub($.fn, 'find').returns({ toArray }); - form.validate.resolves(false); - form.relevant = { update: sinon.stub() }; - const dispatchEventStub = sinon.stub(form.view.html, 'dispatchEvent'); - return service - .completeExistingReport(form, { doc: { } }, 'docId') - .then(() => expect.fail('expected to reject')) - .catch(actual => { - expect(actual.message).to.equal('Form is invalid'); - expect(form.validate.callCount).to.equal(1); - expect(inputRelevant.dataset.relevant).to.equal('true'); - expect(inputNonRelevant.dataset.relevant).to.equal('false'); - // @ts-ignore - expect(inputNoDataset.dataset).to.be.undefined; - expect(dispatchEventStub).to.not.have.been.called; - }); - }); + const saveContact = (defaultData, doc = { internalId: 'contact-form', xmlVersion: '1' }) => { + const config = buildFormConfig({ type: 'contact', doc }); + return service.saveContact({ config, form }, defaultData); + }; - it('updates report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); - getReport.resolves({ - _id: '6', - _rev: '1-abc', - form: 'V', - fields: { name: 'Silly' }, - content: 'Silly', - content_type: 'xml', - type: DOC_TYPES.DATA_RECORD, - reported_date: 500, - }); + it('rejects on invalid form', async () => { const dispatchEventStub = sinon.stub(form.view.html, 'dispatchEvent'); + form.validate.resolves(false); + form.getDataStr.returns('A Clinic'); + const config = buildFormConfig({ type: 'contact', doc: { internalId: 'contact-form' } }); - return service - .completeExistingReport(form, { doc: { } }, '6') - .then(actual => { - actual = actual[0]; - - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(chtDatasourceService.bind.calledOnceWithExactly(Report.v1.get)).to.be.true; - expect(getReport.calledOnceWithExactly(Qualifier.byUuid('6'))).to.be.true; - expect(actual._id).to.equal('6'); - expect(actual._rev).to.equal('1-abc'); - expect(actual.fields.name).to.equal('Sally'); - expect(actual.fields.lmp).to.equal('10'); - expect(actual.form).to.equal('V'); - expect(actual.type).to.equal('data_record'); - expect(actual.reported_date).to.equal(500); - expect(actual.content_type).to.equal('xml'); - expect(AddAttachment.callCount).to.equal(0); - expect(removeAttachment.callCount).to.equal(1); - expect(removeAttachment.args[0]).excludingEvery('_rev').to.deep.equal([actual, 'content']); - expect(dispatchEventStub).to.have.been.calledOnceWithExactly(events.BeforeSave()); - }); + await expect(service.saveContact({ config, form }, { type: 'clinic' })).to.be.rejectedWith(FormValidationError); + expect(form.validate.callCount).to.equal(1); + expect(dispatchEventStub).to.not.have.been.called; }); - }); - - describe('Saving attachments', () => { - let getCurrentFiles; - beforeEach(() => { - service = TestBed.inject(EnketoService); - getCurrentFiles = sinon - .stub(FileManager, 'getCurrentFiles') - .returns([]); + it('builds the contact doc from the group named after the contact type', async () => { + const dispatchEventStub = sinon.stub(form.view.html, 'dispatchEvent'); + const xml = ` + + + Not Clinic + + + New Clinic + + `; + + form.getDataStr.returns(xml); + const { docId, preparedDocs } = await saveContact({ contact_type: 'clinic', type: 'contact' }); + + expect(preparedDocs).excluding(['reported_date']).to.deep.equal([{ + _id: docId, + name: 'New Clinic', + type: 'contact', + form_version: '1', + contact_type: 'clinic', + contact: undefined, + parent: undefined, + _attachments: undefined, + }]); + expect(preparedDocs[0]._id).to.not.be.empty; + expect(preparedDocs[0].reported_date).to.be.a('number'); + expect(dispatchEventStub).to.have.been.calledOnceWithExactly(events.BeforeSave()); }); - it('should save attachments', async () => { - form.validate.resolves(true); - const content = loadXML('file-field'); - form.getDataStr.returns(content); - dbGetAttachment.resolves(''); - const file0 = { name: 'my_image', type: 'image' }; - const file1 = { name: 'my_file', type: 'file' }; - getCurrentFiles.returns([file0, file1]); - - await service.completeNewReport( - 'my-form', - form, - { doc: { } }, - { _id: 'my-user', phone: '8989' } - ); + it('throws when the group named after the contact type is missing', async () => { + const xml = 'A Person'; + form.getDataStr.returns(xml); - expect(AddAttachment.calledTwice).to.be.true; - expect(AddAttachment.args[0][1]).to.equal(`user-file-${file0.name}`); - expect(AddAttachment.args[0][2]).to.deep.equal(file0); - expect(AddAttachment.args[0][3]).to.equal(file0.type); - expect(AddAttachment.args[1][1]).to.equal(`user-file-${file1.name}`); - expect(AddAttachment.args[1][2]).to.deep.equal(file1); - expect(AddAttachment.args[1][3]).to.equal(file1.type); + await expect(saveContact({ type: 'clinic' })).to.be.rejectedWith( + 'Failed to save contact form because the data for the contact is not contained in the clinic group.' + ); }); - it('should remove binary data from content', async () => { - form.validate.resolves(true); - const content = loadXML('binary-field'); + it('creates an inline sibling doc for a parent field set to NEW', async () => { + const xml = ` + + + New Clinic + NEW + + + New Parent Place + district_hospital + + `; + form.getDataStr.returns(xml); + + const { docId, preparedDocs: [clinic, districtHospital, ...additional] } = await saveContact({ type: 'clinic' }); + + expect(additional).to.be.empty; + expect(clinic).excluding(['reported_date']).to.deep.equal({ + _id: docId, + name: 'New Clinic', + type: 'clinic', + form_version: '1', + contact_type: undefined, + contact: undefined, + parent: { _id: districtHospital._id }, + _attachments: undefined, + }); + expect(clinic._id).to.not.be.empty; + expect(clinic.reported_date).to.be.a('number'); + expect(districtHospital).excluding(['reported_date']).to.deep.equal({ + _id: clinic.parent._id, + name: 'New Parent Place', + type: 'district_hospital', + form_version: '1', + contact: undefined, + parent: undefined, + }); + expect(districtHospital._id).to.not.be.empty; + expect(districtHospital.reported_date).to.be.a('number'); + }); - form.getDataStr.returns(content); - dbGetAttachment.resolves(''); + it('preserves the _id and reported_date when editing an existing contact', async () => { + form.getDataStr.returns('Edited Clinic'); - const [actual] = await service.completeNewReport( - 'my-form', - form, - { doc: { } }, - { _id: 'my-user', phone: '8989' } - ); - expect(actual.fields).to.deep.equal({ - name: 'Mary', - age: '10', - gender: 'f', - my_file: '', + const { docId, preparedDocs } = await saveContact({ + _id: 'existing-clinic', + reported_date: 1234, + type: 'clinic', + form_version: '0', }); - expect(AddAttachment.callCount).to.equal(1); - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal('some image data'); - expect(AddAttachment.args[0][3]).to.equal('image/png'); + expect(docId).to.equal('existing-clinic'); + expect(preparedDocs).to.deep.equal([{ + _id: 'existing-clinic', + reported_date: 1234, + name: 'Edited Clinic', + type: 'clinic', + form_version: '1', + contact_type: undefined, + contact: undefined, + parent: undefined, + _attachments: undefined, + }]); }); - }); - describe('multimedia', () => { - let setNavigationStub; - let pauseStubs; - let form; - let $form; - let $nextBtn; - let $prevBtn; - let originalJQueryFind; - - before(() => { - $nextBtn = $(''); - $prevBtn = $(''); - originalJQueryFind = $.fn.find; - setNavigationStub = sinon - .stub(EnketoService.prototype, 'setNavigation') - .callThrough(); - - form = { - calc: { update: sinon.stub() }, - output: { update: sinon.stub() }, - resetView: sinon.stub(), - pages: { - _next: sinon.stub(), - _getCurrentIndex: sinon.stub() - } - }; + it('creates an inline sibling doc for a contact field set to NEW', async () => { + const xml = ` + + + New Clinic + NEW + + + New CHW + person + PARENT + + `; + form.getDataStr.returns(xml); + + const { docId, preparedDocs: [clinic, chw, ...additional] } = await saveContact({ type: 'clinic' }); + + expect(additional).to.be.empty; + expect(clinic).excluding(['reported_date']).to.deep.equal({ + _id: docId, + name: 'New Clinic', + type: 'clinic', + form_version: '1', + contact_type: undefined, + parent: undefined, + contact: { _id: chw._id, parent: { _id: docId } }, + _attachments: undefined, + }); + expect(chw).excluding(['reported_date', '_id']).to.deep.equal({ + name: 'New CHW', + type: 'person', + form_version: '1', + parent: { _id: docId }, + contact: undefined, + }); + expect(getContact.callCount).to.equal(0); }); - beforeEach(() => { - service = TestBed.inject(EnketoService); + it('creates a child doc from a repeat/child element', async () => { + const xml = ` + + New Clinic + + + Child One + person + + + `; + form.getDataStr.returns(xml); + + const { docId, preparedDocs: [clinic, child, ...additional] } = await saveContact({ type: 'clinic' }); + + expect(additional).to.be.empty; + expect(clinic._id).to.equal(docId); + expect(child._id).to.not.equal(clinic._id); + expect(child).excluding(['reported_date', '_id']).to.deep.equal({ + name: 'Child One', + type: 'person', + form_version: '1', + parent: { _id: clinic._id }, + contact: undefined, + }); + }); - $form = $(`
`); - $form - .append($nextBtn) - .append($prevBtn); + it('does not write parent/contact siblings when the contact has no parent/contact value set', async () => { + const xml = ` + + New Clinic + Unused Parentdistrict_hospital + Unused Contactperson + `; + form.getDataStr.returns(xml); + + const { preparedDocs: [clinic, ...additional] } = await saveContact({ type: 'clinic' }); + + expect(additional).to.be.empty; + expect(clinic.parent).to.be.undefined; + expect(clinic.contact).to.be.undefined; + expect(getContact.callCount).to.equal(0); + }); - pauseStubs = {}; - sinon - .stub($.fn, 'find') - .callsFake(selector => { - const result = originalJQueryFind.call($form, selector); + it('writes all parent, contact and child siblings for a new contact', async () => { + const xml = ` + + + New Clinic + <_id>NEW + <_id>NEW + + New Parentdistrict_hospital + New ContactPARENT + + Child Aperson + Child Bperson + + `; + form.getDataStr.returns(xml); + + const { + docId, + preparedDocs: [clinic, parent, contact, childA, childB, ...additional], + } = await saveContact({ type: 'clinic' }); + + expect(additional).to.be.empty; + expect(clinic).excluding(['reported_date']).to.deep.equal({ + _id: docId, + name: 'New Clinic', + type: 'clinic', + parent: { _id: parent._id }, + contact: { _id: contact._id, parent: { _id: docId, parent: { _id: parent._id } } }, + form_version: '1', + contact_type: undefined, + _attachments: undefined, + }); + expect(parent).excluding(['reported_date', '_id']).to.deep.equal({ + name: 'New Parent', type: 'district_hospital', form_version: '1', parent: undefined, contact: undefined, + }); + expect(contact).excluding(['reported_date', '_id']).to.deep.equal({ + name: 'New Contact', + type: 'person', + form_version: '1', + parent: { _id: docId, parent: { _id: parent._id } }, + contact: undefined, + }); + expect(childA).excluding(['reported_date', '_id']).to.deep.equal({ + name: 'Child A', + type: 'person', + form_version: '1', + parent: { _id: clinic._id, parent: { _id: parent._id } }, + contact: undefined, + }); + expect(childB).excluding(['reported_date', '_id']).to.deep.equal({ + name: 'Child B', + type: 'person', + form_version: '1', + parent: { _id: clinic._id, parent: { _id: parent._id } }, + contact: undefined, + }); + expect(getContact.callCount).to.equal(0); + }); - result.each((idx, element) => { - if (element.pause) { - pauseStubs[element.id] = sinon.stub(element, 'pause'); - } - }); + it('fetches parent and contact from the datasource when ids are set with no sibling groups', async () => { + const xml = ` + + + New Clinic + parent-id + contact-id + + `; + form.getDataStr.returns(xml); + getContact.onCall(0).resolves({ _id: 'parent-id', type: 'district_hospital', parent: { _id: 'grandparent-id' } }); + getContact.onCall(1).resolves({ _id: 'contact-id', type: 'person', parent: { _id: 'grandparent-id' } }); + + const { docId, preparedDocs: [clinic, ...additional] } = await saveContact({ type: 'clinic' }); + + expect(additional).to.be.empty; + expect(clinic).excluding(['reported_date']).to.deep.equal({ + _id: docId, + name: 'New Clinic', + type: 'clinic', + parent: { _id: 'parent-id', parent: { _id: 'grandparent-id' } }, + contact: { _id: 'contact-id', parent: { _id: 'grandparent-id' } }, + form_version: '1', + contact_type: undefined, + _attachments: undefined, + }); + expect(getContact.args).to.deep.equal([[Qualifier.byUuid('parent-id')], [Qualifier.byUuid('contact-id')]]); + }); - return result; - }); + it('keeps the existing parent/contact lineage on edit when the values are unchanged', async () => { + const xml = ` + + + Edited Clinic + p1 + c1 + + `; + form.getDataStr.returns(xml); + + const { preparedDocs: [clinic, ...additional] } = await saveContact({ + _id: 'existing-clinic', + reported_date: 9, + type: 'clinic', + parent: { _id: 'p1', parent: { _id: 'gp1' } }, + contact: { _id: 'c1' }, + }); + + expect(additional).to.be.empty; + expect(clinic).to.deep.equal({ + _id: 'existing-clinic', + name: 'Edited Clinic', + reported_date: 9, + type: 'clinic', + parent: { _id: 'p1', parent: { _id: 'gp1' } }, + contact: { _id: 'c1' }, + form_version: '1', + contact_type: undefined, + _attachments: undefined, + }); + expect(getContact).to.not.have.been.called; }); - after(() => $.fn.find = originalJQueryFind); + describe('attachments', () => { + let getCurrentFiles; - xit('should pause the multimedia when going to the previous page', fakeAsync(() => { - $form.prepend(''); - setNavigationStub.call(service, form, $form); + beforeEach(() => getCurrentFiles = FileManager.getCurrentFiles as sinon.SinonStub); - $prevBtn.trigger('click.pagemode'); - flush(); + it('builds file attachments from the current files', async () => { + form.getDataStr.returns('Clinic'); + getCurrentFiles.returns([ + { name: 'my_image', type: 'image' }, + { name: 'my_file', type: 'file' }, + ]); - expect(pauseStubs.video).to.not.be.undefined; - expect(pauseStubs.video.calledOnce).to.be.true; - expect(pauseStubs.audio).to.not.be.undefined; - expect(pauseStubs.audio.calledOnce).to.be.true; - })); + const { preparedDocs: [clinic] } = await saveContact({ type: 'clinic' }); - xit('should pause the multimedia when going to the next page', fakeAsync(() => { - form.pages._next.resolves(true); - $form.prepend(''); - setNavigationStub.call(service, form, $form); + const imageAttachment = clinic._attachments['user-file-my_image']; + expect(imageAttachment.content_type).to.equal('image'); + expect(imageAttachment.data).to.be.an.instanceof(Blob); + const fileAttachment = clinic._attachments['user-file-my_file']; + expect(fileAttachment.content_type).to.equal('file'); + expect(fileAttachment.data).to.be.an.instanceof(Blob); + }); - $nextBtn.trigger('click.pagemode'); - flush(); + it('builds binary attachments and clears the binary field value', async () => { + form.getDataStr.returns( + 'Clinicsome image data' + ); - expect(pauseStubs.video).to.not.be.undefined; - expect(pauseStubs.video.calledOnce).to.be.true; - expect(pauseStubs.audio).to.not.be.undefined; - expect(pauseStubs.audio.calledOnce).to.be.true; - })); + const { preparedDocs: [clinic] } = await saveContact({ type: 'clinic' }); - xit('should not pause the multimedia when trying to go to the next page and form is invalid', fakeAsync(() => { - form.pages._next.resolves(false); - $form.prepend(''); - setNavigationStub.call(service, form, $form); + expect(clinic.my_file).to.equal(''); + expect(clinic._attachments['user-file/contact-form/clinic/my_file']).to.deep.equal({ + data: 'some image data', + content_type: 'image/png', + }); + }); - $nextBtn.trigger('click.pagemode'); - flush(); + it('retains custom attachments and referenced file attachments, dropping unreferenced ones', async () => { + // The field references "referenced.png"; nothing references "orphan.png". + form.getDataStr.returns('Clinicreferenced.png'); + + const { preparedDocs: [clinic] } = await saveContact({ + type: 'clinic', + _attachments: { + 'some-custom-attachment': { content_type: 'text/plain', data: 'c' }, + 'user-file-referenced.png': { content_type: 'image/png', data: 'a' }, + 'user-file-orphan.png': { content_type: 'image/png', data: 'b' }, + }, + }); - expect(pauseStubs.video).to.be.undefined; - expect(pauseStubs.audio).to.be.undefined; - })); + expect(clinic._attachments['some-custom-attachment']).to.deep.equal({ content_type: 'text/plain', data: 'c' }); + expect(clinic._attachments['user-file-referenced.png']) + .to.deep.equal({ content_type: 'image/png', data: 'a' }); + expect(clinic._attachments['user-file-orphan.png']).to.be.undefined; + }); + }); + }); + + describe('unload', () => { + beforeEach(() => { + service = TestBed.inject(EnketoService); + }); - xit('should not call pause function when there isnt video and audio in the form wrapper', fakeAsync(() => { - setNavigationStub.call(service, form, $form); + it('resets the view of the current form', async () => { + enketoInit.returns([]); + EnketoPrepopulationData.returns(''); + const formContext = { + selector: $('
'), + formConfig: buildFormConfig({ html: $('
my form
'), model: VISIT_MODEL }), + }; + const userSettings = { language: 'en' }; - $prevBtn.trigger('click.pagemode'); - $nextBtn.trigger('click.pagemode'); - flush(); + const enketoForm = await service.renderForm(formContext, userSettings); - expect(pauseStubs.video).to.be.undefined; - expect(pauseStubs.audio).to.be.undefined; - })); + service.unload(enketoForm.form); + expect(form.resetView.callCount).to.equal(1); + expect(service.getCurrentForm()).to.be.undefined; + }); + + it('does nothing when the given form is not the current form', () => { + service.unload({ resetView: sinon.stub() }); + expect(form.resetView.callCount).to.equal(0); + }); }); }); From e0e4e728fd8d97c2bc05242741ce3313d47f61c7 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 22:49:03 -0500 Subject: [PATCH 28/41] First round of changes to form-data unit tests --- .../karma/ts/services/form/form-data.spec.ts | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 webapp/tests/karma/ts/services/form/form-data.spec.ts diff --git a/webapp/tests/karma/ts/services/form/form-data.spec.ts b/webapp/tests/karma/ts/services/form/form-data.spec.ts new file mode 100644 index 00000000000..c9cd6eae192 --- /dev/null +++ b/webapp/tests/karma/ts/services/form/form-data.spec.ts @@ -0,0 +1,402 @@ +import { expect } from 'chai'; + +import { FormConfig } from '@mm-services/form/form-config'; +import { + EnektoContactFormData, + EnketoFormData, + EnketoReportFormData, +} from '@mm-services/form/form-data'; + +const parseXml = (xml: string): XMLDocument => new DOMParser().parseFromString(xml, 'text/xml'); + +const buildFormConfig = (repeatPaths: string[] = [], xmlVersion = '1.0'): FormConfig => { + const repeatXml = repeatPaths.map(path => ``).join(''); + const xml = `${repeatXml}`; + return new FormConfig({ xmlVersion }, 'report', xml, '', ''); +}; + +describe('form-data', () => { + describe('EnketoFormData', () => { + describe('deserialize', () => { + it('converts a leaf element to its text content', () => { + const doc = parseXml('Sally10'); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserialize(buildFormConfig()); + + expect(result).to.deep.equal({ name: 'Sally', age: '10' }); + }); + + it('converts a nested element to a nested object', () => { + const doc = parseXml(` + + Sally +
+ Springfield + + -47.15 + -126.72 + +
+
`); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserialize(buildFormConfig()); + + expect(result).to.deep.equal({ + name: 'Sally', + address: { + city: 'Springfield', + geo: { lat: '-47.15', long: '-126.72' }, + }, + }); + }); + + it('accumulates repeat paths into an array', () => { + const doc = parseXml(` + + parent + Daddy Bear + Baby Bear + Goldilocks + `); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserialize(buildFormConfig(['/data/child'])); + + expect(result).to.deep.equal({ + name: 'parent', + child: [ + { name: 'Daddy Bear' }, + { name: 'Baby Bear' }, + { name: 'Goldilocks' }, + ], + }); + }); + + it('accumulates nested repeats', () => { + const doc = parseXml(` + + parent + + Daddy Bear + ugali + chapati + + Baby Bearporridge + Goldilocksoatmeal + `); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserialize(buildFormConfig(['/data/child', '/data/child/foods'])); + + expect(result).to.deep.equal({ + name: 'parent', + child: [ + { name: 'Daddy Bear', foods: [{ type: 'ugali' }, { type: 'chapati' }] }, + { name: 'Baby Bear', foods: [{ type: 'porridge' }] }, + { name: 'Goldilocks', foods: [{ type: 'oatmeal' }] }, + ], + }); + }); + + it('creates a single-entry array when a repeat path occurs only once', () => { + const doc = parseXml('Only Child'); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserialize(buildFormConfig(['/data/child'])); + + expect(result).to.deep.equal({ child: [{ name: 'Only Child' }] }); + }); + + it('takes the last value for a duplicated non-repeat element', () => { + const doc = parseXml('123'); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserialize(buildFormConfig()); + + expect(result).to.deep.equal({ val: '3' }); + }); + }); + + describe('deserializeDoc', () => { + it('adds the _id and the form_version from the form config', () => { + const doc = parseXml('Sally'); + const formData = new EnketoFormData(doc.documentElement, 'the-id'); + + const result = formData.deserializeDoc(buildFormConfig([], '2020-01-01')); + + expect(result).to.deep.equal({ + _id: 'the-id', + form_version: '2020-01-01', + name: 'Sally', + }); + }); + }); + }); + + describe('EnektoContactFormData', () => { + it('throws when the group named after the contact type is missing', () => { + const doc = parseXml('A Clinic'); + + expect(() => new EnektoContactFormData(doc, 'the-id', 'person')) + .to.throw('Failed to save contact form because the data for the contact is not contained in the person group.'); + }); + + describe('deserializeDoc', () => { + it('lifts the contact data out of the type group and adds _id and form_version', () => { + const doc = parseXml(` + + + Denise + +123456789 + + `); + const contactData = new EnektoContactFormData(doc, 'the-id', 'person'); + + const result = contactData.deserializeDoc(buildFormConfig([], '3.5')); + + expect(result).to.deep.equal({ + _id: 'the-id', + form_version: '3.5', + name: 'Denise', + phone: '+123456789', + parent: undefined, + contact: undefined, + }); + }); + + it('lifts string parent/contact id values into { _id } objects', () => { + const doc = parseXml(` + + + <_id>catchment-id + A New Catchment Area + parent-abc + contact-xyz + + `); + const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + + const result = contactData.deserializeDoc(buildFormConfig()); + + expect(result).to.deep.equal({ + _id: 'catchment-id', + form_version: '1.0', + name: 'A New Catchment Area', + parent: { _id: 'parent-abc' }, + contact: { _id: 'contact-xyz' }, + }); + }); + + it('leaves already-nested parent/contact objects untouched', () => { + const doc = parseXml(` + + + A Clinic + <_id>parent-abcThe Parent + <_id>contact-xyzThe Contact + + `); + const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + + const result = contactData.deserializeDoc(buildFormConfig()); + + expect(result).to.deep.equal({ + _id: 'the-id', + form_version: '1.0', + name: 'A Clinic', + parent: { _id: 'parent-abc', name: 'The Parent' }, + contact: { _id: 'contact-xyz', name: 'The Contact' }, + }); + }); + }); + + describe('getChildData', () => { + it('returns child docs from repeat > child, using the _id element when present', () => { + const doc = parseXml(` + + Mum + + <_id>child-1Daddy Bear + Baby Bear + + `); + const contactData = new EnektoContactFormData(doc, 'the-id', 'person'); + + const [child1, child2, ...additional] = contactData.getChildData(); + + expect(additional).to.be.empty; + expect(child1.id).to.equal('child-1'); + expect(child1.deserialize(buildFormConfig())).to.deep.equal({ _id: 'child-1', name: 'Daddy Bear' }); + // No _id element present, so a uuid is generated. + expect(child2.id).to.match(/^[0-9a-f-]{36}$/); + expect(child2.deserialize(buildFormConfig())).to.deep.equal({ name: 'Baby Bear' }); + }); + + it('returns an empty array when there are no repeat > child elements', () => { + const doc = parseXml('Mum'); + const contactData = new EnektoContactFormData(doc, 'the-id', 'person'); + + expect(contactData.getChildData()).to.deep.equal([]); + }); + }); + + describe('getSiblingData', () => { + it('returns the sibling form data for the named top-level group', () => { + const doc = parseXml(` + + A Clinic + <_id>parent-1The Parent + The Contact + `); + const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + + const parent = contactData.getSiblingData('parent'); + expect(parent!.id).to.equal('parent-1'); + expect(parent!.deserialize(buildFormConfig())).to.deep.equal({ _id: 'parent-1', name: 'The Parent' }); + + const contact = contactData.getSiblingData('contact'); + expect(contact!.id).to.match(/^[0-9a-f-]{36}$/); + expect(contact!.deserialize(buildFormConfig())).to.deep.equal({ name: 'The Contact' }); + }); + + it('returns null when the sibling group is not present', () => { + const doc = parseXml('A Clinic'); + const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + + expect(contactData.getSiblingData('parent')).to.be.null; + }); + }); + }); + + describe('EnketoReportFormData', () => { + describe('getDbDocData', () => { + it('returns form data for elements tagged db-doc=true', () => { + const doc = parseXml(` + + The Report + <_id>doc-1data_record + Hellodata_record + `); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + const [dbDoc1, dbDoc2, ...additional] = reportData.getDbDocData(); + + expect(additional).to.be.empty; + expect(dbDoc1.id).to.equal('doc-1'); + expect(dbDoc1.deserialize(buildFormConfig())).to.deep.equal({ _id: 'doc-1', type: 'data_record' }); + expect(dbDoc2.id).to.match(/^[0-9a-f-]{36}$/); + expect(dbDoc2.deserialize(buildFormConfig())).to.deep.equal({ name: 'Hello', type: 'data_record' }); + }); + + it('returns form data for nested and repeated elements tagged db-doc=true', () => { + const doc = parseXml(` + + The Report + <_id>doc-1data_record + <_id>doc-2data_record + + <_id>doc-3 + data_record + <_id>doc-4data_record + + `); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + const [dbDoc1, dbDoc2, dbDoc3, dbDoc4, ...additional] = reportData.getDbDocData(); + + expect(additional).to.be.empty; + expect(dbDoc1.id).to.equal('doc-1'); + expect(dbDoc1.deserialize(buildFormConfig())).to.deep.equal({ _id: 'doc-1', type: 'data_record' }); + expect(dbDoc2.id).to.equal('doc-2'); + expect(dbDoc2.deserialize(buildFormConfig())).to.deep.equal({ _id: 'doc-2', type: 'data_record' }); + expect(dbDoc3.id).to.equal('doc-3'); + expect(dbDoc3.deserialize(buildFormConfig())).to.deep.equal({ + _id: 'doc-3', + type: 'data_record', + my_doc: { _id: 'doc-4', type: 'data_record' } + }); + expect(dbDoc4.id).to.equal('doc-4'); + expect(dbDoc4.deserialize(buildFormConfig())).to.deep.equal({ _id: 'doc-4', type: 'data_record' }); + }); + }); + + it('collects hidden elements (tag=hidden)', () => { + const doc = parseXml(` + + Sally + S4L + S5L + `); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + const hidden = reportData.hiddenElements.map(el => el.nodeName); + expect(hidden).to.deep.equal(['secret', 'another']); + }); + + it('collects db-doc-ref elements', () => { + const doc = parseXml(` + + Sally + something + <_id db-doc-ref="/data/name">doc-4data_record + `); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + const refs = reportData.dbDocRefElements.map(element => element.getAttribute('db-doc-ref')); + expect(refs).to.deep.equal(['/data/my_doc', '/data/name']); + }); + + describe('getNodeByXpath', () => { + it('resolves an absolute xpath', () => { + const doc = parseXml('The Report'); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + const node = reportData.getNodeByXpath(doc.documentElement, '/data/report/name'); + + expect(node!.textContent).to.equal('The Report'); + }); + + it('resolves a relative xpath against the context node', () => { + const doc = parseXml('The Report'); + const reportData = new EnketoReportFormData(doc, 'the-id'); + const reportNode = doc.getElementsByTagName('report')[0]; + + expect(reportData.getNodeByXpath(reportNode, 'name')!.textContent).to.equal('The Report'); + expect(reportData.getNodeByXpath(reportNode, './name')!.textContent).to.equal('The Report'); + }); + + it('returns null for an empty or missing xpath', () => { + const doc = parseXml('The Report'); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + expect(reportData.getNodeByXpath(doc.documentElement, null)).to.be.null; + expect(reportData.getNodeByXpath(doc.documentElement, ' ')).to.be.null; + }); + }); + + describe('findNodeWithTextContent', () => { + it('finds the first node with the given text content', () => { + const doc = parseXml(` + + Sally + hunter2 + hunter2 + `); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + const node = reportData.findNodeWithTextContent('hunter2'); + + expect(node!.nodeName).to.equal('secret'); + }); + + it('returns null when no node has the given text content', () => { + const doc = parseXml('Sally'); + const reportData = new EnketoReportFormData(doc, 'the-id'); + + expect(reportData.findNodeWithTextContent('nope')).to.be.null; + }); + }); + }); +}); From cd23e702dc25e3de633b904d5173eb7d35f3597e Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Fri, 17 Jul 2026 22:59:17 -0500 Subject: [PATCH 29/41] Lock in form-data --- .../karma/ts/services/form/form-data.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/webapp/tests/karma/ts/services/form/form-data.spec.ts b/webapp/tests/karma/ts/services/form/form-data.spec.ts index c9cd6eae192..8d3d59a048e 100644 --- a/webapp/tests/karma/ts/services/form/form-data.spec.ts +++ b/webapp/tests/karma/ts/services/form/form-data.spec.ts @@ -374,6 +374,25 @@ describe('form-data', () => { expect(reportData.getNodeByXpath(doc.documentElement, null)).to.be.null; expect(reportData.getNodeByXpath(doc.documentElement, ' ')).to.be.null; }); + + it('resolves an absolute xpath to a node in the same repeat entry as the context node', () => { + const repeatXml = ` + + firstfirst-value + secondsecond-value + thirdthird-value + `; + const doc = parseXml(repeatXml); + const reportData = new EnketoReportFormData(doc, 'the-id'); + // Context node is the nested inside the SECOND repeat entry. + const contextNode = doc.getElementsByTagName('repeat_section')[1].getElementsByTagName('name')[0]; + + const node = reportData.getNodeByXpath(contextNode, '/data/repeat_section/value'); + expect(node!.textContent).to.equal('second-value'); + + const relativeNode = reportData.getNodeByXpath(contextNode, '../value'); + expect(relativeNode!.textContent).to.equal('second-value'); + }); }); describe('findNodeWithTextContent', () => { From d09a4db7e7d8bcee619b1c6483801f7df5da9765 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 20 Jul 2026 10:38:39 -0500 Subject: [PATCH 30/41] Fix sonar --- .../ts/modules/tasks/tasks-content.component.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts b/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts index 95914fcbd03..48422cb130e 100644 --- a/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts @@ -312,8 +312,8 @@ describe('TasksContentComponent', () => { await compileComponent(); - expect(component.form).to.equal(undefined); - expect(component.loadingForm).to.equal(undefined); + expect(component.form).to.be.undefined; + expect(component.loadingForm).to.be.undefined; expect(render.callCount).to.equal(0); }); @@ -337,8 +337,8 @@ describe('TasksContentComponent', () => { await compileComponent(); - expect(component.form).to.equal(undefined); - expect(component.loadingForm).to.equal(undefined); + expect(component.form).to.be.undefined; + expect(component.loadingForm).to.be.undefined; expect(render.callCount).to.equal(0); }); From 6844aaf029f822c2ac5c32863f354dd917efb144 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 20 Jul 2026 10:59:33 -0500 Subject: [PATCH 31/41] Update cht-form to clear undefined properties --- .../cht-form/default/person-edit.wdio-spec.js | 12 ++---------- .../ts/modules/tasks/tasks-content.component.spec.ts | 2 +- webapp/web-components/cht-form/src/app.component.ts | 12 +++++++++++- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/integration/cht-form/default/person-edit.wdio-spec.js b/tests/integration/cht-form/default/person-edit.wdio-spec.js index 7bf960985d8..f0229147f5f 100644 --- a/tests/integration/cht-form/default/person-edit.wdio-spec.js +++ b/tests/integration/cht-form/default/person-edit.wdio-spec.js @@ -9,14 +9,6 @@ const excludeProps = [ 'dob_approx', 'reported_date', ]; -// These properties are set by default in the enketo service to undefined -const undefinedProps = { - _attachments: undefined, - contact: undefined, - contact_type: undefined, - form_version: undefined, - parent: undefined, -}; describe('cht-form web component - Edit Person Form', () => { @@ -66,7 +58,7 @@ describe('cht-form web component - Edit Person Form', () => { const [doc, ...additionalDocs] = await mockConfig.submitForm(); expect(additionalDocs).to.be.empty; - expect(doc).excludingEvery(excludeProps).to.deep.equal({ ...undefinedProps, ...initialPerson }); + expect(doc).excludingEvery(excludeProps).to.deep.equal(initialPerson); await mockConfig.cancelForm(); @@ -134,6 +126,6 @@ describe('cht-form web component - Edit Person Form', () => { const [updatedDoc, ...updatedAdditionalDocs] = await mockConfig.submitForm(); expect(updatedAdditionalDocs).to.be.empty; - expect(updatedDoc).excludingEvery(excludeProps).to.deep.equal({ ...undefinedProps, ...updatedPerson }); + expect(updatedDoc).excludingEvery(excludeProps).to.deep.equal(updatedPerson); }); }); diff --git a/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts b/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts index 48422cb130e..6ea5366400e 100644 --- a/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/tasks/tasks-content.component.spec.ts @@ -436,7 +436,7 @@ describe('TasksContentComponent', () => { expect((TasksActions.prototype.setSelectedTask).args[0]).to.deep.equal([null]); expect(formService.unload.callCount).to.equal(1); - expect(component.form).to.equal(undefined); + expect(component.form).to.be.undefined; expect(component.loadingForm).to.equal(false); expect(component.contentError).to.equal(false); expect((GlobalActions.prototype.clearNavigation).callCount).to.equal(1); diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index d7c5f18ee70..d5662c6db92 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -137,7 +137,7 @@ export class AppComponent { this.formContext.status.saving = true; try { - const submittedDocs = await this.getDocsFromForm(); + const submittedDocs = (await this.getDocsFromForm()).map(doc => this.cleanDoc(doc)); this.onSubmit.emit(submittedDocs); } catch (e) { console.error('Error submitting form data: ', e); @@ -147,6 +147,16 @@ export class AppComponent { } } + private cleanDoc(doc: Record) { + const attachments = doc._attachments; + // Pass through JSON to remove any dangling properties set to `undefined`. + const cleanDoc = JSON.parse(JSON.stringify(doc)); + if (attachments) { + cleanDoc._attachments = attachments; + } + return cleanDoc; + } + private async getDocsFromForm() { const { contactType } = this.formContext; if (contactType) { From f9ab731e6e045fcd2715a730ee41b7b991def45a Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 20 Jul 2026 12:32:53 -0500 Subject: [PATCH 32/41] Update cht-form unit tests --- .../tests/karma/app.component.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/webapp/web-components/cht-form/tests/karma/app.component.spec.ts b/webapp/web-components/cht-form/tests/karma/app.component.spec.ts index 42f1e434822..b5d858fb140 100644 --- a/webapp/web-components/cht-form/tests/karma/app.component.spec.ts +++ b/webapp/web-components/cht-form/tests/karma/app.component.spec.ts @@ -531,6 +531,41 @@ describe('AppComponent', () => { expect(actualSubmittedDocs).to.deep.equal(expectedDocs); })); + it('strips properties set to undefined and preserves attachments when emitting docs', fakeAsync(async () => { + const attachments = { 'user-file': { content_type: 'text/xml', data: 'not-json-serializable' } }; + const savedDocs = [ + { _id: 'doc1', keep: 'value', drop: undefined, nested: { keep: 'value', drop: undefined } }, + { _id: 'doc2', _attachments: attachments, drop: undefined }, + ]; + enketoService.saveReport.resolves(savedDocs); + const contact = { phone: '12345' }; + const content = { my: 'content', contact }; + + const component = getComponent(); + component.content = content; + component.formXml = FORM_XML; + component.formModel = FORM_MODEL; + component.formHtml = FORM_HTML; + tick(); + + let actualSubmittedDocs; + component.onSubmit.subscribe((submittedDocs) => { + actualSubmittedDocs = submittedDocs; + }); + + await component.submitForm(); + tick(); + + expect(enketoService.saveReport.callCount).to.equal(1); + // undefined properties removed (including nested), other values retained + expect(actualSubmittedDocs).to.deep.equal([ + { _id: 'doc1', keep: 'value', nested: { keep: 'value' } }, + { _id: 'doc2', _attachments: attachments }, + ]); + // attachments are preserved by reference (not run through the JSON round-trip) + expect(actualSubmittedDocs[1]._attachments).to.equal(attachments); + })); + it('submits contact form with default type', fakeAsync(async () => { const expectedDocs = [ { _id: 'doc1' }, From e5cdabc8a3f456f0d5d7d92b61ae157019c2e5c0 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 20 Jul 2026 13:50:08 -0500 Subject: [PATCH 33/41] Final cleanup --- webapp/src/ts/services/form.service.ts | 1 - webapp/src/ts/services/form/form-config.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 20ad986d85f..7d66c65ecbd 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -371,7 +371,6 @@ export class FormService { ? { type } : { type: 'contact', contact_type: type }; - // const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion); const defaultData = docId ? await this.getContact(Qualifier.byUuid(docId)) : typeFields; const docs = await this.enketoService.saveContact(enketoForm, defaultData!); diff --git a/webapp/src/ts/services/form/form-config.ts b/webapp/src/ts/services/form/form-config.ts index 8209b77a65d..ecbba614ede 100644 --- a/webapp/src/ts/services/form/form-config.ts +++ b/webapp/src/ts/services/form/form-config.ts @@ -1,6 +1,7 @@ export type FormType = 'contact' | 'report' | 'task' | 'training-card'; -// FYI, putting this in the xml-forms.service file causes that service (and dependencies) to leak into cht-form. +// Putting this in the xml-forms.service file causes that service (and dependencies) to leak into cht-form. +// This breaks the cht-form build. export class FormConfig { public readonly repeatPaths: string[]; From dd3c717f27ec406f0df93cd313dff7820c1a6620 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 12:33:45 -0500 Subject: [PATCH 34/41] Minor refactors from code review. --- .../training-cards-form.component.ts | 4 +- .../contacts/contacts-edit.component.ts | 70 +++++++++---------- .../contacts/contacts-report.component.ts | 3 +- .../modules/reports/reports-add.component.ts | 4 +- .../modules/tasks/tasks-content.component.ts | 4 +- webapp/src/ts/services/form.service.ts | 64 +++++++++-------- webapp/src/ts/services/form/form-config.ts | 10 ++- webapp/src/ts/services/xml-forms.service.ts | 2 +- .../cht-form/src/app.component.ts | 4 +- 9 files changed, 89 insertions(+), 76 deletions(-) diff --git a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts index b2b32ee72c7..82d699a034d 100644 --- a/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts +++ b/webapp/src/ts/components/training-cards-form/training-cards-form.component.ts @@ -13,7 +13,7 @@ import { TranslateFromService } from '@mm-services/translate-from.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; -import { FormConfig } from '@mm-services/form/form-config'; +import { FormConfig, FormType } from '@mm-services/form/form-config'; @Component({ selector: 'training-cards-form', @@ -113,7 +113,7 @@ export class TrainingCardsFormComponent implements OnInit, OnDestroy { this.loadingContent = true; this.geoHandle?.cancel(); this.geoHandle = this.geolocationService.init(); - const formConfig = await this.xmlFormsService.getFormConfig('training-card', this.trainingCardFormId!); + const formConfig = await this.xmlFormsService.getFormConfig(FormType.TrainingCard, this.trainingCardFormId!); await this.ngZone.run(() => this.renderForm(formConfig)); } catch (error) { this.setError(error); diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index f9487712efb..7a91c4d8580 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -22,6 +22,7 @@ import { Contact, Qualifier } from '@medic/cht-datasource'; import { TelemetryService } from '@mm-services/telemetry.service'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormType } from '@mm-services/form/form-config'; import { FormValidationError } from '@mm-services/enketo.service'; @Component({ @@ -322,7 +323,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { } private async renderForm(formId: string, titleKey: string) { - const formConfig = await this.xmlFormsService.getFormConfig('contact', formId); + const formConfig = await this.xmlFormsService.getFormConfig(FormType.Contact, formId); this.globalActions.setEnketoEditedStatus(false); const formContext = new WebappEnketoFormContext('#contact-form', formConfig, this.getFormInstanceData()); @@ -414,7 +415,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { }; } - save() { + async save() { if (this.enketoSaving) { console.debug('Attempted to call contacts-edit:save more than once'); return; @@ -436,50 +437,49 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { ); } - return this.formService - .saveContact( + try { + const result = await this.formService.saveContact( { docId, type: this.enketoContact.type }, form, this.duplicatesAcknowledged - ) - .then((result) => { - console.debug('saved contact', result); + ); - this.globalActions.setEnketoSavingStatus(false); - this.globalActions.setEnketoEditedStatus(false); + console.debug('saved contact', result); - this.trackSave?.stop({ - name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'save'].join(':'), - recordApdex: true, - }); + this.globalActions.setEnketoSavingStatus(false); + this.globalActions.setEnketoEditedStatus(false); - this.translateService - .get(docId ? 'contact.updated' : 'contact.created') - .then(snackBarContent => this.globalActions.setSnackbarContent(snackBarContent)); + this.trackSave?.stop({ + name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'save'].join(':'), + recordApdex: true, + }); - this.router.navigate(['/contacts', result.docId]); - }) - .catch((err) => { - this.globalActions.setEnketoSavingStatus(false); + this.translateService + .get(docId ? 'contact.updated' : 'contact.created') + .then(snackBarContent => this.globalActions.setSnackbarContent(snackBarContent)); - if (err instanceof FormValidationError) { - // validation messages will be displayed for individual fields. - // That's all we want, really. - return; - } + this.router.navigate(['/contacts', result.docId]); + } catch (err) { + this.globalActions.setEnketoSavingStatus(false); - if (err instanceof DuplicatesFoundError) { - this.duplicates = err.duplicates; - } else { - this.duplicates = []; - } + if (err instanceof FormValidationError) { + // validation messages will be displayed for individual fields. + // That's all we want, really. + return; + } - console.error('Error submitting form data', err); + if (err instanceof DuplicatesFoundError) { + this.duplicates = err.duplicates; + } else { + this.duplicates = []; + } - return this.translateService - .get('Error updating contact') - .then(error => this.globalActions.setEnketoError(error)); - }); + console.error('Error submitting form data', err); + + return this.translateService + .get('Error updating contact') + .then(error => this.globalActions.setEnketoError(error)); + } } navigationCancel() { diff --git a/webapp/src/ts/modules/contacts/contacts-report.component.ts b/webapp/src/ts/modules/contacts/contacts-report.component.ts index 86c2570a5d2..c1ca45e171f 100644 --- a/webapp/src/ts/modules/contacts/contacts-report.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-report.component.ts @@ -12,6 +12,7 @@ import { Selectors } from '@mm-selectors/index'; import { PerformanceService } from '@mm-services/performance.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; +import { FormType } from '@mm-services/form/form-config'; import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; @@ -116,7 +117,7 @@ export class ContactsReportComponent implements OnInit, OnDestroy, AfterViewInit return Promise .all([ this.getContact(), - this.xmlFormsService.getFormConfig('report', this.routeSnapshot.params?.formId), + this.xmlFormsService.getFormConfig(FormType.Report, this.routeSnapshot.params?.formId), ]); } diff --git a/webapp/src/ts/modules/reports/reports-add.component.ts b/webapp/src/ts/modules/reports/reports-add.component.ts index a1de286e684..ce8c1d0a5dd 100644 --- a/webapp/src/ts/modules/reports/reports-add.component.ts +++ b/webapp/src/ts/modules/reports/reports-add.component.ts @@ -19,7 +19,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; -import { FormConfig } from '@mm-services/form/form-config'; +import { FormConfig, FormType } from '@mm-services/form/form-config'; @Component({ templateUrl: './reports-add.component.html', @@ -161,7 +161,7 @@ export class ReportsAddComponent implements OnInit, OnDestroy, AfterViewInit { return Promise .all([ this.getReportContentService.getReportContent(model.doc), - this.xmlFormsService.getFormConfig('report', model.formInternalId) + this.xmlFormsService.getFormConfig(FormType.Report, model.formInternalId) ]) .then(([ reportContent, formConfig ]) => { this.globalActions.setEnketoEditedStatus(false); diff --git a/webapp/src/ts/modules/tasks/tasks-content.component.ts b/webapp/src/ts/modules/tasks/tasks-content.component.ts index e9532ed0bdd..0191caa21f5 100644 --- a/webapp/src/ts/modules/tasks/tasks-content.component.ts +++ b/webapp/src/ts/modules/tasks/tasks-content.component.ts @@ -21,7 +21,7 @@ import { SimpleDatePipe } from '@mm-pipes/date.pipe'; import { TranslateFromPipe } from '@mm-pipes/translate-from.pipe'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { Contact, Qualifier } from '@medic/cht-datasource'; -import { FormConfig } from '@mm-services/form/form-config'; +import { FormConfig, FormType } from '@mm-services/form/form-config'; import { EnketoForm } from '@mm-services/enketo.service'; @Component({ @@ -287,7 +287,7 @@ export class TasksContentComponent implements OnInit, OnDestroy { this.interactionTrackingService.record('task:form_open', action.form); this.loadingForm = true; return this.xmlFormsService - .getFormConfig('task', action.form) + .getFormConfig(FormType.Task, action.form) .then((formConfig) => this.renderForm(action, formConfig)) .then(() => { this.trackMetadata.action = action.content.doc ? 'edit' : 'add'; diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 7d66c65ecbd..e76cb3b55a2 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -297,8 +297,10 @@ export class FormService { } async save(enketoForm: EnketoForm, geoHandle, docId?) { - const docs = await this.completeReport(enketoForm, docId); - return this.ngZone.runOutsideAngular(() => this._save(docs, geoHandle)); + return this.ngZone.runOutsideAngular(async () => { + const docs = await this.completeReport(enketoForm, docId); + return this._save(docs, geoHandle); + }); } private _save(docs, geoHandle) { @@ -366,37 +368,39 @@ export class FormService { enketoForm: EnketoForm, duplicatesAcknowledged: boolean, ) { - const { docId, type } = contactInfo; - const typeFields = this.contactTypesService.isHardcodedType(type) - ? { type } - : { type: 'contact', contact_type: type }; - - const defaultData = docId ? await this.getContact(Qualifier.byUuid(docId)) : typeFields; - const docs = await this.enketoService.saveContact(enketoForm, defaultData!); - - const preparedDocs = await this.applyTransitions(docs); - - const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); - - const duplicates = await this.checkForDuplicates( - primaryDoc ?? preparedDocs.preparedDocs[0], - type, - duplicatesAcknowledged, - enketoForm.config.doc.duplicate_check - ); - if (duplicates?.length) { - throw new DuplicatesFoundError('Duplicates found', duplicates); - } + return this.ngZone.runOutsideAngular(async () => { + const { docId, type } = contactInfo; + const typeFields = this.contactTypesService.isHardcodedType(type) + ? { type } + : { type: 'contact', contact_type: type }; + + const defaultData = docId ? await this.getContact(Qualifier.byUuid(docId)) : typeFields; + const docs = await this.enketoService.saveContact(enketoForm, defaultData!); + + const preparedDocs = await this.applyTransitions(docs); + + const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); + + const duplicates = await this.checkForDuplicates( + primaryDoc ?? preparedDocs.preparedDocs[0], + type, + duplicatesAcknowledged, + enketoForm.config.doc.duplicate_check + ); + if (duplicates?.length) { + throw new DuplicatesFoundError('Duplicates found', duplicates); + } - this.servicesActions.setLastChangedDoc(primaryDoc || preparedDocs.preparedDocs[0]); - const bulkDocsResult = await this.dbService.get().bulkDocs(preparedDocs.preparedDocs); - const failureMessage = this.generateFailureMessage(bulkDocsResult); + this.servicesActions.setLastChangedDoc(primaryDoc || preparedDocs.preparedDocs[0]); + const bulkDocsResult = await this.dbService.get().bulkDocs(preparedDocs.preparedDocs); + const failureMessage = this.generateFailureMessage(bulkDocsResult); - if (failureMessage) { - throw new Error(failureMessage); - } + if (failureMessage) { + throw new Error(failureMessage); + } - return { docId: preparedDocs.docId, bulkDocsResult }; + return { docId: preparedDocs.docId, bulkDocsResult }; + }); } unload(form?: EnketoForm) { diff --git a/webapp/src/ts/services/form/form-config.ts b/webapp/src/ts/services/form/form-config.ts index ecbba614ede..805d4ec52c4 100644 --- a/webapp/src/ts/services/form/form-config.ts +++ b/webapp/src/ts/services/form/form-config.ts @@ -1,4 +1,12 @@ -export type FormType = 'contact' | 'report' | 'task' | 'training-card'; +export const FormType = { + Contact: 'contact', + Report: 'report', + Task: 'task', + TrainingCard: 'training-card', +} as const; + +// eslint-disable-next-line no-redeclare -- intentional value/type merge so FormType works as both +export type FormType = typeof FormType[keyof typeof FormType]; // Putting this in the xml-forms.service file causes that service (and dependencies) to leak into cht-form. // This breaks the cht-form build. diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index 34611a0ba34..1530c9afb83 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -379,7 +379,7 @@ export class XmlFormsService { async getFormConfig(formType: FormType, id: string) { return this.ngZone.runOutsideAngular(async () => { // contact_types config stores full _id value. All other forms are referenced by internalId - const formDoc = await (formType === 'contact' + const formDoc = await (formType === FormType.Contact ? this.dbService.get().get(id) : this.get(id)); const xmlAttachmentName = this.findXFormAttachmentName(formDoc); diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index d5662c6db92..1f54652bb44 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -9,7 +9,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { CHTDatasourceService as CHTDatasourceServiceStub } from './stubs/cht-datasource.service'; import { CONTACT_TYPES } from '@medic/constants'; -import { FormConfig } from '@mm-services/form/form-config'; +import { FormConfig, FormType } from '@mm-services/form/form-config'; const DEFAULT_FORM_ID = 'cht-form-id'; @@ -299,6 +299,6 @@ class ChtFormEnketoFormContext implements EnketoFormContext { } get type() { - return this.contactType ? 'contact': 'report'; + return this.contactType ? FormType.Contact : FormType.Report; } } From 9a88818ec01a9008b8f0a7a91e0c7fd6fc20781a Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 13:17:11 -0500 Subject: [PATCH 35/41] Fix findNodeWithTextContent to support special chars --- webapp/src/ts/services/form/form-data.ts | 16 ++++++---------- .../karma/ts/services/form/form-data.spec.ts | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/webapp/src/ts/services/form/form-data.ts b/webapp/src/ts/services/form/form-data.ts index a8b4c0d2f36..f1bbbc8e7e3 100644 --- a/webapp/src/ts/services/form/form-data.ts +++ b/webapp/src/ts/services/form/form-data.ts @@ -18,9 +18,9 @@ export class EnketoFormData { public deserializeDoc(formConfig: FormConfig): Record { return { + ...this.deserialize(formConfig), _id: this.id, - form_version: formConfig.doc.xmlVersion, - ...this.deserialize(formConfig) + form_version: formConfig.doc.xmlVersion }; } @@ -77,14 +77,10 @@ export abstract class EnketoRootFormData extends EnketoFormData { } public findNodeWithTextContent(textContent: string) { - const result = this.xmlDoc.evaluate( - `.//*[text()=${JSON.stringify(textContent)}]`, - this.rootElement, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null - ); - return result.singleNodeValue; + // XPath query is not viable here because attachment filenames can contain chars that break the XPath (e.g. ") + return Array + .from(this.rootElement.querySelectorAll('*')) + .find(node => node.textContent === textContent) ?? null; } public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { diff --git a/webapp/tests/karma/ts/services/form/form-data.spec.ts b/webapp/tests/karma/ts/services/form/form-data.spec.ts index 8d3d59a048e..347ca940883 100644 --- a/webapp/tests/karma/ts/services/form/form-data.spec.ts +++ b/webapp/tests/karma/ts/services/form/form-data.spec.ts @@ -181,7 +181,7 @@ describe('form-data', () => { const result = contactData.deserializeDoc(buildFormConfig()); expect(result).to.deep.equal({ - _id: 'catchment-id', + _id: 'the-id', form_version: '1.0', name: 'A New Catchment Area', parent: { _id: 'parent-abc' }, From c513f16b3b530a1a1adb8a9b2e86e3d6b46f8cda Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 16:23:44 -0500 Subject: [PATCH 36/41] Replace getNodeByXpath with new findReferencedDoc logic in EnketoService --- webapp/src/ts/services/enketo.service.ts | 40 +- webapp/src/ts/services/form/form-data.ts | 49 +-- .../services/enketo-xml/deep-file-fields.xml | 4 +- .../karma/ts/services/enketo.service.spec.ts | 374 ++++++++++++++++++ .../karma/ts/services/form/form-data.spec.ts | 67 +--- 5 files changed, 419 insertions(+), 115 deletions(-) diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index d7961500e7d..28a01031541 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -16,7 +16,7 @@ import { Contact, Qualifier } from '@medic/cht-datasource'; import { DOC_TYPES } from '@medic/constants'; import { FormConfig } from '@mm-services/form/form-config'; import { - EnektoContactFormData, + EnketoContactFormData, EnketoFormData, EnketoReportFormData, EnketoRootFormData @@ -401,7 +401,7 @@ export class EnketoService { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); const contactDoc = this.initializeDoc(defaultData); - const formData = new EnektoContactFormData( + const formData = new EnketoContactFormData( this.getFormDataXml(form), contactDoc._id, isHardcodedType(contactDoc.type) ? contactDoc.type : contactDoc.contact_type @@ -417,9 +417,9 @@ export class EnketoService { _attachments: formAttachments }; - const siblings = EnektoContactFormData.SIBLING_FIELD_NAMES - .map(fieldName => ({ fieldName, doc: formData.getSiblingData(fieldName)?.deserializeDoc(config) })) - .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); + const siblings = EnketoContactFormData.SIBLING_FIELD_NAMES + .map(fieldName => ({ fieldName, doc: formData.getSiblingData(fieldName)?.deserializeDoc(config) })) + .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); await Promise.all(siblings.map(async ({ fieldName, doc }) => { rootOutputDoc[fieldName] = await this.getContactSiblingValue( doc, rootOutputDoc[fieldName], defaultData[fieldName] @@ -559,16 +559,30 @@ export class EnketoService { }; } + private findReferencedDoc(refElement: Element, reference: string | null, allData: EnketoFormData[]) { + const target = reference?.trim().replace(/^\.?\//, ''); // strip leading "./" or "/" + if (!target) { + return; + } + const matches = allData.filter(({ rootElement }) => { + const path = Xpath.getElementRawXPath(rootElement).replace(/^\//, ''); // strip leading "/" + return path === target || path.endsWith(`/${target}`); + }); + + // For the docs that match the path tail, find the one with the closest ancestor node to the refElement. + for (let ancestor: Element | null = refElement; ancestor; ancestor = ancestor.parentElement) { + const match = matches.find(({ rootElement }) => ancestor?.contains(rootElement)); + if (match) { + return match; + } + } + } + private populateDbDocRefElements(formData: EnketoReportFormData, allData: EnketoFormData[]) { formData.dbDocRefElements.forEach(element => { - const reference = element.getAttribute('db-doc-ref'); - const referencedNode = formData.getNodeByXpath(element, reference); - if (!referencedNode) { - return; - } - const refDoc = allData.find(({ rootElement }) => rootElement === referencedNode); - if (refDoc) { - element.textContent = refDoc.id; + const referencedDoc = this.findReferencedDoc(element, element.getAttribute('db-doc-ref'), allData); + if (referencedDoc) { + element.textContent = referencedDoc.id; } }); } diff --git a/webapp/src/ts/services/form/form-data.ts b/webapp/src/ts/services/form/form-data.ts index f1bbbc8e7e3..30493594d6b 100644 --- a/webapp/src/ts/services/form/form-data.ts +++ b/webapp/src/ts/services/form/form-data.ts @@ -69,10 +69,10 @@ export abstract class EnketoRootFormData extends EnketoFormData { public readonly binaryTypeElements: Element[]; protected constructor( - private readonly xmlDoc: XMLDocument, + rootElement: Element, id: string, ) { - super(xmlDoc.documentElement, id); + super(rootElement, id); this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary]')); } @@ -82,52 +82,15 @@ export abstract class EnketoRootFormData extends EnketoFormData { .from(this.rootElement.querySelectorAll('*')) .find(node => node.textContent === textContent) ?? null; } - - public getNodeByXpath(contextNode: Node, rawXpath?: string | null): Node | null { - const xpath = rawXpath?.trim(); - if (!xpath) { - return null; - } - const xpathSegments = xpath - .trim() - .split('/') - .filter(Boolean); - const contextLineage = this.getNodeWithLineage(contextNode); - - // Number of leading segments the target path shares with the context node's lineage. - const firstDivergence = xpathSegments - .findIndex((segment, i) => segment !== contextLineage[i]?.nodeName); - const commonAncestorIndex = firstDivergence === -1 ? xpathSegments.length : firstDivergence; - - // Fall back to contextNode in case of relative xpath - const anchor = contextLineage[commonAncestorIndex - 1] ?? contextNode; - const relativePath = xpathSegments.slice(commonAncestorIndex).join('/') || '.'; - const result = this.xmlDoc.evaluate( - relativePath, - anchor, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null - ); - return result.singleNodeValue; - } - - private getNodeWithLineage(contextNode?: Node | null, lineage: Node[] = []): Node[] { - if (!this.isElementNode(contextNode)) { - return lineage; - } - lineage.unshift(contextNode); - return this.getNodeWithLineage(contextNode.parentNode, lineage); - } } -export class EnektoContactFormData extends EnketoRootFormData { +export class EnketoContactFormData extends EnketoRootFormData { public static readonly SIBLING_FIELD_NAMES = ['parent', 'contact'] as const; private readonly childElements: Element[]; private readonly rootContactElement: Element; constructor(xmlDoc: XMLDocument, id: string, type: string) { - super(xmlDoc, id); + super(xmlDoc.documentElement, id); this.childElements = Array.from(this.rootElement.querySelectorAll(':scope > repeat > child')); const elementForType = this.findChildNode(this.rootElement, type); if (!elementForType) { @@ -155,7 +118,7 @@ export class EnektoContactFormData extends EnketoRootFormData { return this.childElements.map(dbDoc => new EnketoFormData(dbDoc, this.getDocId(dbDoc))); } - public getSiblingData(fieldName: typeof EnektoContactFormData.SIBLING_FIELD_NAMES[number]) { + public getSiblingData(fieldName: typeof EnketoContactFormData.SIBLING_FIELD_NAMES[number]) { const element = this.findChildNode(this.rootElement, fieldName); return element ? new EnketoFormData(element, this.getDocId(element)) : null; } @@ -167,7 +130,7 @@ export class EnketoReportFormData extends EnketoRootFormData { public readonly dbDocRefElements: Element[]; constructor(xmlDoc: XMLDocument, id: string) { - super(xmlDoc, id); + super(xmlDoc.documentElement, id); this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]')); this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]')); this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]')); diff --git a/webapp/tests/karma/ts/services/enketo-xml/deep-file-fields.xml b/webapp/tests/karma/ts/services/enketo-xml/deep-file-fields.xml index cd84afd614e..92930697ed6 100644 --- a/webapp/tests/karma/ts/services/enketo-xml/deep-file-fields.xml +++ b/webapp/tests/karma/ts/services/enketo-xml/deep-file-fields.xml @@ -2,10 +2,10 @@ Mary 10 f - some image name.png + some image data - some other name.png + some other data diff --git a/webapp/tests/karma/ts/services/enketo.service.spec.ts b/webapp/tests/karma/ts/services/enketo.service.spec.ts index e51a29f5cca..2ee8ab865c9 100644 --- a/webapp/tests/karma/ts/services/enketo.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo.service.spec.ts @@ -419,6 +419,8 @@ describe('Enketo service', () => { }); describe('saveReport', () => { + const repeatXml = ''; + beforeEach(() => { service = TestBed.inject(EnketoService); sinon.stub(FileManager, 'getCurrentFiles').returns([]); @@ -674,6 +676,346 @@ describe('Enketo service', () => { expect(thing3.reported_date).to.be.a('number'); }); + it('creates a db-doc for each repeated db-doc element', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('extra-docs-with-repeat')); + + const [report, thing1, thing2, thing3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const doc1 = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + const doc2 = { my_parent: report._id, some_property: 'some_value_2', type: 'repeater' }; + const doc3 = { my_parent: report._id, some_property: 'some_value_3', type: 'repeater' }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + // The three repeated elements are not declared as a repeat path, so only the last one survives + // deserialization of the report fields (each still becomes its own db-doc below). + repeat_doc: doc3, + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ ...doc1, _id: thing1._id, form_version: undefined }); + expect(thing2).excluding(['reported_date']).to.deep.equal({ ...doc2, _id: thing2._id, form_version: undefined }); + expect(thing3).excluding(['reported_date']).to.deep.equal({ ...doc3, _id: thing3._id, form_version: undefined }); + }); + + it('populates db-doc-ref elements inside deeply-nested repeats', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-in-deep-repeat')); + + const [report, thing1, thing2, thing3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const doc1 = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + const doc2 = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + const doc3 = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + repeat_section: [ + { + extra: 'data1', + other: { deep: { structure: { repeat_doc: doc1 } } }, + some: { deep: { structure: { repeat_doc_ref: thing1._id } } }, + }, + { + extra: 'data2', + other: { deep: { structure: { repeat_doc: doc2 } } }, + some: { deep: { structure: { repeat_doc_ref: thing2._id } } }, + }, + { + extra: 'data3', + other: { deep: { structure: { repeat_doc: doc3 } } }, + some: { deep: { structure: { repeat_doc_ref: thing3._id } } }, + }, + ], + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section.other.deep.structure.repeat_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ ...doc1, _id: thing1._id, form_version: undefined }); + expect(thing2).excluding(['reported_date']).to.deep.equal({ ...doc2, _id: thing2._id, form_version: undefined }); + expect(thing3).excluding(['reported_date']).to.deep.equal({ ...doc3, _id: thing3._id, form_version: undefined }); + }); + + it('populates db-doc-ref elements that reference a db-doc outside the repeat', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-outside-of-repeat')); + + const [report, separateDoc, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const separate = { my_parent: report._id, some_property: 'some_value_1', type: 'separat5e' }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + separate_doc: separate, + // Every repeat instance references the single db-doc defined outside the repeat. + repeat_section: [ + { extra: 'data1', repeat_doc_ref: separateDoc._id }, + { extra: 'data2', repeat_doc_ref: separateDoc._id }, + { extra: 'data3', repeat_doc_ref: separateDoc._id }, + ], + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'separate_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(separateDoc).excluding(['reported_date']) + .to.deep.equal({ ...separate, _id: separateDoc._id, form_version: undefined }); + }); + + it('creates a db-doc from each repeat instance when the repeat itself is the db-doc', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-same-as-repeat')); + + const [report, doc1, doc2, doc3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const repeat1 = { + extra: 'data1', type: 'repeater', some_property: 'some_value_1', my_parent: report._id, + repeat_doc_ref: doc1._id, + }; + const repeat2 = { + extra: 'data2', type: 'repeater', some_property: 'some_value_2', my_parent: report._id, + repeat_doc_ref: doc2._id, + }; + const repeat3 = { + extra: 'data3', type: 'repeater', some_property: 'some_value_3', my_parent: report._id, + child: { repeat_doc_ref: doc3._id }, + }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + repeat_section: [repeat1, repeat2, repeat3], + // A db-doc-ref outside any repeat resolves to the first (closest) repeat_section db-doc. + repeat_doc_ref: doc1._id, + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(doc1).excluding(['reported_date']).to.deep.equal({ ...repeat1, _id: doc1._id, form_version: undefined }); + expect(doc2).excluding(['reported_date']).to.deep.equal({ ...repeat2, _id: doc2._id, form_version: undefined }); + expect(doc3).excluding(['reported_date']).to.deep.equal({ ...repeat3, _id: doc3._id, form_version: undefined }); + }); + + it('leaves db-doc-ref elements with unresolvable references untouched', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-broken-ref')); + + const [report, doc1, doc2, doc3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const repeat1 = { + extra: 'data1', type: 'repeater', some_property: 'some_value_1', my_parent: report._id, + repeat_doc_ref: 'value1', + }; + const repeat2 = { + extra: 'data2', type: 'repeater', some_property: 'some_value_2', my_parent: report._id, + repeat_doc_ref: 'value2', ing: 'something', + }; + const repeat3 = { + extra: 'data3', type: 'repeater', some_property: 'some_value_3', my_parent: report._id, + repeat_doc_ref: 'value3', + }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + // None of the db-doc-refs point at a real element, so each keeps its original literal value. + repeat_section: [repeat1, repeat2, repeat3], + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(doc1).excluding(['reported_date']).to.deep.equal({ ...repeat1, _id: doc1._id, form_version: undefined }); + expect(doc2).excluding(['reported_date']).to.deep.equal({ ...repeat2, _id: doc2._id, form_version: undefined }); + expect(doc3).excluding(['reported_date']).to.deep.equal({ ...repeat3, _id: doc3._id, form_version: undefined }); + }); + + it('populates a local (./) db-doc-ref with the sibling db-doc in the same repeat', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-in-repeats-with-local-references')); + + const [report, thing1, thing2, thing3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const doc1 = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + const doc2 = { my_parent: report._id, some_property: 'some_value_2', type: 'repeater' }; + const doc3 = { my_parent: report._id, some_property: 'some_value_3', type: 'repeater' }; + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + repeat_section: [ + { extra: 'data1', repeat_doc: doc1, repeat_doc_ref: thing1._id }, + { extra: 'data2', repeat_doc: doc2, repeat_doc_ref: thing2._id }, + { extra: 'data3', repeat_doc: doc3, repeat_doc_ref: thing3._id }, + ], + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section.repeat_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ ...doc1, _id: thing1._id, form_version: undefined }); + expect(thing2).excluding(['reported_date']).to.deep.equal({ ...doc2, _id: thing2._id, form_version: undefined }); + expect(thing3).excluding(['reported_date']).to.deep.equal({ ...doc3, _id: thing3._id, form_version: undefined }); + }); + + it('resolves a db-doc-ref to the db-doc when a non-db-doc sibling shares the element name', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-in-deep-repeats-extra-repeats')); + + const [report, thing1, thing2, thing3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const doc = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + const buildRepeat = (extra: string, refId: string) => ({ + extra, + other: { deep: { structure: { repeat_doc: doc } } }, + some: { deep: { structure: { repeat_doc_ref: refId } } }, + }); + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + repeat_section: [ + buildRepeat('data1', thing1._id), + buildRepeat('data2', thing2._id), + buildRepeat('data3', thing3._id), + ], + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section.other.deep.structure.repeat_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ ...doc, _id: thing1._id, form_version: undefined }); + expect(thing2).excluding(['reported_date']).to.deep.equal({ ...doc, _id: thing2._id, form_version: undefined }); + expect(thing3).excluding(['reported_date']).to.deep.equal({ ...doc, _id: thing3._id, form_version: undefined }); + }); + + it('populates a deeply-nested local db-doc-ref, skipping the non-db-doc sibling', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('db-doc-ref-in-deep-repeats-with-local-references')); + + const [report, thing1, thing2, thing3, ...additional] = await saveReport( + { contact: { _id: '123', phone: '555' } }, + { xml: repeatXml, doc: { internalId: 'V' } } + ); + + expect(additional).to.be.empty; + const doc = { my_parent: report._id, some_property: 'some_value_1', type: 'repeater' }; + const buildRepeat = (extra: string, refId: string) => ({ + extra, + other: { deep: { structure: { repeat_doc: doc } } }, + some: { deep: { structure: { repeat_doc_ref: refId } } }, + }); + expect(report).excluding(['reported_date']).to.deep.equal({ + _id: report._id, + contact: { _id: '123' }, + content_type: 'xml', + fields: { + name: 'Sally', + lmp: '10', + secret_code_name: 'S4L', + repeat_section: [ + buildRepeat('data1', thing1._id), + buildRepeat('data2', thing2._id), + buildRepeat('data3', thing3._id), + ], + }, + form: 'V', + form_version: undefined, + from: '555', + hidden_fields: ['secret_code_name', 'repeat_section.other.deep.structure.repeat_doc'], + type: DOC_TYPES.DATA_RECORD, + _attachments: undefined, + }); + expect(thing1).excluding(['reported_date']).to.deep.equal({ ...doc, _id: thing1._id, form_version: undefined }); + expect(thing2).excluding(['reported_date']).to.deep.equal({ ...doc, _id: thing2._id, form_version: undefined }); + expect(thing3).excluding(['reported_date']).to.deep.equal({ ...doc, _id: thing3._id, form_version: undefined }); + }); + describe('attachments', () => { let getCurrentFiles; @@ -722,6 +1064,38 @@ describe('Enketo service', () => { }); }); + it('names binary attachments by the form internalId and full field path, not the root node name', async () => { + form.validate.resolves(true); + form.getDataStr.returns(loadXML('deep-file-fields')); + + const [report] = await saveReport( + { contact: { _id: 'my-user', phone: '8989' } }, + { doc: { internalId: 'my-form' } } + ); + + expect(report.fields).to.deep.equal({ + name: 'Mary', + age: '10', + gender: 'f', + my_file: '', + sub_element: { sub_sub_element: { other_file: '' } }, + }); + // Attachment names use the form internalId ("my-form") and the field's full path - not the root node name + // ("my-root-element") - even for a deeply-nested field. + expect(Object.keys(report._attachments)).to.have.members([ + 'user-file/my-form/my_file', + 'user-file/my-form/sub_element/sub_sub_element/other_file', + ]); + expect(report._attachments['user-file/my-form/my_file']).to.deep.equal({ + data: 'some image data', + content_type: 'image/png', + }); + expect(report._attachments['user-file/my-form/sub_element/sub_sub_element/other_file']).to.deep.equal({ + data: 'some other data', + content_type: 'image/png', + }); + }); + it('retains custom attachments and referenced file attachments, dropping unreferenced ones', async () => { form.validate.resolves(true); // The field references the "referenced.png" file attachment; nothing references "orphan.png". diff --git a/webapp/tests/karma/ts/services/form/form-data.spec.ts b/webapp/tests/karma/ts/services/form/form-data.spec.ts index 347ca940883..e86d91017b1 100644 --- a/webapp/tests/karma/ts/services/form/form-data.spec.ts +++ b/webapp/tests/karma/ts/services/form/form-data.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { FormConfig } from '@mm-services/form/form-config'; import { - EnektoContactFormData, + EnketoContactFormData, EnketoFormData, EnketoReportFormData, } from '@mm-services/form/form-data'; @@ -135,11 +135,11 @@ describe('form-data', () => { }); }); - describe('EnektoContactFormData', () => { + describe('EnketoContactFormData', () => { it('throws when the group named after the contact type is missing', () => { const doc = parseXml('A Clinic'); - expect(() => new EnektoContactFormData(doc, 'the-id', 'person')) + expect(() => new EnketoContactFormData(doc, 'the-id', 'person')) .to.throw('Failed to save contact form because the data for the contact is not contained in the person group.'); }); @@ -152,7 +152,7 @@ describe('form-data', () => { +123456789 `); - const contactData = new EnektoContactFormData(doc, 'the-id', 'person'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'person'); const result = contactData.deserializeDoc(buildFormConfig([], '3.5')); @@ -176,7 +176,7 @@ describe('form-data', () => { contact-xyz `); - const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'clinic'); const result = contactData.deserializeDoc(buildFormConfig()); @@ -198,7 +198,7 @@ describe('form-data', () => { <_id>contact-xyzThe Contact `); - const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'clinic'); const result = contactData.deserializeDoc(buildFormConfig()); @@ -222,7 +222,7 @@ describe('form-data', () => { Baby Bear
`); - const contactData = new EnektoContactFormData(doc, 'the-id', 'person'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'person'); const [child1, child2, ...additional] = contactData.getChildData(); @@ -236,7 +236,7 @@ describe('form-data', () => { it('returns an empty array when there are no repeat > child elements', () => { const doc = parseXml('Mum'); - const contactData = new EnektoContactFormData(doc, 'the-id', 'person'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'person'); expect(contactData.getChildData()).to.deep.equal([]); }); @@ -250,7 +250,7 @@ describe('form-data', () => { <_id>parent-1The Parent The Contact `); - const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'clinic'); const parent = contactData.getSiblingData('parent'); expect(parent!.id).to.equal('parent-1'); @@ -263,7 +263,7 @@ describe('form-data', () => { it('returns null when the sibling group is not present', () => { const doc = parseXml('A Clinic'); - const contactData = new EnektoContactFormData(doc, 'the-id', 'clinic'); + const contactData = new EnketoContactFormData(doc, 'the-id', 'clinic'); expect(contactData.getSiblingData('parent')).to.be.null; }); @@ -348,53 +348,6 @@ describe('form-data', () => { expect(refs).to.deep.equal(['/data/my_doc', '/data/name']); }); - describe('getNodeByXpath', () => { - it('resolves an absolute xpath', () => { - const doc = parseXml('The Report'); - const reportData = new EnketoReportFormData(doc, 'the-id'); - - const node = reportData.getNodeByXpath(doc.documentElement, '/data/report/name'); - - expect(node!.textContent).to.equal('The Report'); - }); - - it('resolves a relative xpath against the context node', () => { - const doc = parseXml('The Report'); - const reportData = new EnketoReportFormData(doc, 'the-id'); - const reportNode = doc.getElementsByTagName('report')[0]; - - expect(reportData.getNodeByXpath(reportNode, 'name')!.textContent).to.equal('The Report'); - expect(reportData.getNodeByXpath(reportNode, './name')!.textContent).to.equal('The Report'); - }); - - it('returns null for an empty or missing xpath', () => { - const doc = parseXml('The Report'); - const reportData = new EnketoReportFormData(doc, 'the-id'); - - expect(reportData.getNodeByXpath(doc.documentElement, null)).to.be.null; - expect(reportData.getNodeByXpath(doc.documentElement, ' ')).to.be.null; - }); - - it('resolves an absolute xpath to a node in the same repeat entry as the context node', () => { - const repeatXml = ` - - firstfirst-value - secondsecond-value - thirdthird-value - `; - const doc = parseXml(repeatXml); - const reportData = new EnketoReportFormData(doc, 'the-id'); - // Context node is the nested inside the SECOND repeat entry. - const contextNode = doc.getElementsByTagName('repeat_section')[1].getElementsByTagName('name')[0]; - - const node = reportData.getNodeByXpath(contextNode, '/data/repeat_section/value'); - expect(node!.textContent).to.equal('second-value'); - - const relativeNode = reportData.getNodeByXpath(contextNode, '../value'); - expect(relativeNode!.textContent).to.equal('second-value'); - }); - }); - describe('findNodeWithTextContent', () => { it('finds the first node with the given text content', () => { const doc = parseXml(` From c03b1b4618949317e0099eadc87b4c0200866895 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 17:17:53 -0500 Subject: [PATCH 37/41] Clean up and streamline xml-forms logic --- webapp/src/ts/services/xml-forms.service.ts | 62 +++++++------------ .../ts/services/xml-forms.service.spec.ts | 22 ++----- 2 files changed, 28 insertions(+), 56 deletions(-) diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index 1530c9afb83..3dfe8ce6ecf 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -72,11 +72,6 @@ export class XmlFormsService { }); } - private getById(internalId) { - const formId = `${PREFIXES.FORM}${internalId}`; - return this.dbService.get().get(formId); - } - private getByView(internalId) { return this .init @@ -344,55 +339,44 @@ export class XmlFormsService { /** * @memberof XmlFormsService * @param {String} internalId The value of the desired doc's internalId field. + * @param {FormType} formType The type of form to retrieve * @returns {Promise} Resolves a doc containing an xform with the given * internal identifier if the user is allowed to see it. */ - private get(internalId) { - return this - .getById(internalId) - .catch(err => { - console.warn('Error in XMLFormService : getById : ', err?.message, err?.status, err); - if (err.status === 404) { - // fallback for backwards compatibility - return this.getByView(internalId); - } - throw err; - }) - .then(doc => { - if (!this.hasRequiredAttachments(doc)) { - const errorTitle = 'Error in XMLFormService : hasRequiredAttachments : '; - const errorMessage = `The form "${internalId}" doesn't have required attachments`; - console.error(errorTitle, errorMessage); - return Promise.reject(new Error(errorTitle + errorMessage)); - } - return doc; - }); + private async get(internalId: string, formType: FormType) { + // contact_types config stores full _id value. All other forms are referenced by internalId + const formId = formType === FormType.Contact ? internalId : `${PREFIXES.FORM}${internalId}`; + try { + return await this.dbService.get().get(formId); + } catch (err) { + console.warn('Error in XMLFormService : getById : ', err?.message, err?.status, err); + if (err.status === 404) { + // fallback for backwards compatibility + return this.getByView(internalId); + } + throw err; + } } private getAttachment(id: string, name: string) { return this.dbService .get() .getAttachment(id, name) - .then(blob => this.fileReaderService.utf8(blob)); + .then(blob => this.fileReaderService.utf8(blob)) + .catch(() => { + throw new Error(`Could not get [${name}] form attachment for form [${id}].`); + }); } async getFormConfig(formType: FormType, id: string) { return this.ngZone.runOutsideAngular(async () => { - // contact_types config stores full _id value. All other forms are referenced by internalId - const formDoc = await (formType === FormType.Contact - ? this.dbService.get().get(id) - : this.get(id)); + const formDoc = await this.get(id, formType); const xmlAttachmentName = this.findXFormAttachmentName(formDoc); + if (!xmlAttachmentName) { + throw new Error(`Could not get [xml] form attachment for form [${id}].`); + } const [xml, html, model] = await Promise.all([ - this.getAttachment(formDoc._id, xmlAttachmentName) - .catch(err => { - const errorDetails = err.status === 404 - ? `The form "${id}" doesn't have an xform attachment` - : `Failed to get the form "${id}" xform attachment`; - const errorMessage = `Error in XMLFormService : getDocAndFormAttachment : ${errorDetails}`; - console.error(errorMessage); - throw new Error(errorMessage); - }), + this.getAttachment(formDoc._id, this.findXFormAttachmentName(formDoc)), this.getAttachment(formDoc._id, this.HTML_ATTACHMENT_NAME), this.getAttachment(formDoc._id, this.MODEL_ATTACHMENT_NAME) ]); diff --git a/webapp/tests/karma/ts/services/xml-forms.service.spec.ts b/webapp/tests/karma/ts/services/xml-forms.service.spec.ts index 6aa16550f1b..d25643a7dab 100644 --- a/webapp/tests/karma/ts/services/xml-forms.service.spec.ts +++ b/webapp/tests/karma/ts/services/xml-forms.service.spec.ts @@ -1370,10 +1370,8 @@ describe('XmlForms service', () => { }); }); - it('returns error when cannot find xform attachment', () => { + it('returns error when cannot find xform attachment', async () => { const internalId = 'birth'; - const expectedErrorTitle = 'Error in XMLFormService : hasRequiredAttachments : '; - const expectedErrorDetail = `The form "${internalId}" doesn't have required attachments`; const expected = { _id: `form:${internalId}`, type: 'form', @@ -1382,16 +1380,9 @@ describe('XmlForms service', () => { dbGet.resolves(expected); dbQuery.resolves([]); const service = getService(); - return service.getFormConfig('report', internalId) - .then(() => { - assert.fail('expected error to be thrown'); - }) - .catch(err => { - expect(err.message).to.equal(expectedErrorTitle + expectedErrorDetail); - expect(error.callCount).to.equal(1); - expect(error.args[0][0]).to.equal(expectedErrorTitle); - expect(error.args[0][1]).to.equal(expectedErrorDetail); - }); + await expect(service.getFormConfig('report', internalId)).to.be.rejectedWith( + `Could not get [xml] form attachment for form [${internalId}].` + ); }); it('falls back to using view if no doc found', async () => { @@ -1492,8 +1483,7 @@ describe('XmlForms service', () => { 'form.html': { stub: true }, }, }; - const expectedErrorMessage = 'Error in XMLFormService : getDocAndFormAttachment : ' - + `The form "${formId}" doesn't have an xform attachment`; + const expectedErrorMessage = `Could not get [xml] form attachment for form [${formId}].`; dbQuery.resolves([]); dbGet.resolves(formDoc); dbGetAttachment.withArgs(formId, 'xml').rejects({ status: 404 }); @@ -1503,8 +1493,6 @@ describe('XmlForms service', () => { const service = getService(); await expect(service.getFormConfig('contact', formId)).to.be.rejectedWith(expectedErrorMessage); - - expect(error.calledWith(expectedErrorMessage)).to.be.true; }); }); From 6c8924b0b848a449649d2db4061eebb1a50e158b Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 17:56:49 -0500 Subject: [PATCH 38/41] Clean up cht-form logic --- .../cht-form/src/app.component.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/webapp/web-components/cht-form/src/app.component.ts b/webapp/web-components/cht-form/src/app.component.ts index 1f54652bb44..00f58d457ea 100644 --- a/webapp/web-components/cht-form/src/app.component.ts +++ b/webapp/web-components/cht-form/src/app.component.ts @@ -57,22 +57,23 @@ export class AppComponent { if (!value?.trim().length) { throw new Error('The Form Id must be populated.'); } - this.formContext.formId = value; + this.formContext.setFormConfig({ formId: value }); this.queueRenderForm(); } @Input() set formHtml(value: string | undefined) { - this.formContext.formHtml = value; + this.formContext.setFormConfig({ formHtml: value }); this.queueRenderForm(); } @Input() set formModel(value: string | undefined) { - this.formContext.formModel = value; + this.formContext.setFormConfig({ formModel: value }); this.queueRenderForm(); } @Input() set formXml(value: string | undefined) { this.formContext.formXml = value; + this.formContext.setFormConfig(); this.queueRenderForm(); } @@ -83,6 +84,7 @@ export class AppComponent { @Input() set contactType(value: string | undefined) { this.formContext.contactType = value; + this.formContext.setFormConfig(); this.queueRenderForm(); } @@ -125,7 +127,7 @@ export class AppComponent { } get formId(): string { - return this.formContext.formId; + return this.formContext.formConfig.doc._id; } cancelForm(): void { @@ -191,7 +193,11 @@ export class AppComponent { private async renderForm() { this.unloadForm(); - if (!this.formContext.formHtml || !this.formContext.formModel || !this.formContext.formXml) { + if ( + !this.formContext.formConfig.html + || !this.formContext.formConfig.model + || !this.formContext.formXml + ) { return; } @@ -239,7 +245,7 @@ export class AppComponent { // twice. So, we cannot just reset the internal state of the "inputs" here. We need to call "through the front door" // so the context knows that the values have changed instead of just directly resetting the state of this class. const myForm = this.document - .getElementById(this.formContext.formId) + .getElementById(this.formContext.formConfig.doc._id) ?.closest('cht-form'); const component = myForm || this; // @ts-expect-error it does exist @@ -272,22 +278,26 @@ class ChtFormEnketoFormContext implements EnketoFormContext { error: null as string | null }; - formId = DEFAULT_FORM_ID; formXml?: string; - formHtml?: string; - formModel?: string; + formConfig: FormConfig = this.setFormConfig(); + contactType?: string; content?: Record; editing = false; - get formConfig() { - return new FormConfig( - { _id: this.formId }, + setFormConfig({ + formId = this.formConfig?.doc._id, + formHtml = this.formConfig?.html, + formModel = this.formConfig?.model, + }: { formId?: string, formHtml?: string, formModel?: string } = {}) { + this.formConfig = new FormConfig( + { ...this.formConfig?.doc, _id: formId || DEFAULT_FORM_ID }, this.type, this.formXml || 'xml', - this.formHtml || '', - this.formModel || '' + formHtml || '', + formModel || '' ); + return this.formConfig; } get instanceData() { @@ -295,7 +305,7 @@ class ChtFormEnketoFormContext implements EnketoFormContext { } get selector() { - return `#${this.formId}`; + return `#${this.formConfig.doc._id}`; } get type() { From bdcc72692d745e27f160ea7aabad6a9bcc80485e Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 21:11:40 -0500 Subject: [PATCH 39/41] Clean up EnketoService logic --- webapp/src/ts/services/enketo.service.ts | 84 +++++++++++++----------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts index 28a01031541..da76f103af2 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -193,10 +193,10 @@ export class EnketoService { } private renderFromXmls(xmlFormContext: XmlFormContext, userSettings) { - const { formConfig, titleKey, wrapper, isFormInModal } = xmlFormContext; + const { formConfig, $formHtml, titleKey, wrapper, isFormInModal } = xmlFormContext; const formContainer = wrapper.find('.container').first(); - formContainer.html($(formConfig.html).get(0)!); + formContainer.html($formHtml.get(0)!); return this .getEnketoForm(xmlFormContext, userSettings) @@ -350,11 +350,12 @@ export class EnketoService { isFormInModal, } = formContext; - const $html = $(formConfig.html); - this.replaceDataI18nTranslations($html); - this.replaceJavarosaMediaWithLoaders($html); + const $formHtml = $(formConfig.html); + this.replaceDataI18nTranslations($formHtml); + this.replaceJavarosaMediaWithLoaders($formHtml); const xmlFormContext: XmlFormContext = { formConfig, + $formHtml, wrapper: $(selector), instanceData, titleKey, @@ -400,7 +401,7 @@ export class EnketoService { public async saveContact({ config, form }: EnketoForm, defaultData: Record) { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); - const contactDoc = this.initializeDoc(defaultData); + const contactDoc: Record = { _id: uuid(), ...defaultData }; const formData = new EnketoContactFormData( this.getFormDataXml(form), contactDoc._id, @@ -408,41 +409,38 @@ export class EnketoService { ); const formAttachments = this.processFormAttachments(config.doc.internalId, formData, contactDoc._attachments); - + const reportedDate = Date.now(); const rootOutputDoc: Record = { ...contactDoc, ...formData.deserializeDoc(config), + _id: contactDoc._id, type: contactDoc.type, contact_type: contactDoc.contact_type, + reported_date: contactDoc.reported_date || reportedDate, _attachments: formAttachments }; - - const siblings = EnketoContactFormData.SIBLING_FIELD_NAMES - .map(fieldName => ({ fieldName, doc: formData.getSiblingData(fieldName)?.deserializeDoc(config) })) - .map(({ fieldName, doc }) => ({ fieldName, doc: this.initializeContactSibling(rootOutputDoc, doc)})); - await Promise.all(siblings.map(async ({ fieldName, doc }) => { - rootOutputDoc[fieldName] = await this.getContactSiblingValue( - doc, rootOutputDoc[fieldName], defaultData[fieldName] - ); - })); + const siblings = await this.processContactSiblings(formData, config, rootOutputDoc, defaultData); + siblings.forEach(({ fieldName, fieldValue }) => rootOutputDoc[fieldName] = fieldValue); const outputSiblings = siblings .filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName] === doc) - .map(({ doc }) => doc) - .filter(doc => !!doc); + .map(({ doc }) => ({ ...doc, reported_date: reportedDate })); const childDocs = formData .getChildData() - .map(doc => this.initializeDoc(doc.deserializeDoc(config))) - .map(doc => ({ ...doc, parent: rootOutputDoc })); + .map(doc => doc.deserializeDoc(config)) + .map(doc => ({ ...doc, reported_date: reportedDate, parent: rootOutputDoc })); return { docId: rootOutputDoc._id, - preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs].map(doc => this.dehydrateContactLineage(doc)) + preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs].map(doc => this.minifyContactLineage(doc)) }; }); } - public async saveReport({ config, form }: EnketoForm, defaultData: Record) { + public async saveReport( + { config, form }: EnketoForm, + defaultData: Record + ): Promise[]> { return this.ngZone.runOutsideAngular(async () => { await this.validate(form); const { internalId, xmlVersion } = config.doc; @@ -461,16 +459,18 @@ export class EnketoService { ...subDocsData.map(({ rootElement }) => rootElement) ]); + const reportedDate = Date.now(); const rootOutputDoc: Record = { ...reportDoc, hidden_fields: hiddenFields, fields: formData.deserialize(config), + reported_date: reportDoc.reported_date || reportedDate, _attachments: attachments }; const dbDocObjects = subDocsData .map(docData => docData.deserializeDoc(config)) - .map(doc => this.initializeDoc(doc)); + .map(doc => ({ ...doc, reported_date: reportedDate })); return [rootOutputDoc, ...dbDocObjects]; }); } @@ -488,13 +488,29 @@ export class EnketoService { return new DOMParser().parseFromString(formString, 'text/xml'); } + private async processContactSiblings( + formData: EnketoContactFormData, + config: FormConfig, + rootOutputDoc: Record, + defaultData: Record + ) { + return Promise.all(EnketoContactFormData.SIBLING_FIELD_NAMES.map(async (fieldName) => { + const sibling = formData + .getSiblingData(fieldName) + ?.deserializeDoc(config); + const doc = this.initializeContactSibling(rootOutputDoc, sibling); + const fieldValue = await this.getContactSiblingValue(doc, rootOutputDoc[fieldName], defaultData[fieldName]); + return { fieldName, fieldValue, doc, }; + })); + } + private initializeContactSibling(rootContactDoc: Record, rawSibling?: Record) { if (!rawSibling) { return; } return { type: 'person', // legacy support - form data should override this - ...this.initializeDoc(rawSibling), + ...rawSibling, parent: rawSibling.parent === 'PARENT' ? rootContactDoc : rawSibling.parent }; } @@ -517,7 +533,7 @@ export class EnketoService { return await this.getContactFromDatasource(Qualifier.byUuid(currentValue._id)); } - private dehydrateContactLineage(contactDoc: Record) { + private minifyContactLineage(contactDoc: Record) { return { ...contactDoc, parent: this.extractLineageService.extract(contactDoc.parent), @@ -539,21 +555,14 @@ export class EnketoService { .join('.')); } - private initializeDoc(defaultData: Record): Record { - return { - _id: defaultData._id || uuid(), - reported_date: Date.now(), - ...defaultData - }; - } - private initializeReportDoc(form: string, formVersion: string, defaultData: Record) { return { + _id: uuid(), form, type: DOC_TYPES.DATA_RECORD, content_type: 'xml', from: defaultData.contact?.phone, - ...this.initializeDoc(defaultData), + ...defaultData, contact: this.extractLineageService.extract(defaultData.contact), form_version: formVersion }; @@ -612,7 +621,7 @@ export class EnketoService { const binaryAttachments = rootData.binaryTypeElements .map(element => this.buildBinaryAttachmentData(form, originalAttachments, element)) .filter(({ attachment }) => attachment) - .reduce((acc, { filename, attachment }) => ({ ...acc, [filename]: attachment }), {}); + .reduce((binaryAttachments, { filename, attachment }) => ({ ...binaryAttachments, [filename]: attachment }), {}); const newFileAttachments = FileManager .getCurrentFiles() .map(file => ({ @@ -620,12 +629,12 @@ export class EnketoService { content_type: file.type, data: new Blob([ file ], { type: file.type }) })) - .reduce((acc, { name, content_type, data }) => ({ ...acc, [name]: { content_type, data } }), {}); + .reduce((attachments, { name, content_type, data }) => ({ ...attachments, [name]: { content_type, data } }), {}); const existingAttachments = Object .entries(originalAttachments) // Keep custom attachments and existing file attachments still referenced by a field .filter(([key]) => hasCustomAttachmentName(key) || isExistingFileAttachment(key)) - .reduce((acc, [key, attachment]) => ({ ...acc, [key]: attachment }), {}); + .reduce((existingAttachments, [key, attachment]) => ({ ...existingAttachments, [key]: attachment }), {}); const attachments = { ...existingAttachments, @@ -671,6 +680,7 @@ interface EnketoOptions { interface XmlFormContext { formConfig: FormConfig; + $formHtml: JQuery; wrapper: JQuery; instanceData?: string | Record; // String for report forms, Record<> for contact forms. titleKey?: string; From a057a85fd536ccb531f2b22b960594ad6258cb7c Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 22:14:23 -0500 Subject: [PATCH 40/41] Fix sonar --- .../contacts/contacts-edit.component.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 7a91c4d8580..2bcaaae10d1 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -431,11 +431,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.globalActions.setEnketoSavingStatus(true); this.globalActions.setEnketoError(null); - if (this.duplicatesAcknowledged && this.duplicates.length) { - this.telemetryService.record( - ['enketo', 'contacts', this.enketoContact.type, 'duplicates_acknowledged'].join(':') - ); - } + this.recordDuplicatesAcknowledgedTelemetry(); try { const result = await this.formService.saveContact( @@ -468,12 +464,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { return; } - if (err instanceof DuplicatesFoundError) { - this.duplicates = err.duplicates; - } else { - this.duplicates = []; - } - + this.populateErrorDuplicates(err); console.error('Error submitting form data', err); return this.translateService @@ -482,6 +473,22 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { } } + private recordDuplicatesAcknowledgedTelemetry() { + if (this.duplicatesAcknowledged && this.duplicates.length) { + this.telemetryService.record( + ['enketo', 'contacts', this.enketoContact.type, 'duplicates_acknowledged'].join(':') + ); + } + } + + private populateErrorDuplicates(err) { + if (err instanceof DuplicatesFoundError) { + this.duplicates = err.duplicates; + } else { + this.duplicates = []; + } + } + navigationCancel() { this.globalActions.navigationCancel(); } From 400a6dd836700e6b74e4e33b911d9243996436e7 Mon Sep 17 00:00:00 2001 From: Joshua Kuestersteffen Date: Mon, 27 Jul 2026 22:20:07 -0500 Subject: [PATCH 41/41] Fix linting --- webapp/tests/karma/ts/services/enketo.service.spec.ts | 4 ---- webapp/tests/karma/ts/services/form.service.spec.ts | 10 ++++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/webapp/tests/karma/ts/services/enketo.service.spec.ts b/webapp/tests/karma/ts/services/enketo.service.spec.ts index 40f94772462..aca5caf9f32 100644 --- a/webapp/tests/karma/ts/services/enketo.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo.service.spec.ts @@ -377,10 +377,6 @@ describe('Enketo service', () => { const formContext = new WebappEnketoFormContext('#div', formConfig); formContext.contactSummary = { id: 'contact-summary', context: { pregnant: true } }; formContext.externalInstances = externalInstances; - const doc = { - html: $('
my form
'), - model: VISIT_MODEL_WITH_EXTERNAL_DATASET, - }; const userSettings = { language: 'en' }; return service.renderForm(formContext, userSettings).then(() => { expect(EnketoForm.callCount).to.equal(1); diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index cbf0fc0d022..86647d8b97d 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -570,7 +570,10 @@ describe('Form service', () => { const xmlDoc = new DOMParser() .parseFromString('a', 'text/xml'); customResourceService.getXml.withArgs('items.xml').returns(xmlDoc); - const formContext = new WebappEnketoFormContext('#div', mockFormConfig('myform', { model: VISIT_MODEL_WITH_EXTERNAL_DATASET })); + const formContext = new WebappEnketoFormContext( + '#div', + mockFormConfig('myform', { model: VISIT_MODEL_WITH_EXTERNAL_DATASET }) + ); await service.render(formContext); @@ -581,7 +584,10 @@ describe('Form service', () => { it('throws when resource is not found', async () => { setupRenderStubs(); customResourceService.getXml.withArgs('items.xml').returns(null); - const formContext = new WebappEnketoFormContext('#div', mockFormConfig('myform', { model: VISIT_MODEL_WITH_EXTERNAL_DATASET })); + const formContext = new WebappEnketoFormContext( + '#div', + mockFormConfig('myform', { model: VISIT_MODEL_WITH_EXTERNAL_DATASET }) + ); await expect(service.render(formContext)).to.be.rejectedWith('not found in resources'); });