diff --git a/Gruntfile.js b/Gruntfile.js index 3ebc1233fa1..cfff48044aa 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -274,7 +274,6 @@ module.exports = function(grunt) { 'webapp/src/ts/**/*.component.html', ]; const ignore = [ - 'webapp/src/ts/providers/xpath-element-path.provider.ts', 'webapp/src/js/bootstrap-tour-standalone.js', 'api/src/public/login/lib-bowser.js', 'api/extracted-resources/**/*', diff --git a/api/server.js b/api/server.js index b5f3e03f6e7..c50affbb77d 100644 --- a/api/server.js +++ b/api/server.js @@ -59,7 +59,7 @@ process const checkInstall = require('./src/services/setup/check-install'); const configWatcher = require('./src/services/config-watcher'); const migrations = require('./src/migrations'); - const generateXform = require('./src/services/generate-xform'); + const updateXform = require('./src/services/update-xform'); const serverUtils = require('./src/server-utils'); const generateServiceWorker = require('./src/generate-service-worker'); const manifest = require('./src/services/manifest'); @@ -92,7 +92,7 @@ process logger.info('Service worker generated successfully'); logger.info('Updating xforms…'); - await generateXform.updateAll(); + await updateXform.updateAll(); logger.info('xforms updated successfully'); } catch (err) { diff --git a/api/src/services/config-watcher.js b/api/src/services/config-watcher.js index 6c3e09a28dc..5949b8ba13a 100644 --- a/api/src/services/config-watcher.js +++ b/api/src/services/config-watcher.js @@ -5,7 +5,7 @@ const tombstoneUtils = require('@medic/tombstone-utils'); const viewMapUtils = require('@medic/view-map-utils'); const settingsService = require('./settings'); const translations = require('../translations'); -const generateXform = require('./generate-xform'); +const updateXform = require('./update-xform'); const generateServiceWorker = require('../generate-service-worker'); const manifest = require('./manifest'); const config = require('../config'); @@ -105,7 +105,7 @@ const handleFormChange = (change) => { return Promise.resolve(); } logger.info('Detected form change - generating attachments'); - return generateXform.update(change.id).catch(err => { + return updateXform.update(change.id).catch(err => { logger.error('Failed to update xform: %o', err); }); }; diff --git a/api/src/services/generate-xform.js b/api/src/services/generate-xform.js index f7396798dff..6bab1c9bd8d 100644 --- a/api/src/services/generate-xform.js +++ b/api/src/services/generate-xform.js @@ -3,20 +3,15 @@ * @module generate-xform */ const childProcess = require('child_process'); -const path = require('path'); const htmlParser = require('node-html-parser'); const logger = require('../logger'); -const db = require('../db'); -const formsService = require('./forms'); const markdown = require('../enketo-transformer/markdown'); +const { FORM_STYLESHEET, MODEL_STYLESHEET } = require('../xsl/xsl-paths'); const MODEL_ROOT_OPEN = ''; const ROOT_CLOSE = ''; const JAVAROSA_SRC = / src="jr:\/\//gi; const MEDIA_SRC_ATTR = ' data-media-src="'; - -const FORM_STYLESHEET = path.join(__dirname, '../xsl/openrosa2html5form.xsl'); -const MODEL_STYLESHEET = path.join(__dirname, '../enketo-transformer/xsl/openrosa2xmlmodel.xsl'); const XSLTPROC_CMD = 'xsltproc'; const processErrorHandler = (xsltproc, err, reject) => { @@ -161,109 +156,13 @@ const generateModel = formXml => { }); }; -const getEnketoForm = doc => { - const collect = doc.context && doc.context.collect; - return !collect && formsService.getXFormAttachment(doc); -}; - const generate = formXml => { return Promise.all([ generateForm(formXml), generateModel(formXml) ]) .then(([ form, model ]) => ({ form, model })); }; -const updateAttachment = (doc, updated, name, type) => { - const attachmentData = doc._attachments && - doc._attachments[name] && - doc._attachments[name].data && - doc._attachments[name].data.toString(); - if (attachmentData === updated) { - return false; - } - doc._attachments[name] = { - data: Buffer.from(updated), - content_type: type - }; - return true; -}; - -const updateAttachmentsIfRequired = (doc, updated) => { - const formUpdated = updateAttachment(doc, updated.form, 'form.html', 'text/html'); - const modelUpdated = updateAttachment(doc, updated.model, 'model.xml', 'text/xml'); - return formUpdated || modelUpdated; -}; - -const updateAttachments = (accumulator, doc) => { - return accumulator.then(results => { - const form = getEnketoForm(doc); - if (!form) { - results.push(null); // not an enketo form - no update required - return results; - } - logger.debug(`Generating html and xml model for enketo form "${doc._id}"`); - return module.exports.generate(form.data.toString()).then(result => { - results.push(result); - return results; - }); - }); -}; - -// Returns array of docs that need saving. -const updateAllAttachments = docs => { - // spawn the child processes in series so we don't smash the server - return docs.reduce(updateAttachments, Promise.resolve([])).then(results => { - return docs.filter((doc, i) => { - return results[i] && updateAttachmentsIfRequired(doc, results[i]); - }); - }); -}; - module.exports = { - /** - * Updates the model and form attachments of the given form if necessary. - * @param {string} docId - The db id of the doc defining the form. - */ - update: docId => { - return db.medic.get(docId, { attachments: true, binary: true }) - .then(doc => updateAllAttachments([ doc ])) - .then(docs => { - const doc = docs.length && docs[0]; - if (doc) { - logger.info(`Updating form with ID "${docId}"`); - return db.medic.put(doc); - } else { - logger.info(`Form with ID "${docId}" does not need to be updated.`); - } - }); - }, - - /** - * Updates the model and form attachments for all forms if necessary. - */ - updateAll: () => { - return formsService.getFormDocs() - .then(docs => { - if (!docs.length) { - return []; - } - return updateAllAttachments(docs); - }) - .then(toSave => { - logger.info(`Updating ${toSave.length} enketo form${toSave.length === 1 ? '' : 's'}`); - if (!toSave.length) { - return; - } - return db.medic.bulkDocs(toSave).then(results => { - const failures = results.filter(result => !result.ok); - if (failures.length) { - logger.error('Bulk save failed with: %o', failures); - throw new Error('Failed to save updated xforms to the database'); - } - }); - }); - - }, - /** * @param formXml The XML form string * @returns a promise with the XML form transformed following diff --git a/api/src/services/update-xform.js b/api/src/services/update-xform.js new file mode 100644 index 00000000000..6dcd1566abb --- /dev/null +++ b/api/src/services/update-xform.js @@ -0,0 +1,103 @@ +const db = require('../db'); +const logger = require('../logger'); +const formsService = require('./forms'); +const generatexFormService = require('./generate-xform'); + +const getEnketoForm = doc => { + const collect = doc.context && doc.context.collect; + return !collect && formsService.getXFormAttachment(doc); +}; + +const updateAttachment = (doc, updated, name, type) => { + const attachmentData = doc._attachments && + doc._attachments[name] && + doc._attachments[name].data && + doc._attachments[name].data.toString(); + if(attachmentData === updated) { + return false; + } + doc._attachments[name] = { + data: Buffer.from(updated), + content_type: type + }; + return true; +}; + +const updateAttachmentsIfRequired = (doc, updated) => { + const formUpdated = updateAttachment(doc, updated.form, 'form.html', 'text/html'); + const modelUpdated = updateAttachment(doc, updated.model, 'model.xml', 'text/xml'); + return formUpdated || modelUpdated; +}; + +const updateAttachments = (accumulator, doc) => { + return accumulator.then(results => { + const form = getEnketoForm(doc); + if(!form) { + results.push(null); // not an enketo form - no update required + return results; + } + logger.debug(`Generating html and xml model for enketo form "${doc._id}"`); + return generatexFormService.generate(form.data.toString()).then(result => { + results.push(result); + return results; + }); + }); +}; + +// Returns array of docs that need saving. +const updateAllAttachments = docs => { + // spawn the child processes in series so we don't smash the server + return docs.reduce(updateAttachments, Promise.resolve([])).then(results => { + return docs.filter((doc, i) => { + return results[i] && updateAttachmentsIfRequired(doc, results[i]); + }); + }); +}; + +module.exports = { + + /** + * Updates the model and form attachments of the given form if necessary. + * @param {string} docId - The db id of the doc defining the form. + */ + update: docId => { + return db.medic.get(docId, { attachments: true, binary: true }) + .then(doc => updateAllAttachments([doc])) + .then(docs => { + const doc = docs.length && docs[0]; + if(doc) { + logger.info(`Updating form with ID "${docId}"`); + return db.medic.put(doc); + } else { + logger.info(`Form with ID "${docId}" does not need to be updated.`); + } + }); + }, + + /** + * Updates the model and form attachments for all forms if necessary. + */ + updateAll: () => { + return formsService.getFormDocs() + .then(docs => { + if(!docs.length) { + return []; + } + return updateAllAttachments(docs); + }) + .then(toSave => { + logger.info(`Updating ${toSave.length} enketo form${toSave.length === 1 ? '' : 's'}`); + if(!toSave.length) { + return; + } + return db.medic.bulkDocs(toSave).then(results => { + const failures = results.filter(result => !result.ok); + if(failures.length) { + logger.error('Bulk save failed with: %o', failures); + throw new Error('Failed to save updated xforms to the database'); + } + }); + }); + + } +}; diff --git a/api/src/xsl/xsl-paths.js b/api/src/xsl/xsl-paths.js new file mode 100644 index 00000000000..1824b0da287 --- /dev/null +++ b/api/src/xsl/xsl-paths.js @@ -0,0 +1,6 @@ +const path = require('path'); + +module.exports = { + FORM_STYLESHEET: path.join(__dirname, './openrosa2html5form.xsl'), + MODEL_STYLESHEET: path.join(__dirname, '../enketo-transformer/xsl/openrosa2xmlmodel.xsl') +}; diff --git a/api/tests/mocha/services/config-watcher.spec.js b/api/tests/mocha/services/config-watcher.spec.js index 4d705816f90..803b6593c79 100644 --- a/api/tests/mocha/services/config-watcher.spec.js +++ b/api/tests/mocha/services/config-watcher.spec.js @@ -7,7 +7,7 @@ const db = require('../../../src/db'); const settingsService = require('../../../src/services/settings'); const translations = require('../../../src/translations'); const generateServiceWorker = require('../../../src/generate-service-worker'); -const generateXform = require('../../../src/services/generate-xform'); +const updateXform = require('../../../src/services/update-xform'); const config = require('../../../src/config'); const bootstrap = require('../../../src/services/config-watcher'); const manifest = require('../../../src/services/manifest'); @@ -248,35 +248,35 @@ describe('Configuration', () => { describe('form changes', () => { it('handles xform changes', () => { - sinon.stub(generateXform, 'update').resolves(); + sinon.stub(updateXform, 'update').resolves(); return emitChange({ id: 'form:something:something' }).then(() => { - chai.expect(generateXform.update.callCount).to.equal(1); - chai.expect(generateXform.update.args[0]).to.deep.equal(['form:something:something']); + chai.expect(updateXform.update.callCount).to.equal(1); + chai.expect(updateXform.update.args[0]).to.deep.equal(['form:something:something']); }); }); it('should not terminate the process on form gen errors', () => { - sinon.stub(generateXform, 'update').rejects(); + sinon.stub(updateXform, 'update').rejects(); return emitChange({ id: 'form:id' }).then(() => { - chai.expect(generateXform.update.callCount).to.equal(1); - chai.expect(generateXform.update.args[0]).to.deep.equal(['form:id']); + chai.expect(updateXform.update.callCount).to.equal(1); + chai.expect(updateXform.update.args[0]).to.deep.equal(['form:id']); }); }); it('should handle deletions gracefully - #7608', () => { - sinon.stub(generateXform, 'update'); + sinon.stub(updateXform, 'update'); return emitChange({ id: 'form:id', deleted: true }).then(() => { - chai.expect(generateXform.update.callCount).to.equal(0); + chai.expect(updateXform.update.callCount).to.equal(0); }); }); it('should ignore tombstones - #7608', () => { - sinon.stub(generateXform, 'update'); + sinon.stub(updateXform, 'update'); const tombstoneId = 'form:pnc_danger_sign_follow_up_mother____3-336f91959e14966f9baec1c3dd1c7fa2____tombstone'; return emitChange({ id: tombstoneId }).then(() => { - chai.expect(generateXform.update.callCount).to.equal(0); + chai.expect(updateXform.update.callCount).to.equal(0); }); }); }); diff --git a/api/tests/mocha/services/generate-xform.spec.js b/api/tests/mocha/services/generate-xform.spec.js index a77c55ccad1..dace5f8a3d5 100644 --- a/api/tests/mocha/services/generate-xform.spec.js +++ b/api/tests/mocha/services/generate-xform.spec.js @@ -6,7 +6,6 @@ const { assert, expect } = require('chai'); const sinon = require('sinon'); const childProcess = require('child_process'); const markdown = require('../../../src/enketo-transformer/markdown'); -const db = require('../../../src/db'); const service = require('../../../src/services/generate-xform'); const FILES = { @@ -17,15 +16,6 @@ const FILES = { expectedModel: 'model.expected.xml', }; -const expectAttachments = (doc, form, model) => { - const formAttachment = doc._attachments['form.html']; - expect(formAttachment.data.toString()).to.equal(form); - expect(formAttachment.content_type).to.equal('text/html'); - const modelAttachment = doc._attachments['model.xml']; - expect(modelAttachment.data.toString()).to.equal(model); - expect(modelAttachment.content_type).to.equal('text/xml'); -}; - afterEach(() => sinon.restore()); describe('generate-xform service', () => { @@ -250,218 +240,6 @@ describe('generate-xform service', () => { }); - describe('update', () => { - - it('should fail when no form found', done => { - sinon.stub(db.medic, 'get').rejects('boom'); - service.update('form:missing') - .then(() => done(new Error('expected error to be thrown'))) - .catch(err => { - expect(err.name).to.equal('boom'); - expect(db.medic.get.callCount).to.equal(1); - expect(db.medic.get.args[0][0]).to.equal('form:missing'); - done(); - }); - }); - - it('should do nothing when doc does not have form attachment', () => { - sinon.stub(db.medic, 'get').resolves({ _attachments: { image: {} } }); - sinon.stub(db.medic, 'put'); - return service.update('form:exists').then(() => { - expect(db.medic.get.callCount).to.equal(1); - expect(db.medic.put.callCount).to.equal(0); - }); - }); - - it('should do nothing when the attachments are up to date', () => { - const formXml = ''; - const currentForm = ''; - const currentModel = ''; - sinon.stub(db.medic, 'get').resolves({ _attachments: { - 'xform.xml': { data: Buffer.from(formXml) }, - 'form.html': { data: Buffer.from(currentForm) }, - 'model.xml': { data: Buffer.from(currentModel) } - } }); - sinon.stub(service, 'generate').resolves({ form: currentForm, model: currentModel }); - sinon.stub(db.medic, 'put'); - return service.update('form:exists').then(() => { - expect(service.generate.callCount).to.equal(1); - expect(service.generate.args[0][0]).to.equal(formXml); - expect(db.medic.put.callCount).to.equal(0); - }); - }); - - it('should update doc when attachments do not exist', () => { - const formXml = ''; - const newForm = 'Hello'; - const newModel = ''; - sinon.stub(db.medic, 'get').resolves({ _attachments: { - xml: { data: Buffer.from(formXml) } - } }); - sinon.stub(service, 'generate').resolves({ form: newForm, model: newModel }); - sinon.stub(db.medic, 'put'); - return service.update('form:exists').then(() => { - expect(db.medic.put.callCount).to.equal(1); - expectAttachments(db.medic.put.args[0][0], newForm, newModel); - }); - }); - - it('should update doc when attachments have changed', () => { - const formXml = ''; - const currentForm = ''; - const newForm = 'Hello'; - const currentModel = ''; - const newModel = ''; - sinon.stub(db.medic, 'get').resolves({ _attachments: { - 'xform.xml': { data: Buffer.from(formXml) }, - 'form.html': { data: Buffer.from(currentForm) }, - 'model.xml': { data: Buffer.from(currentModel) } - } }); - sinon.stub(service, 'generate').resolves({ form: newForm, model: newModel }); - sinon.stub(db.medic, 'put'); - return service.update('form:exists').then(() => { - expect(db.medic.put.callCount).to.equal(1); - expectAttachments(db.medic.put.args[0][0], newForm, newModel); - }); - }); - - }); - - describe('updateAll', () => { - - const JSON_FORM_ROW = { - doc: { - _id: 'a', - _attachments: { lmx: {} } - } - }; - const COLLECT_FORM_ROW = { - doc: { - _id: 'b', - _attachments: { xml: {} }, - context: { collect: true } - } - }; - - it('should handle no forms', () => { - sinon.stub(db.medic, 'query').resolves({ rows: [] }); - sinon.stub(db.medic, 'bulkDocs'); - return service.updateAll().then(() => { - expect(db.medic.query.callCount).to.equal(1); - expect(db.medic.bulkDocs.callCount).to.equal(0); - }); - }); - - it('should ignore json forms', () => { - sinon.stub(db.medic, 'query').resolves({ rows: [ JSON_FORM_ROW ] }); - sinon.stub(db.medic, 'bulkDocs'); - return service.updateAll().then(() => { - expect(db.medic.query.callCount).to.equal(1); - expect(db.medic.bulkDocs.callCount).to.equal(0); - }); - }); - - it('should ignore collect forms', () => { - sinon.stub(db.medic, 'query').resolves({ rows: [ COLLECT_FORM_ROW ] }); - sinon.stub(db.medic, 'bulkDocs'); - return service.updateAll().then(() => { - expect(db.medic.query.callCount).to.equal(1); - expect(db.medic.bulkDocs.callCount).to.equal(0); - }); - }); - - it('should do nothing when no forms have changed', () => { - const formXml = ''; - const currentForm = ''; - const currentModel = ''; - sinon.stub(db.medic, 'query').resolves({ rows: [ { - doc: { _attachments: { - 'xform.xml': { data: Buffer.from(formXml) }, - 'form.html': { data: Buffer.from(currentForm) }, - 'model.xml': { data: Buffer.from(currentModel) } - } } - } ] }); - sinon.stub(service, 'generate').resolves({ form: currentForm, model: currentModel }); - sinon.stub(db.medic, 'bulkDocs'); - return service.updateAll().then(() => { - expect(db.medic.query.callCount).to.equal(1); - expect(db.medic.bulkDocs.callCount).to.equal(0); - }); - }); - - it('should throw when not all updated successfully', done => { - const formXml = ''; - const currentForm = ''; - const newForm = 'Hello'; - const currentModel = ''; - const newModel = ''; - sinon.stub(db.medic, 'query').resolves({ rows: [ - { - doc: { - _id: 'd', - _attachments: { - 'xform.xml': { data: Buffer.from(formXml) }, - 'form.html': { data: Buffer.from(currentForm) }, - 'model.xml': { data: Buffer.from(currentModel) } - } - } - } - ] }); - sinon.stub(service, 'generate').resolves({ form: newForm, model: newModel }); - sinon.stub(db.medic, 'bulkDocs').resolves([ { error: 'some error' } ]); - service.updateAll() - .then(() => done(new Error('expected error to be thrown'))) - .catch(err => { - expect(err.message).to.equal('Failed to save updated xforms to the database'); - done(); - }); - }); - - it('should save all updated forms', () => { - const formXml = ''; - const currentForm = ''; - const newForm = 'Hello'; - const currentModel = ''; - const newModel = ''; - sinon.stub(db.medic, 'query').resolves({ rows: [ - JSON_FORM_ROW, - COLLECT_FORM_ROW, - { - doc: { - _id: 'c', - _attachments: { - 'xform.xml': { data: Buffer.from(formXml) }, - 'form.html': { data: Buffer.from(currentForm) }, - 'model.xml': { data: Buffer.from(currentModel) } - } - } - }, - { - doc: { - _id: 'd', - _attachments: { - 'xform.xml': { data: Buffer.from(formXml) }, - 'form.html': { data: Buffer.from(currentForm) }, - 'model.xml': { data: Buffer.from(currentModel) } - } - } - } - ] }); - sinon.stub(service, 'generate') - .onCall(0).resolves({ form: currentForm, model: currentModel }) - .onCall(1).resolves({ form: newForm, model: newModel }); - sinon.stub(db.medic, 'bulkDocs').resolves([ { ok: true } ]); - return service.updateAll().then(() => { - expect(db.medic.query.callCount).to.equal(1); - expect(db.medic.bulkDocs.callCount).to.equal(1); - expect(db.medic.bulkDocs.args[0][0].length).to.equal(1); - expect(db.medic.bulkDocs.args[0][0][0]._id).to.equal('d'); - expectAttachments(db.medic.bulkDocs.args[0][0][0], newForm, newModel); - }); - }); - - }); - describe('replaceAllMarkdown', () => { let replaceAllMarkdown; diff --git a/api/tests/mocha/services/update-xform.spec.js b/api/tests/mocha/services/update-xform.spec.js new file mode 100644 index 00000000000..8561f0e61f1 --- /dev/null +++ b/api/tests/mocha/services/update-xform.spec.js @@ -0,0 +1,245 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const db = require('../../../src/db'); +const service = require('../../../src/services/update-xform'); +const generatexFormService = require('../../../src/services/generate-xform'); + +const expectAttachments = (doc, form, model) => { + const formAttachment = doc._attachments['form.html']; + expect(formAttachment.data.toString()).to.equal(form); + expect(formAttachment.content_type).to.equal('text/html'); + const modelAttachment = doc._attachments['model.xml']; + expect(modelAttachment.data.toString()).to.equal(model); + expect(modelAttachment.content_type).to.equal('text/xml'); +}; + +afterEach(() => sinon.restore()); + +describe('generate-xform service', () => { + + describe('update', () => { + + it('should fail when no form found', done => { + sinon.stub(db.medic, 'get').rejects('boom'); + service.update('form:missing') + .then(() => done(new Error('expected error to be thrown'))) + .catch(err => { + expect(err.name).to.equal('boom'); + expect(db.medic.get.callCount).to.equal(1); + expect(db.medic.get.args[0][0]).to.equal('form:missing'); + done(); + }); + }); + + it('should do nothing when doc does not have form attachment', () => { + sinon.stub(db.medic, 'get').resolves({ _attachments: { image: {} } }); + sinon.stub(db.medic, 'put'); + return service.update('form:exists').then(() => { + expect(db.medic.get.callCount).to.equal(1); + expect(db.medic.put.callCount).to.equal(0); + }); + }); + + it('should do nothing when the attachments are up to date', () => { + const formXml = ''; + const currentForm = ''; + const currentModel = ''; + sinon.stub(db.medic, 'get').resolves({ + _attachments: { + 'xform.xml': { data: Buffer.from(formXml) }, + 'form.html': { data: Buffer.from(currentForm) }, + 'model.xml': { data: Buffer.from(currentModel) } + } + }); + sinon.stub(generatexFormService, 'generate').resolves({ form: currentForm, model: currentModel }); + sinon.stub(db.medic, 'put'); + return service.update('form:exists').then(() => { + expect(generatexFormService.generate.callCount).to.equal(1); + expect(generatexFormService.generate.args[0][0]).to.equal(formXml); + expect(db.medic.put.callCount).to.equal(0); + }); + }); + + it('should update doc when attachments do not exist', () => { + const formXml = ''; + const newForm = 'Hello'; + const newModel = ''; + sinon.stub(db.medic, 'get').resolves({ + _attachments: { + xml: { data: Buffer.from(formXml) } + } + }); + sinon.stub(generatexFormService, 'generate').resolves({ form: newForm, model: newModel }); + sinon.stub(db.medic, 'put'); + return service.update('form:exists').then(() => { + expect(db.medic.put.callCount).to.equal(1); + expectAttachments(db.medic.put.args[0][0], newForm, newModel); + }); + }); + + it('should update doc when attachments have changed', () => { + const formXml = ''; + const currentForm = ''; + const newForm = 'Hello'; + const currentModel = ''; + const newModel = ''; + sinon.stub(db.medic, 'get').resolves({ + _attachments: { + 'xform.xml': { data: Buffer.from(formXml) }, + 'form.html': { data: Buffer.from(currentForm) }, + 'model.xml': { data: Buffer.from(currentModel) } + } + }); + sinon.stub(generatexFormService, 'generate').resolves({ form: newForm, model: newModel }); + sinon.stub(db.medic, 'put'); + return service.update('form:exists').then(() => { + expect(db.medic.put.callCount).to.equal(1); + expectAttachments(db.medic.put.args[0][0], newForm, newModel); + }); + }); + + }); + + describe('updateAll', () => { + + const JSON_FORM_ROW = { + doc: { + _id: 'a', + _attachments: { lmx: {} } + } + }; + const COLLECT_FORM_ROW = { + doc: { + _id: 'b', + _attachments: { xml: {} }, + context: { collect: true } + } + }; + + it('should handle no forms', () => { + sinon.stub(db.medic, 'query').resolves({ rows: [] }); + sinon.stub(db.medic, 'bulkDocs'); + return service.updateAll().then(() => { + expect(db.medic.query.callCount).to.equal(1); + expect(db.medic.bulkDocs.callCount).to.equal(0); + }); + }); + + it('should ignore json forms', () => { + sinon.stub(db.medic, 'query').resolves({ rows: [JSON_FORM_ROW] }); + sinon.stub(db.medic, 'bulkDocs'); + return service.updateAll().then(() => { + expect(db.medic.query.callCount).to.equal(1); + expect(db.medic.bulkDocs.callCount).to.equal(0); + }); + }); + + it('should ignore collect forms', () => { + sinon.stub(db.medic, 'query').resolves({ rows: [COLLECT_FORM_ROW] }); + sinon.stub(db.medic, 'bulkDocs'); + return service.updateAll().then(() => { + expect(db.medic.query.callCount).to.equal(1); + expect(db.medic.bulkDocs.callCount).to.equal(0); + }); + }); + + it('should do nothing when no forms have changed', () => { + const formXml = ''; + const currentForm = ''; + const currentModel = ''; + sinon.stub(db.medic, 'query').resolves({ + rows: [{ + doc: { + _attachments: { + 'xform.xml': { data: Buffer.from(formXml) }, + 'form.html': { data: Buffer.from(currentForm) }, + 'model.xml': { data: Buffer.from(currentModel) } + } + } + }] + }); + sinon.stub(generatexFormService, 'generate').resolves({ form: currentForm, model: currentModel }); + sinon.stub(db.medic, 'bulkDocs'); + return service.updateAll().then(() => { + expect(db.medic.query.callCount).to.equal(1); + expect(db.medic.bulkDocs.callCount).to.equal(0); + }); + }); + + it('should throw when not all updated successfully', done => { + const formXml = ''; + const currentForm = ''; + const newForm = 'Hello'; + const currentModel = ''; + const newModel = ''; + sinon.stub(db.medic, 'query').resolves({ + rows: [ + { + doc: { + _id: 'd', + _attachments: { + 'xform.xml': { data: Buffer.from(formXml) }, + 'form.html': { data: Buffer.from(currentForm) }, + 'model.xml': { data: Buffer.from(currentModel) } + } + } + } + ] + }); + sinon.stub(generatexFormService, 'generate').resolves({ form: newForm, model: newModel }); + sinon.stub(db.medic, 'bulkDocs').resolves([{ error: 'some error' }]); + service.updateAll() + .then(() => done(new Error('expected error to be thrown'))) + .catch(err => { + expect(err.message).to.equal('Failed to save updated xforms to the database'); + done(); + }); + }); + + it('should save all updated forms', () => { + const formXml = ''; + const currentForm = ''; + const newForm = 'Hello'; + const currentModel = ''; + const newModel = ''; + sinon.stub(db.medic, 'query').resolves({ + rows: [ + JSON_FORM_ROW, + COLLECT_FORM_ROW, + { + doc: { + _id: 'c', + _attachments: { + 'xform.xml': { data: Buffer.from(formXml) }, + 'form.html': { data: Buffer.from(currentForm) }, + 'model.xml': { data: Buffer.from(currentModel) } + } + } + }, + { + doc: { + _id: 'd', + _attachments: { + 'xform.xml': { data: Buffer.from(formXml) }, + 'form.html': { data: Buffer.from(currentForm) }, + 'model.xml': { data: Buffer.from(currentModel) } + } + } + } + ] + }); + sinon.stub(generatexFormService, 'generate') + .onCall(0).resolves({ form: currentForm, model: currentModel }) + .onCall(1).resolves({ form: newForm, model: newModel }); + sinon.stub(db.medic, 'bulkDocs').resolves([{ ok: true }]); + return service.updateAll().then(() => { + expect(db.medic.query.callCount).to.equal(1); + expect(db.medic.bulkDocs.callCount).to.equal(1); + expect(db.medic.bulkDocs.args[0][0].length).to.equal(1); + expect(db.medic.bulkDocs.args[0][0][0]._id).to.equal('d'); + expectAttachments(db.medic.bulkDocs.args[0][0][0], newForm, newModel); + }); + }); + + }); +}); diff --git a/package.json b/package.json index b7189fb84ec..64c6d3283e6 100755 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "bundlesize": [ { "path": "./api/build/static/webapp/main.js", - "maxSize": "950 kB" + "maxSize": "955 kB" }, { "path": "./api/build/static/webapp/polyfills-es5.js", diff --git a/shared-libs/rules-engine/package-lock.json b/shared-libs/rules-engine/package-lock.json index 3b1d4445054..d212640de0a 100644 --- a/shared-libs/rules-engine/package-lock.json +++ b/shared-libs/rules-engine/package-lock.json @@ -14,6 +14,7 @@ "cht-nootils": "^4.0.2", "lodash": "4.17.19", "md5": "^2.3.0", + "moment": "^2.29.1", "nools": "^0.4.4" } }, @@ -25,6 +26,11 @@ "moment": "^2.29.1" } }, + "../calendar-interval/node_modules/moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, "../memdown": { "name": "@medic/memdown", "version": "1.0.0", @@ -79,6 +85,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" }, + "node_modules/camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -219,6 +233,14 @@ "is-buffer": "~1.1.6" } }, + "node_modules/moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "engines": { + "node": "*" + } + }, "node_modules/nools": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/nools/-/nools-0.4.4.tgz", @@ -308,25 +330,6 @@ "node": ">=0.4.0" } }, - "node_modules/uglify-js/node_modules/camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/uglify-js/node_modules/yargs": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz", - "integrity": "sha1-2K/49mXpTDS9JZvevRv68N3TU2E=", - "dependencies": { - "camelcase": "^1.0.2", - "decamelize": "^1.0.0", - "window-size": "0.1.0", - "wordwrap": "0.0.2" - } - }, "node_modules/uglify-to-browserify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", @@ -352,6 +355,17 @@ "engines": { "node": ">=0.4.0" } + }, + "node_modules/yargs": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz", + "integrity": "sha1-2K/49mXpTDS9JZvevRv68N3TU2E=", + "dependencies": { + "camelcase": "^1.0.2", + "decamelize": "^1.0.0", + "window-size": "0.1.0", + "wordwrap": "0.0.2" + } } }, "dependencies": { @@ -359,6 +373,13 @@ "version": "file:../calendar-interval", "requires": { "moment": "^2.29.1" + }, + "dependencies": { + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + } } }, "@medic/registration-utils": { @@ -396,6 +417,11 @@ "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=" + }, "charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -524,6 +550,11 @@ "is-buffer": "~1.1.6" } }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, "nools": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/nools/-/nools-0.4.4.tgz", @@ -596,24 +627,6 @@ "source-map": "0.1.34", "uglify-to-browserify": "~1.0.0", "yargs": "~3.5.4" - }, - "dependencies": { - "camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=" - }, - "yargs": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz", - "integrity": "sha1-2K/49mXpTDS9JZvevRv68N3TU2E=", - "requires": { - "camelcase": "^1.0.2", - "decamelize": "^1.0.0", - "window-size": "0.1.0", - "wordwrap": "0.0.2" - } - } } }, "uglify-to-browserify": { @@ -635,6 +648,17 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=" + }, + "yargs": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.5.4.tgz", + "integrity": "sha1-2K/49mXpTDS9JZvevRv68N3TU2E=", + "requires": { + "camelcase": "^1.0.2", + "decamelize": "^1.0.0", + "window-size": "0.1.0", + "wordwrap": "0.0.2" + } } } } diff --git a/shared-libs/rules-engine/package.json b/shared-libs/rules-engine/package.json index 4b4fc0a4451..c5a0e499df8 100644 --- a/shared-libs/rules-engine/package.json +++ b/shared-libs/rules-engine/package.json @@ -22,6 +22,7 @@ "@medic/registration-utils": "file:../registration-utils", "lodash": "4.17.19", "md5": "^2.3.0", + "moment": "^2.29.1", "cht-nootils": "^4.0.2", "nools": "^0.4.4" } diff --git a/webapp/src/js/enketo/contact-saver.js b/webapp/src/js/enketo/contact-saver.js new file mode 100644 index 00000000000..1bd0539988f --- /dev/null +++ b/webapp/src/js/enketo/contact-saver.js @@ -0,0 +1,212 @@ +const EnketoDataTranslator = require('./enketo-data-translator'); +const { defaults: _defaults, isObject: _isObject, reduce: _reduce } = require('lodash'); +const { v4: uuidV4 } = require('uuid'); + +const CONTACT_FIELD_NAMES = ['parent', 'contact']; + +// Prepares document to be bulk-saved at a later time, and for it to be +// referenced by _id by other docs if required. +const prepare = (doc) => { + if(!doc._id) { + doc._id = uuidV4(); + } + + if(!doc._rev) { + doc.reported_date = Date.now(); + } + + return doc; +}; + +const extractIfRequired = (extractLineageService, name, value) => { + return CONTACT_FIELD_NAMES.includes(name) ? extractLineageService.extract(value) : value; +}; + +const prepareNewSibling = (extractLineageService, doc, fieldName, siblings) => { + const preparedSibling = 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] = Object.assign({}, 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] = extractIfRequired(extractLineageService, fieldName, preparedSibling); + } + + return preparedSibling; +}; + +const getContact = (dbService, extractLineageService, doc, fieldName, contactId) => { + return dbService + .get() + .get(contactId) + .then((dbFieldValue) => { + // 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] = extractIfRequired(extractLineageService, 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. +const prepareAndAttachSiblingDocs = (extractLineageService, dbService, doc, original, siblings) => { + if(!doc._id) { + throw new Error('doc passed must already be prepared with an _id'); + } + + const preparedSiblings = []; + let promiseChain = Promise.resolve(); + + CONTACT_FIELD_NAMES.forEach(fieldName => { + let value = doc[fieldName]; + if(_isObject(value)) { + value = doc[fieldName]._id; + } + if(!value) { + return; + } + if(value === 'NEW') { + const preparedSibling = prepareNewSibling(extractLineageService, doc, fieldName, siblings); + preparedSiblings.push(preparedSibling); + } else if(original && original[fieldName] && original[fieldName]._id === value) { + doc[fieldName] = original[fieldName]; + } else { + promiseChain = promiseChain + .then(() => getContact(dbService, extractLineageService, doc, fieldName, value)); + } + }); + + return promiseChain.then(() => preparedSiblings); +}; + +const prepareRepeatedDocs = (extractLineageService, doc, repeated) => { + const childData = repeated ? repeated.child_data : []; + return childData.map(child => { + child.parent = extractLineageService.extract(doc); + return prepare(child); + }); +}; + +const prepareSubmittedDocsForSave = (contactServices, dbService, original, submitted, type) => { + if(original) { + _defaults(submitted.doc, original); + } else if(contactServices.contactTypes.isHardcodedType(type)) { + // default hierarchy - maintain backwards compatibility + submitted.doc.type = type; + } else { + // configured hierarchy + submitted.doc.type = 'contact'; + submitted.doc.contact_type = type; + } + + const doc = prepare(submitted.doc); + + return prepareAndAttachSiblingDocs( + contactServices.extractLineage, + dbService, + submitted.doc, + original, + submitted.siblings + ).then((siblings) => { + const extract = item => { + item.parent = item.parent && contactServices.extractLineage.extract(item.parent); + item.contact = item.contact && contactServices.extractLineage.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 = prepareRepeatedDocs(contactServices.extractLineage, submitted.doc, submitted.repeats); + + return { + docId: doc._id, + preparedDocs: [doc].concat(repeated, siblings) // NB: order matters: #4200 + }; + }); +}; + +const applyTransitions = (transitionsService, preparedDocs) => { + return transitionsService + .applyTransitions(preparedDocs.preparedDocs) + .then(updatedDocs => { + preparedDocs.preparedDocs = updatedDocs; + return preparedDocs; + }); +}; + +const generateFailureMessage = (bulkDocsResult) => { + return _reduce(bulkDocsResult, (msg, result) => { + let newMsg = msg; + if(!result.ok) { + if(!newMsg) { + newMsg = 'Some documents did not save correctly: '; + } + newMsg += result.id + ' with ' + result.message + '; '; + } + return newMsg; + }, null); +}; + +class ContactSaver { + constructor( + contactServices, + fileServices, + transitionsService + ) { + this.contactServices = contactServices; + this.fileServices = fileServices; + this.transitionsService = transitionsService; + } + + save(form, docId, type, xmlVersion) { + return (docId ? this.fileServices.db.get().get(docId) : Promise.resolve()) + .then(original => { + const submitted = EnketoDataTranslator.contactRecordToJs(form.getDataStr({ irrelevant: false })); + return prepareSubmittedDocsForSave( + this.contactServices, + this.fileServices.db, + original, + submitted, + type + ); + }) + .then((preparedDocs) => applyTransitions(this.transitionsService, preparedDocs)) + .then((preparedDocs) => { + if (xmlVersion) { + for (const doc of preparedDocs.preparedDocs) { + doc.form_version = xmlVersion; + } + } + return this.fileServices.db + .get() + .bulkDocs(preparedDocs.preparedDocs) + .then((bulkDocsResult) => { + const failureMessage = generateFailureMessage(bulkDocsResult); + + if(failureMessage) { + throw new Error(failureMessage); + } + + // TODO Not sure we need to return bulkDocsResult + return { docId: preparedDocs.docId, preparedDocs: preparedDocs.preparedDocs }; + }); + }); + } +} + +module.exports = ContactSaver; diff --git a/webapp/src/js/enketo/enketo-data-prepopulator.js b/webapp/src/js/enketo/enketo-data-prepopulator.js new file mode 100644 index 00000000000..a047ef68a6f --- /dev/null +++ b/webapp/src/js/enketo/enketo-data-prepopulator.js @@ -0,0 +1,41 @@ +const { isString: _isString } = require('lodash'); +const { bindJsonToXml } = require('./enketo-data-translator'); + +class EnketoDataPrepopulator { + constructor(userSettingsService, languageService) { + this.userSettingsService = userSettingsService; + this.languageService = languageService; + } + + get(model, data) { + if(data && _isString(data)) { + return Promise.resolve(data); + } + + return Promise.all([ + this.userSettingsService.get(), + this.languageService.get() + ]) + .then(([user, language]) => { + const xml = $($.parseXML(model)); + const bindRoot = xml.find('model instance').children().first(); + const userRoot = bindRoot.find('>inputs>user'); + + if(data) { + bindJsonToXml(bindRoot, data, (name) => { + // Either a direct child or a direct child of inputs + return '>%, >inputs>%'.replace(/%/g, name); + }); + } + + if(userRoot.length) { + const userObject = Object.assign({ language }, user); + bindJsonToXml(userRoot, userObject); + } + + return new XMLSerializer().serializeToString(bindRoot[0]); + }); + } +} + +module.exports = EnketoDataPrepopulator; diff --git a/webapp/src/js/enketo/enketo-data-translator.js b/webapp/src/js/enketo/enketo-data-translator.js new file mode 100644 index 00000000000..fbbe063fee5 --- /dev/null +++ b/webapp/src/js/enketo/enketo-data-translator.js @@ -0,0 +1,198 @@ +const withElements = (nodes) => { + return Array + .from(nodes) + .filter((node) => node.nodeType === Node.ELEMENT_NODE); +}; + +const nodesToJs = (data, repeatPaths, path) => { + repeatPaths = repeatPaths || []; + path = path || ''; + const result = {}; + withElements(data).forEach((n) => { + const typeAttribute = n.attributes.getNamedItem('type'); + const updatedPath = path + '/' + n.nodeName; + let value; + + const hasChildren = withElements(n.childNodes).length > 0; + if(hasChildren) { + value = 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; +}; + +const getHiddenFieldListRecursive = (nodes, prefix, current) => { + 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 = withElements(node.childNodes); + getHiddenFieldListRecursive(children, path + '.', current); + } + }); +}; + +const findCurrentElement = (elem, name, childMatcher) => { + if(childMatcher) { + const matcher = childMatcher(name); + const found = elem.find(matcher); + if(found.length > 1) { + // eslint-disable-next-line no-console + 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); +}; + +const findChildNode = (root, childNodeName) => { + return withElements(root.childNodes) + .find((node) => node.nodeName === childNodeName); +}; + +const repeatsToJs = (data) => { + const repeatNode = findChildNode(data, 'repeat'); + if(!repeatNode) { + return; + } + + const repeats = {}; + + withElements(repeatNode.childNodes).forEach((repeated) => { + const key = repeated.nodeName + '_data'; + if(!repeats[key]) { + repeats[key] = []; + } + repeats[key].push(nodesToJs(repeated.childNodes)); + }); + + return repeats; +}; + +const 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(); + bindJsonToXml(clone, dataEntry); + parent.append(clone); + }); + return; + } + + if (!elem.children().length) { + bindJsonToXml(elem, data._id); + } + + Object.keys(data).forEach((key) => { + const value = data[key]; + const current = findCurrentElement(elem, key, childMatcher); + bindJsonToXml(current, value); + }); +}; + +const getRepeatPaths = (formXml) => { + return $(formXml) + .find('repeat[nodeset]') + .map((idx, element) => { + return $(element).attr('nodeset'); + }) + .get(); +}; + +const reportRecordToJs = (record, formXml) => { + const root = $.parseXML(record).firstChild; + if(!formXml) { + return nodesToJs(root.childNodes); + } + const repeatPaths = getRepeatPaths(formXml); + return nodesToJs(root.childNodes, repeatPaths, '/' + root.nodeName); +}; + +module.exports = { + bindJsonToXml, + + getHiddenFieldList: (model, dbDocFields) => { + model = $.parseXML(model).firstChild; + if(!model) { + return; + } + const children = withElements(model.childNodes); + const fields = new Set(dbDocFields); + getHiddenFieldListRecursive(children, '', fields); + return [...fields]; + }, + + getRepeatPaths, + + reportRecordToJs, + + /* + * 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 = { + doc: null, + siblings: {}, + }; + + const repeats = repeatsToJs(root); + if (repeats) { + result.repeats = repeats; + } + + const NODE_NAMES_TO_IGNORE = ['meta', 'inputs', 'repeat']; + + withElements(root.childNodes) + .filter((node) => !NODE_NAMES_TO_IGNORE.includes(node.nodeName) && node.childElementCount > 0) + .forEach((child) => { + if (!result.doc) { + // First child is the main result, rest are siblings + result.doc = nodesToJs(child.childNodes); + return; + } + result.siblings[child.nodeName] = nodesToJs(child.childNodes); + }); + + return result; + } +}; diff --git a/webapp/src/js/enketo/enketo-form-manager.js b/webapp/src/js/enketo/enketo-form-manager.js new file mode 100644 index 00000000000..9af1ad40b75 --- /dev/null +++ b/webapp/src/js/enketo/enketo-form-manager.js @@ -0,0 +1,863 @@ +const uuid = require('uuid').v4; +const pojo2xml = require('pojo2xml'); +const $ = require('jquery'); +const { getElementXPath } = require('./xpath-element-path'); +const enketoConstants = require('./constants'); +const EnketoDataPrepopulator = require('./enketo-data-prepopulator'); +const EnketoDataTranslator = require('./enketo-data-translator'); +const ContactSaver = require('./contact-saver'); + +const HTML_ATTACHMENT_NAME = 'form.html'; +const MODEL_ATTACHMENT_NAME = 'model.xml'; + +const getUserContact = (userContactService) => { + return userContactService + .get() + .then((contact) => { + if(!contact) { + const err = new Error('Your user does not have an associated contact, or does not have access to the ' + + 'associated contact. Talk to your administrator to correct this.'); + err.translationKey = 'error.loading.form.no_contact'; + throw err; + } + return contact; + }); +}; + +const getAttachment = (fileServices, id, name) => { + return fileServices.db + .get() + .getAttachment(id, name) + .then(blob => fileServices.fileReader.utf8(blob)); +}; + +const transformXml = (fileServices, translateService, form) => { + return Promise + .all([ + getAttachment(fileServices, form._id, HTML_ATTACHMENT_NAME), + getAttachment(fileServices, form._id, MODEL_ATTACHMENT_NAME) + ]) + .then(([html, model]) => { + const $html = $(html); + $html.find('[data-i18n]').each((idx, element) => { + const $element = $(element); + $element.text(translateService.instant('enketo.' + $element.attr('data-i18n'))); + }); + + const hasContactSummary = $(model).find('> instance[id="contact-summary"]').length === 1; + return { + html: $html, + model: model, + title: form.title, + hasContactSummary: hasContactSummary + }; + }); +}; + +const replaceJavarosaMediaWithLoaders = (formDoc, formHtml) => { + formHtml.find('[data-media-src]').each((idx, element) => { + const $img = $(element); + const lang = $img.attr('lang'); + const active = $img.is('.active') ? 'active' : ''; + $img + .css('visibility', 'hidden') + .wrap(() => '
'); + }); +}; + +const getContactReports = (searchService, contact) => { + const subjectIds = [contact._id]; + const shortCode = contact.patient_id || contact.place_id; + if(shortCode) { + subjectIds.push(shortCode); + } + return searchService.search('reports', { subjectIds: subjectIds }, { include_docs: true }); +}; + +const getLineage = (lineageModelGeneratorService, contact) => { + return lineageModelGeneratorService + .contact(contact._id) + .then((model) => model.lineage) + .catch((err) => { + if(err.code === 404) { + // eslint-disable-next-line no-console + console.warn(`Enketo failed to get lineage of contact '${contact._id}' because document does not exist`, err); + return []; + } + + throw err; + }); +}; + +const getContactSummary = (formDataServices, doc, instanceData) => { + const contact = instanceData && instanceData.contact; + if(!doc.hasContactSummary || !contact) { + return Promise.resolve(); + } + return Promise + .all([ + getContactReports(formDataServices.search, contact), + getLineage(formDataServices.lineageModelGenerator, contact) + ]) + .then(([reports, lineage]) => { + return formDataServices.contactSummary.get(contact, reports, lineage); + }) + .then((summary) => { + if(!summary) { + return; + } + + try { + const xmlStr = pojo2xml({ context: summary.context }); + return { + id: 'contact-summary', + xml: new DOMParser().parseFromString(xmlStr, 'text/xml') + }; + } catch(e) { + // eslint-disable-next-line no-console + console.error('Error while converting app_summary.contact_summary.context to xml.'); + throw new Error('contact_summary context is misconfigured'); + } + }); +}; + +const getEnketoForm = (formDataServices, wrapper, doc, instanceData) => { + return Promise + .all([ + formDataServices.enketoDataPrepopulator.get(doc.model, instanceData), + getContactSummary(formDataServices, doc, instanceData), + formDataServices.language.get() + ]) + .then(([instanceStr, contactSummary, language]) => { + const data = { + modelStr: doc.model, + instanceStr: instanceStr + }; + if(contactSummary) { + data.external = [contactSummary]; + } + const form = wrapper.find('form')[0]; + return new window.EnketoForm(form, data, { language }); + }); +}; + +const getFormTitle = (translationServices, titleKey, doc) => { + if(titleKey) { + // using translation key + return translationServices.translate.get(titleKey); + } + + if(doc.title) { + // title defined in the doc + return Promise.resolve(translationServices.translateFrom.get(doc.title)); + } +}; + +const setFormTitle = (wrapper, title) => { + // manually translate the title as enketo-core doesn't have any way to do this + // https://github.com/enketo/enketo-core/issues/405 + const $title = wrapper.find('#form-title'); + if(title) { + // overwrite contents + $title.text(title); + } else if($title.text() === 'No Title') { + // useless enketo default - remove it + $title.remove(); + } // else the title is hardcoded in the form definition - leave it alone +}; + +// eslint-disable-next-line func-style +function handleKeypressOnInputField(e) { + // Here we capture both CR and TAB characters, and handle field-skipping + if(!window.medicmobile_android || (e.keyCode !== 9 && e.keyCode !== 13)) { + return; + } + + const $input = $(this); + + // stop the keypress from being handled elsewhere + e.preventDefault(); + + const $thisQuestion = $input.closest('.question'); + + // If there's another question on the current page, focus on that + if($thisQuestion.attr('role') !== 'page') { + const $nextQuestion = $thisQuestion.find( + '~ .question:not(.disabled):not(.or-appearance-hidden), ~ .repeat-buttons button.repeat:not(:disabled)' + ); + if($nextQuestion.length) { + if($nextQuestion[0].tagName !== 'LABEL') { + // The next question is something complicated, so we can't just + // focus on it. Next best thing is to blur the current selection + // so the on-screen keyboard closes. + $input.trigger('blur'); + } else { + // Delay focussing on the next field, so that keybaord close and + // open events both register. This should mean that the on-screen + // keyboard is maintained between fields. + setTimeout(() => { + $nextQuestion.first().trigger('focus'); + }, 10); + } + return; + } + } + + // Trigger the change listener on the current field to update the enketo + // model + $input.trigger('change'); + + const enketoContainer = $thisQuestion.closest('.enketo'); + + // If there's no question on the current page, try to go to change page, + // or submit the form. + const next = enketoContainer.find('.btn.next-page:enabled:not(.disabled)'); + if(next.length) { + next.trigger('click'); + } else { + enketoContainer.find('.btn.submit').trigger('click'); + } +} + +const setupNavButtons = (currentForm, $wrapper, currentIndex) => { + if(!currentForm.pages) { + return; + } + const lastIndex = currentForm.pages.activePages.length - 1; + const footer = $wrapper.find('.form-footer'); + footer.removeClass('end'); + footer.find('.previous-page, .next-page').removeClass('disabled'); + + if(currentIndex >= lastIndex) { + footer.addClass('end'); + footer.find('.next-page').addClass('disabled'); + } + if(currentIndex <= 0) { + footer.find('.previous-page').addClass('disabled'); + } +}; + +const forceRecalculate = (form) => { + // Work-around for stale jr:choice-name() references in labels. ref #3870 + form.calc.update(); + + // Force forms to update jr:itext references in output fields that contain + // calculated values. ref #4111 + form.output.update(); +}; + +const overrideNavigationButtons = (form, $wrapper) => { + $wrapper + .find('.btn.next-page') + .off('.pagemode') + .on('click.pagemode', () => { + form.pages + ._next() + .then(valid => { + if(valid) { + const currentIndex = form.pages._getCurrentIndex(); + window.history.pushState({ enketo_page_number: currentIndex }, ''); + setupNavButtons(form, $wrapper, currentIndex); + pauseMultimedia($wrapper); + } + forceRecalculate(form); + }); + return false; + }); + + $wrapper + .find('.btn.previous-page') + .off('.pagemode') + .on('click.pagemode', () => { + window.history.back(); + setupNavButtons(form, $wrapper, form.pages._getCurrentIndex() - 1); + forceRecalculate(form); + pauseMultimedia($wrapper); + return false; + }); +}; + +// This code can be removed once this issue is fixed: https://github.com/enketo/enketo-core/issues/816 +const pauseMultimedia = ($wrapper) => { + $wrapper + .find('audio, video') + .each((idx, element) => element.pause()); +}; + +const addPopStateHandler = (form, $wrapper) => { + $(window).on('popstate.enketo-pagemode', (event) => { + if(event.originalEvent && + event.originalEvent.state && + typeof event.originalEvent.state.enketo_page_number === 'number' && + $wrapper.find('.container').not(':empty')) { + + const targetPage = event.originalEvent.state.enketo_page_number; + const pages = form.pages; + const currentIndex = pages._getCurrentIndex(); + if(targetPage > currentIndex) { + pages._next(); + } else { + pages._prev(); + } + } + }); +}; + +const renderFromXmls = (formDataServices, translationServices, xmlFormContext) => { + const { doc, instanceData, titleKey, wrapper } = xmlFormContext; + + const formContainer = wrapper.find('.container').first(); + formContainer.html(doc.html.get(0)); + + return Promise.all([ + getEnketoForm(formDataServices, wrapper, doc, instanceData).then((form) => { + const loadErrors = form.init(); + if(loadErrors && loadErrors.length) { + return Promise.reject(new Error(JSON.stringify(loadErrors))); + } + return form; + }), + getFormTitle(translationServices, titleKey, doc) + ]).then(([form, title]) => { + setFormTitle(wrapper, title); + wrapper.show(); + + wrapper.find('input').on('keydown', handleKeypressOnInputField); + + // handle page turning using browser history + window.history.replaceState({ enketo_page_number: 0 }, ''); + overrideNavigationButtons(form, wrapper); + addPopStateHandler(form, wrapper); + forceRecalculate(form); + setupNavButtons(form, wrapper, 0); + return form; + }); +}; + +const update = (dbService, 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. + return dbService.get().get(docId).then((doc) => { + // 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? + delete doc.content; + return doc; + }); +}; + +const create = (contactServices, formInternalId) => { + return getUserContact(contactServices.userContact).then((contact) => { + return { + form: formInternalId, + type: 'data_record', + content_type: 'xml', + reported_date: Date.now(), + contact: contactServices.extractLineage.extract(contact), + from: contact && contact.phone + }; + }); +}; + +const xmlToDocs = (xmlServices, doc, formXml, xmlVersion, record) => { + const recordDoc = $.parseXML(record); + const $record = $($(recordDoc).children()[0]); + const repeatPaths = EnketoDataTranslator.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, window.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(); + + if(repeatPaths) { + 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) { + // eslint-disable-next-line no-console + 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 = []; + $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 = EnketoDataTranslator.reportRecordToJs(getOuterHTML(element)); + docToStore._id = getId(getElementXPath(element)); + docToStore.reported_date = Date.now(); + return docToStore; + }) + .get(); + + doc._id = getId('/*'); + if (xmlVersion) { + doc.form_version = xmlVersion; + } + doc.hidden_fields = EnketoDataTranslator.getHiddenFieldList(record, dbDocTags); + + const attach = (elem, file, type, alreadyEncoded, xpath) => { + 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)); + xmlServices.addAttachment.add(doc, filename, file, type, alreadyEncoded); + }; + + $record + .find('[type=file]') + .each((idx, element) => { + const xpath = getElementXPath(element); + const $input = $('input[type=file][name="' + xpath + '"]'); + const file = $input[0].files[0]; + if(file) { + attach(element, file, file.type, false, xpath); + } + }); + + $record + .find('[type=binary]') + .each((idx, element) => { + const file = $(element).text(); + if(file) { + $(element).text(''); + attach(element, file, 'image/png', true); + } + }); + + record = getOuterHTML($record[0]); + + // remove old style content attachment + xmlServices.addAttachment.remove(doc, xmlServices.getReportContentService.REPORT_ATTACHMENT_NAME); + + docsToStore.unshift(doc); + + doc.fields = EnketoDataTranslator.reportRecordToJs(record, formXml); + return docsToStore; +}; + +const validateAttachments = (docs, translateService, globalActions) => { + const oversizeDoc = docs.find(doc => { + let attachmentsSize = 0; + + if(doc._attachments) { + Object + .keys(doc._attachments) + .forEach(name => { + // It can be Base64 (binary) or object (file) + const data = doc._attachments[name] ? doc._attachments[name].data : null; + const getSize = data => data && data.size ? data.size : 0; + const size = typeof data === 'string' ? data.length : getSize(data); + attachmentsSize += size; + }); + } + + return attachmentsSize > enketoConstants.maxAttachmentSize; + }); + + return new Promise((resolve, reject) => { + if(oversizeDoc) { + translateService.get('enketo.error.max_attachment_size') + .then(errorMessage => { + globalActions.setSnackbarContent(errorMessage); + reject(new Error(errorMessage)); + }); + } else { + resolve(docs); + } + }); +}; + +const saveGeo = (geoHandle, docs) => { + if(!geoHandle) { + return docs; + } + + return geoHandle() + .catch(err => err) + .then(geoData => { + docs.forEach(doc => { + doc.geolocation_log = doc.geolocation_log || []; + doc.geolocation_log.push({ + timestamp: Date.now(), + recording: geoData + }); + doc.geolocation = geoData; + }); + return docs; + }); +}; + +const saveDocs = (dbService, docs) => { + return dbService + .get() + .bulkDocs(docs) + .then((results) => { + results.forEach((result) => { + if(result.error) { + // eslint-disable-next-line no-console + console.error('Error saving report', result); + throw new Error('Error saving report'); + } + const idx = docs.findIndex(doc => doc._id === result.id); + docs[idx] = Object.assign({}, docs[idx]); + docs[idx]._rev = result.rev; + }); + return docs; + }); +}; + +class ContactServices { + constructor(extractLineageService, userContactService, contactTypesService) { + this.extractLineageService = extractLineageService; + this.userContactService = userContactService; + this.contactTypesService = contactTypesService; + } + + get extractLineage() { + return this.extractLineageService; + } + + get userContact() { + return this.userContactService; + } + + get contactTypes() { + return this.contactTypesService; + } +} + +class FileServices { + constructor(dbService, fileReaderService) { + this.dbService = dbService; + this.fileReaderService = fileReaderService; + } + + get db() { + return this.dbService; + } + + get fileReader() { + return this.fileReaderService; + } +} + +class FormDataServices { + constructor( + contactSummaryService, + userSettingsService, + languageService, + lineageModelGeneratorService, + searchService + ) { + this.contactSummaryService = contactSummaryService; + this.enketoDataPrepopulatorService = new EnketoDataPrepopulator(userSettingsService, languageService); + this.languageService = languageService; + this.searchService = searchService; + this.lineageModelGeneratorService = lineageModelGeneratorService; + } + + get contactSummary() { + return this.contactSummaryService; + } + + get enketoDataPrepopulator() { + return this.enketoDataPrepopulatorService; + } + + get language() { + return this.languageService; + } + + get search() { + return this.searchService; + } + + get lineageModelGenerator() { + return this.lineageModelGeneratorService; + } +} + +class TranslationServices { + constructor(translateService, translateFromService) { + this.translateService = translateService; + this.translateFromService = translateFromService; + } + + get translate() { + return this.translateService; + } + + get translateFrom() { + return this.translateFromService; + } +} + +class XmlServices { + constructor(addAttachmentService, getReportContentService, xmlFormsService) { + this.addAttachmentService = addAttachmentService; + this.getReportContentService = getReportContentService; + this.xmlFormsService = xmlFormsService; + } + + get addAttachment() { + return this.addAttachmentService; + } + + get getReportContent() { + return this.getReportContentService; + } + + get xmlForms() { + return this.xmlFormsService; + } +} + +class EnketoFormManager { + constructor( + contactServices, + fileServices, + formDataServices, + translationServices, + xmlServices, + transitionsService, + globalActions + ) { + this.contactServices = contactServices; + this.fileServices = fileServices; + this.formDataServices = formDataServices; + this.translationServices = translationServices; + this.xmlServices = xmlServices; + this.transitionsService = transitionsService; + this.globalActions = globalActions; + + this.contactSaver = new ContactSaver(contactServices, fileServices, transitionsService); + + this.currentForm = null; + this.objUrls = []; + } + + getCurrentForm() { + return this.currentForm; + } + + render(selector, form, instanceData) { + return getUserContact(this.contactServices.userContact).then(() => { + const formContext = { + selector, + formDoc: form, + instanceData, + }; + + return this._renderForm(formContext); + }); + } + + renderContactForm(formContext) { + return this._renderForm(formContext); + } + + validate(form) { + return Promise + .resolve(form.validate()) + .then((valid) => { + if (!valid) { + throw new Error('Form is invalid'); + } + }); + } + + save(formInternalId, form, geoHandle, docId) { + const getDocPromise = docId ? update(this.fileServices.db, docId) : + create(this.contactServices, formInternalId); + + return Promise + .all([ + getDocPromise, + this.xmlServices.xmlForms.getDocAndFormAttachment(formInternalId) + ]) + .then(([doc, formDoc]) => { + const dataString = form.getDataStr({ irrelevant: false }); + return xmlToDocs(this.xmlServices, doc, formDoc.xml, formDoc.doc.xmlVersion, dataString); + }) + .then(docs => validateAttachments(docs, this.translationServices.translateService, this.globalActions)) + .then((docs) => saveGeo(geoHandle, docs)) + .then((docs) => this.transitionsService.applyTransitions(docs)) + .then((docs) => saveDocs(this.fileServices.db, docs)); + } + + saveContactForm(form, docId, type, xmlVersion) { + return this.contactSaver.save(form, docId, type, xmlVersion); + } + + unload(form) { + $(window).off('.enketo-pagemode'); + if(form) { + form.resetView(); + } + // unload blobs + this.objUrls.forEach((url) => { + (window.URL || window.webkitURL).revokeObjectURL(url); + }); + + delete window.CHTCore.debugFormModel; + delete this.currentForm; + this.objUrls.length = 0; + } + + setupNavButtons(currentForm, $wrapper, currentIndex) { + setupNavButtons(currentForm, $wrapper, currentIndex); + } + + _renderForm(formContext) { + const { + formDoc, + instanceData, + selector, + titleKey, + } = formContext; + + const $selector = $(selector); + return transformXml(this.fileServices, this.translationServices.translate, formDoc).then(doc => { + replaceJavarosaMediaWithLoaders(formDoc, doc.html); + const xmlFormContext = { + doc, + wrapper: $selector, + instanceData, + titleKey, + }; + return renderFromXmls(this.formDataServices, this.translationServices, xmlFormContext); + }).then((form) => { + this.currentForm = form; + const formContainer = $selector.find('.container').first(); + const replaceMediaLoaders = (formContainer, formDoc) => { + formContainer.find('[data-media-src]').each((idx, element) => { + const elem = $(element); + const src = elem.attr('data-media-src'); + this.fileServices.db + .get() + .getAttachment(formDoc._id, src) + .then((blob) => { + const objUrl = (window.URL || window.webkitURL).createObjectURL(blob); + this.objUrls.push(objUrl); + elem + .attr('src', objUrl) + .css('visibility', '') + .unwrap(); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('Error fetching media file', formDoc._id, src, err); + elem.closest('.loader').hide(); + }); + }); + }; + + replaceMediaLoaders(formContainer, formDoc); + + $selector.on('addrepeat', (ev) => { + setTimeout(() => { // timeout to allow enketo to finish first + replaceMediaLoaders($(ev.target), formDoc); + }); + }); + + window.CHTCore.debugFormModel = () => form.model.getStr(); + return form; + }); + } +} + +module.exports = { + ContactServices, + FileServices, + FormDataServices, + TranslationServices, + XmlServices, + EnketoFormManager +}; diff --git a/webapp/src/js/enketo/widgets/countdown-widget.js b/webapp/src/js/enketo/widgets/countdown-widget.js index 3b91dd2f57f..581efa51d09 100644 --- a/webapp/src/js/enketo/widgets/countdown-widget.js +++ b/webapp/src/js/enketo/widgets/countdown-widget.js @@ -63,14 +63,15 @@ const TimerAnimation = function(canvas, canvasW, canvasH, duration) { const androidSoundSupport = window.medicmobile_android && typeof window.medicmobile_android.playAlert === 'function'; - if(!androidSoundSupport) { - cached = loadSound(); - } const loadSound = () => { return new Audio('/audio/alert.mp3'); }; + if(!androidSoundSupport) { + cached = loadSound(); + } + return { play: function() { if(androidSoundSupport) { diff --git a/webapp/src/js/enketo/xpath-element-path.js b/webapp/src/js/enketo/xpath-element-path.js new file mode 100644 index 00000000000..28f51fbb803 --- /dev/null +++ b/webapp/src/js/enketo/xpath-element-path.js @@ -0,0 +1,57 @@ +/* + * Simple module for calculating XPath of an element using the browser's built- + * in XML support. + * + * Copyright (c) 2009, Mozilla Foundation + * + * Taken from Firebug, licensed under BSD: + * https://github.com/firebug/firebug/blob/master/extension/content/firebug/lib/xpath.js + */ + +/** + * Gets an XPath for an element which describes its hierarchical location. + */ +const getElementXPath = function(element) { + if(element && element.id) { + return '//*[@id="' + element.id + '"]'; + } else { + return getElementTreeXPath(element); + } +}; + +const getElementTreeXPath = function(element) { + const paths = []; + + // Use nodeName (instead of localName) so namespace prefix is included (if any). + for(; element && element.nodeType === Node.ELEMENT_NODE; element = element.parentNode) { + let index = 0; + let hasFollowingSiblings = false; + for(let sibling = element.previousSibling; sibling; sibling = sibling.previousSibling) { + // Ignore document type declaration. + if(sibling.nodeType === Node.DOCUMENT_TYPE_NODE) { + continue; + } + + if(sibling.nodeName === element.nodeName) { + ++index; + } + } + + for(let sibling = element.nextSibling; sibling && !hasFollowingSiblings; + sibling = sibling.nextSibling) { + if(sibling.nodeName === element.nodeName) { + hasFollowingSiblings = true; + } + } + + const tagName = (element.prefix ? element.prefix + ':' : '') + element.localName; + const pathIndex = (index || hasFollowingSiblings ? '[' + (index + 1) + ']' : ''); + paths.splice(0, 0, tagName + pathIndex); + } + + return paths.length ? '/' + paths.join('/') : null; +}; + +module.exports = { + getElementXPath +}; diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 4450750df76..2d808a8a195 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -8,7 +8,6 @@ import { LineageModelGeneratorService } from '@mm-services/lineage-model-generat import { EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; import { Selectors } from '@mm-selectors/index'; import { GlobalActions } from '@mm-actions/global'; import { ContactsActions } from '@mm-actions/contacts'; @@ -27,7 +26,6 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private enketoService:EnketoService, private contactTypesService:ContactTypesService, private dbService:DbService, - private contactSaveService:ContactSaveService, private translateService:TranslateService, ) { this.globalActions = new GlobalActions(store); @@ -288,8 +286,8 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { // Updating fields before save. Ref: #6670. $('form.or').trigger('beforesave'); - return this.contactSaveService - .save(form, docId, this.enketoContact.type, this.xmlVersion) + return this.enketoService + .saveContactForm(form, docId, this.enketoContact.type, this.xmlVersion) .then((result) => { console.debug('saved contact', result); diff --git a/webapp/src/ts/providers/xpath-element-path.provider.ts b/webapp/src/ts/providers/xpath-element-path.provider.ts deleted file mode 100644 index ad02d45065c..00000000000 --- a/webapp/src/ts/providers/xpath-element-path.provider.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Simple module for calculating XPath of an element using the browser's built- - * in XML support. - * - * Copyright (c) 2009, Mozilla Foundation - * - * Taken from Firebug, licensed under BSD: - * https://github.com/firebug/firebug/blob/master/extension/content/firebug/lib/xpath.js - */ - -export const Xpath:any = {}; - -// ********************************************************************************************* // -// XPATH - -/** - * Gets an XPath for an element which describes its hierarchical location. - */ -Xpath.getElementXPath = function(element) -{ - if (element && element.id) - return '//*[@id="' + element.id + '"]'; - else - return Xpath.getElementTreeXPath(element); -}; - -Xpath.getElementTreeXPath = function(element) -{ - var paths = []; - - // Use nodeName (instead of localName) so namespace prefix is included (if any). - for (; element && element.nodeType == Node.ELEMENT_NODE; element = element.parentNode) - { - var index = 0; - var hasFollowingSiblings = false; - for (var sibling = element.previousSibling; sibling; sibling = sibling.previousSibling) - { - // Ignore document type declaration. - if (sibling.nodeType == Node.DOCUMENT_TYPE_NODE) - continue; - - if (sibling.nodeName == element.nodeName) - ++index; - } - - for (var sibling = element.nextSibling; sibling && !hasFollowingSiblings; - sibling = sibling.nextSibling) - { - if (sibling.nodeName == element.nodeName) - hasFollowingSiblings = true; - } - - var tagName = (element.prefix ? element.prefix + ":" : "") + element.localName; - var pathIndex = (index || hasFollowingSiblings ? "[" + (index + 1) + "]" : ""); - paths.splice(0, 0, tagName + pathIndex); - } - - return paths.length ? "/" + paths.join("/") : null; -}; 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 6289974d7a7..00000000000 --- a/webapp/src/ts/services/contact-save.service.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { v4 as uuidV4 } from 'uuid'; -import { Injectable, NgZone } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { reduce as _reduce, isObject as _isObject, defaults as _defaults } from 'lodash-es'; - -import { DbService } from '@mm-services/db.service'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { ServicesActions } from '@mm-actions/services'; -import { ContactTypesService } from '@mm-services/contact-types.service'; -import { TransitionsService } from '@mm-services/transitions.service'; - -@Injectable({ - providedIn: 'root' -}) -export class ContactSaveService { - private servicesActions; - private readonly CONTACT_FIELD_NAMES = [ 'parent', 'contact' ]; - - constructor( - private store:Store, - private contactTypesService:ContactTypesService, - private dbService:DbService, - private enketoTranslationService:EnketoTranslationService, - private extractLineageService:ExtractLineageService, - private transitionsService:TransitionsService, - private ngZone:NgZone, - ) { - this.servicesActions = new ServicesActions(store); - } - - private generateFailureMessage(bulkDocsResult) { - return _reduce(bulkDocsResult, (msg, result) => { - let newMsg = msg; - if (!result.ok) { - if (!newMsg) { - newMsg = 'Some documents did not save correctly: '; - } - newMsg += result.id + ' with ' + result.message + '; '; - } - return newMsg; - }, null); - } - - private prepareSubmittedDocsForSave(original, submitted, type) { - if (original) { - _defaults(submitted.doc, original); - } else if (this.contactTypesService.isHardcodedType(type)) { - // default hierarchy - maintain backwards compatibility - submitted.doc.type = type; - } else { - // configured hierarchy - submitted.doc.type = 'contact'; - submitted.doc.contact_type = type; - } - - 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); - - return { - docId: doc._id, - preparedDocs: [ doc ].concat(repeated, siblings) // NB: order matters: #4200 - }; - }); - } - - // 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) { - if (!doc._id) { - doc._id = uuidV4(); - } - - 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); - }); - } - - 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 getContact(doc, fieldName, contactId) { - return this.dbService - .get() - .get(contactId) - .then((dbFieldValue) => { - // 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 = []; - 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); - } - - save(form, docId, type, xmlVersion) { - return this.ngZone.runOutsideAngular(() => { - return (docId ? this.dbService.get().get(docId) : Promise.resolve()) - .then(original => { - const submitted = this.enketoTranslationService.contactRecordToJs(form.getDataStr({ irrelevant: false })); - return this.prepareSubmittedDocsForSave(original, submitted, type); - }) - .then((preparedDocs) => this.applyTransitions(preparedDocs)) - .then((preparedDocs) => { - if (xmlVersion) { - for (const doc of preparedDocs.preparedDocs) { - doc.form_version = xmlVersion; - } - } - - const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); - this.servicesActions.setLastChangedDoc(primaryDoc || preparedDocs.preparedDocs[0]); - - return this.dbService - .get() - .bulkDocs(preparedDocs.preparedDocs) - .then((bulkDocsResult) => { - const failureMessage = this.generateFailureMessage(bulkDocsResult); - - if (failureMessage) { - throw new Error(failureMessage); - } - - return { docId: preparedDocs.docId, bulkDocsResult }; - }); - }); - }); - } - - private applyTransitions(preparedDocs) { - return this.transitionsService - .applyTransitions(preparedDocs.preparedDocs) - .then(updatedDocs => { - preparedDocs.preparedDocs = updatedDocs; - return preparedDocs; - }); - } -} diff --git a/webapp/src/ts/services/enketo-prepopulation-data.service.ts b/webapp/src/ts/services/enketo-prepopulation-data.service.ts deleted file mode 100644 index 78a282f2661..00000000000 --- a/webapp/src/ts/services/enketo-prepopulation-data.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable } from '@angular/core'; -import { isString as _isString } from 'lodash-es'; - -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import { UserSettingsService } from '@mm-services/user-settings.service'; -import { LanguageService } from '@mm-services/language.service'; - -@Injectable({ - providedIn: 'root' -}) -export class EnketoPrepopulationDataService { - constructor( - private enketoTranslationService:EnketoTranslationService, - private userSettingsService:UserSettingsService, - private languageService:LanguageService, - ) {} - - get(model, data) { - if (data && _isString(data)) { - return Promise.resolve(data); - } - - return Promise - .all([ - this.userSettingsService.get(), - this.languageService.get() - ]) - .then(([user, language]) => { - const xml = $($.parseXML(model)); - const bindRoot = xml.find('model instance').children().first(); - - const userRoot = bindRoot.find('>inputs>user'); - - if (data) { - this.enketoTranslationService.bindJsonToXml(bindRoot, data, (name) => { - // Either a direct child or a direct child of inputs - return '>%, >inputs>%'.replace(/%/g, name); - }); - } - - if (userRoot.length) { - const userObject = { ...user, language }; - this.enketoTranslationService.bindJsonToXml(userRoot, userObject); - } - - return new XMLSerializer().serializeToString(bindRoot[0]); - }); - } -} 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 1e759325671..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 6e6259f44c0..b35c713fca3 100644 --- a/webapp/src/ts/services/enketo.service.ts +++ b/webapp/src/ts/services/enketo.service.ts @@ -1,18 +1,19 @@ import { Injectable, NgZone } from '@angular/core'; -import { v4 as uuid } from 'uuid'; -import * as pojo2xml from 'pojo2xml'; import { Store } from '@ngrx/store'; -import type JQuery from 'jquery'; import { toBik_text } from 'bikram-sambat'; import * as moment from 'moment'; -import { Xpath } from '@mm-providers/xpath-element-path.provider'; -import * as enketoConstants from './../../js/enketo/constants'; import * as medicXpathExtensions from '../../js/enketo/medic-xpath-extensions'; +import { + ContactServices, + EnketoFormManager, + FileServices, + FormDataServices, + TranslationServices, + XmlServices, +} from '../../js/enketo/enketo-form-manager'; 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 { FileReaderService } from '@mm-services/file-reader.service'; import { GetReportContentService } from '@mm-services/get-report-content.service'; @@ -26,51 +27,68 @@ 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'; +import { ContactTypesService } from '@mm-services/contact-types.service'; import { TranslateService } from '@mm-services/translate.service'; import { TransitionsService } from '@mm-services/transitions.service'; import { GlobalActions } from '@mm-actions/global'; +import {UserSettingsService} from '@mm-services/user-settings.service'; @Injectable({ providedIn: 'root' }) export class EnketoService { constructor( - private store: Store, - private attachmentService: AttachmentService, - private contactSummaryService: ContactSummaryService, - private dbService: DbService, - private enketoPrepopulationDataService: EnketoPrepopulationDataService, - private enketoTranslationService: EnketoTranslationService, - private extractLineageService: ExtractLineageService, - private fileReaderService: FileReaderService, - private getReportContentService: GetReportContentService, - private languageService: LanguageService, - private lineageModelGeneratorService: LineageModelGeneratorService, - private searchService: SearchService, - private submitFormBySmsService: SubmitFormBySmsService, - private translateFromService: TranslateFromService, - private userContactService: UserContactService, - private xmlFormsService: XmlFormsService, - private zScoreService: ZScoreService, - private transitionsService: TransitionsService, - private translateService: TranslateService, - private ngZone: NgZone, + store:Store, + attachmentService: AttachmentService, + contactSummaryService:ContactSummaryService, + contactTypesService:ContactTypesService, + dbService:DbService, + userSettingsService:UserSettingsService, + extractLineageService:ExtractLineageService, + fileReaderService:FileReaderService, + getReportContentService:GetReportContentService, + languageService:LanguageService, + lineageModelGeneratorService:LineageModelGeneratorService, + searchService:SearchService, + private submitFormBySmsService:SubmitFormBySmsService, + translateFromService:TranslateFromService, + userContactService:UserContactService, + xmlFormsService:XmlFormsService, + private zScoreService:ZScoreService, + transitionsService:TransitionsService, + translateService:TranslateService, + private ngZone:NgZone, ) { + this.enketoFormMgr = new EnketoFormManager( + new ContactServices(extractLineageService, userContactService, contactTypesService), + new FileServices(dbService, fileReaderService), + new FormDataServices( + contactSummaryService, + userSettingsService, + languageService, + lineageModelGeneratorService, + searchService + ), + new TranslationServices(translateService, translateFromService), + new XmlServices( + attachmentService, + getReportContentService, + xmlFormsService + ), + transitionsService, + new GlobalActions(store) + ); + this.inited = this.init(); - this.globalActions = new GlobalActions(store); - this.servicesActions = new ServicesActions(this.store); + this.servicesActions = new ServicesActions(store); } - private globalActions: GlobalActions; private servicesActions: ServicesActions; - private readonly HTML_ATTACHMENT_NAME = 'form.html'; - private readonly MODEL_ATTACHMENT_NAME = 'model.xml'; - private readonly objUrls = []; - private inited = false; + private enketoFormMgr; + private inited:Promise; - private currentForm; getCurrentForm() { - return this.currentForm; + return this.enketoFormMgr.getCurrentForm(); } private init() { @@ -84,759 +102,75 @@ export class EnketoService { }); } - private replaceJavarosaMediaWithLoaders(formHtml) { - formHtml.find('[data-media-src]').each((idx, element) => { - const $img = $(element); - const lang = $img.attr('lang'); - const active = $img.is('.active') ? 'active' : ''; - $img - .css('visibility', 'hidden') - .wrap(() => '
'); - }); - } - - private replaceMediaLoaders(formContainer, formDoc) { - formContainer.find('[data-media-src]').each((idx, element) => { - const elem = $(element); - const src = elem.attr('data-media-src'); - this.dbService - .get() - .getAttachment(formDoc._id, src) - .then((blob) => { - const objUrl = (window.URL || window.webkitURL).createObjectURL(blob); - this.objUrls.push(objUrl); - elem - .attr('src', objUrl) - .css('visibility', '') - .unwrap(); - }) - .catch((err) => { - console.error('Error fetching media file', formDoc._id, src, err); - elem.closest('.loader').hide(); - }); - }); - } - - 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.HTML_ATTACHMENT_NAME), - this.getAttachment(form._id, this.MODEL_ATTACHMENT_NAME) - ]) - .then(([html, model]) => { - const $html = $(html); - $html.find('[data-i18n]').each((idx, element) => { - const $element = $(element); - $element.text(this.translateService.instant('enketo.' + $element.attr('data-i18n'))); - }); - - const hasContactSummary = $(model).find('> instance[id="contact-summary"]').length === 1; - return { - html: $html, - model: model, - title: form.title, - hasContactSummary: hasContactSummary - }; - }); - } - - private handleKeypressOnInputField(e) { - // Here we capture both CR and TAB characters, and handle field-skipping - if (!window.medicmobile_android || (e.keyCode !== 9 && e.keyCode !== 13)) { - return; - } - - const $input = $(this); - - // stop the keypress from being handled elsewhere - e.preventDefault(); - - const $thisQuestion = $input.closest('.question'); - - // If there's another question on the current page, focus on that - if ($thisQuestion.attr('role') !== 'page') { - const $nextQuestion = $thisQuestion.find( - '~ .question:not(.disabled):not(.or-appearance-hidden), ~ .repeat-buttons button.repeat:not(:disabled)' - ); - if ($nextQuestion.length) { - if ($nextQuestion[0].tagName !== 'LABEL') { - // The next question is something complicated, so we can't just - // focus on it. Next best thing is to blur the current selection - // so the on-screen keyboard closes. - $input.trigger('blur'); - } else { - // Delay focussing on the next field, so that keybaord close and - // open events both register. This should mean that the on-screen - // keyboard is maintained between fields. - setTimeout(() => { - $nextQuestion.first().trigger('focus'); - }, 10); - } - return; - } - } - - // Trigger the change listener on the current field to update the enketo - // model - $input.trigger('change'); - - const enketoContainer = $thisQuestion.closest('.enketo'); - - // If there's no question on the current page, try to go to change page, - // or submit the form. - const next = enketoContainer.find('.btn.next-page:enabled:not(.disabled)'); - if (next.length) { - next.trigger('click'); - } else { - enketoContainer.find('.btn.submit').trigger('click'); - } - } - - private getLineage(contact) { - return this.lineageModelGeneratorService - .contact(contact._id) - .then((model) => model.lineage) - .catch((err) => { - if (err.code === 404) { - console.warn(`Enketo failed to get lineage of contact '${contact._id}' because document does not exist`, err); - return []; - } - - throw err; - }); - } - - private getContactReports(contact) { - const subjectIds = [contact._id]; - const shortCode = contact.patient_id || contact.place_id; - if (shortCode) { - subjectIds.push(shortCode); - } - return this.searchService.search('reports', { subjectIds: subjectIds }, { include_docs: true }); - } - - private getContactSummary(doc, instanceData) { - const contact = instanceData && instanceData.contact; - if (!doc.hasContactSummary || !contact) { - return Promise.resolve(); - } - return Promise - .all([ - this.getContactReports(contact), - this.getLineage(contact) - ]) - .then(([reports, lineage]) => { - return this.contactSummaryService.get(contact, reports, lineage); - }) - .then((summary: any) => { - if (!summary) { - return; - } - - try { - const xmlStr = pojo2xml({ context: summary.context }); - return { - id: 'contact-summary', - xml: new DOMParser().parseFromString(xmlStr, 'text/xml') - }; - } catch (e) { - console.error('Error while converting app_summary.contact_summary.context to xml.'); - throw new Error('contact_summary context is misconfigured'); - } - }); - } - - private getEnketoForm(wrapper, doc, instanceData) { - return Promise - .all([ - this.enketoPrepopulationDataService.get(doc.model, instanceData), - this.getContactSummary(doc, instanceData), - this.languageService.get() - ]) - .then(([ instanceStr, contactSummary, language ]) => { - const options: EnketoOptions = { - modelStr: doc.model, - instanceStr: instanceStr - }; - if (contactSummary) { - options.external = [contactSummary]; - } - const form = wrapper.find('form')[0]; - return new window.EnketoForm(form, options, { language }); - }); - } - - private renderFromXmls(xmlFormContext: XmlFormContext) { - const { doc, instanceData, titleKey, wrapper } = xmlFormContext; - - const formContainer = wrapper.find('.container').first(); - formContainer.html(doc.html.get(0)); - - return this - .getEnketoForm(wrapper, doc, instanceData) - .then((form) => { - this.currentForm = form; - const loadErrors = this.currentForm.init(); - if (loadErrors?.length) { - return Promise.reject(new Error(JSON.stringify(loadErrors))); - } - }) - .then(() => this.getFormTitle(titleKey, doc)) - .then((title) => { - this.setFormTitle(wrapper, title); - wrapper.show(); - - wrapper.find('input').on('keydown', this.handleKeypressOnInputField); - - // handle page turning using browser history - window.history.replaceState({ enketo_page_number: 0 }, ''); - this.overrideNavigationButtons(this.currentForm, wrapper); - this.addPopStateHandler(this.currentForm, wrapper); - this.forceRecalculate(this.currentForm); - this.setupNavButtons(wrapper, 0); - return this.currentForm; - }); - } - - private getFormTitle(titleKey, doc) { - if (titleKey) { - // using translation key - return this.translateService.get(titleKey); - } - - if (doc.title) { - // title defined in the doc - return Promise.resolve(this.translateFromService.get(doc.title)); - } - } - - private setFormTitle(wrapper, title) { - // manually translate the title as enketo-core doesn't have any way to do this - // https://github.com/enketo/enketo-core/issues/405 - const $title = wrapper.find('#form-title'); - if (title) { - // overwrite contents - $title.text(title); - } else if ($title.text() === 'No Title') { - // useless enketo default - remove it - $title.remove(); - } // else the title is hardcoded in the form definition - leave it alone - } - - private overrideNavigationButtons(form, $wrapper) { - $wrapper - .find('.btn.next-page') - .off('.pagemode') - .on('click.pagemode', () => { - form.pages - ._next() - .then(valid => { - if (valid) { - const currentIndex = form.pages._getCurrentIndex(); - window.history.pushState({ enketo_page_number: currentIndex }, ''); - this.setupNavButtons($wrapper, currentIndex); - this.pauseMultimedia($wrapper); - } - this.forceRecalculate(form); - }); - return false; - }); - - $wrapper - .find('.btn.previous-page') - .off('.pagemode') - .on('click.pagemode', () => { - window.history.back(); - this.setupNavButtons($wrapper, form.pages._getCurrentIndex() - 1); - this.forceRecalculate(form); - this.pauseMultimedia($wrapper); - return false; - }); - } - - // This code can be removed once this issue is fixed: https://github.com/enketo/enketo-core/issues/816 - private pauseMultimedia($wrapper) { - $wrapper - .find('audio, video') - .each((idx, element) => element.pause()); - } - - private addPopStateHandler(form, $wrapper) { - $(window).on('popstate.enketo-pagemode', (event: any) => { - if (event.originalEvent && - event.originalEvent.state && - typeof event.originalEvent.state.enketo_page_number === 'number' && - $wrapper.find('.container').not(':empty')) { - - const targetPage = event.originalEvent.state.enketo_page_number; - const pages = form.pages; - const currentIndex = pages._getCurrentIndex(); - if(targetPage > currentIndex) { - pages._next(); - } else { - pages._prev(); - } + private registerListeners(selector, form, editedListener, valueChangeListener) { + const $selector = $(selector); + if(editedListener) { + $selector.on('edited', () => this.ngZone.run(() => editedListener())); + } + [ + valueChangeListener, + () => this.enketoFormMgr.setupNavButtons(form, $selector, form.pages._getCurrentIndex()) + ].forEach(listener => { + if(listener) { + $selector.on('xforms-value-changed', () => this.ngZone.run(() => listener())); } }); - } - - private registerEditedListener($selector, listener) { - if (listener) { - $selector.on('edited', () => this.ngZone.run(() => listener())); - } - } - - private registerValuechangeListener($selector, listener) { - if (listener) { - $selector.on('xforms-value-changed', () => this.ngZone.run(() => listener())); - } - } - - private registerAddrepeatListener($selector, formDoc) { - $selector.on('addrepeat', (ev) => { - setTimeout(() => { // timeout to allow enketo to finish first - this.replaceMediaLoaders($(ev.target), formDoc); - }); - }); - } - - private renderForm(formContext: EnketoFormContext) { - const { - editedListener, - formDoc, - instanceData, - selector, - titleKey, - valuechangeListener, - } = formContext; - - const $selector = $(selector); - return this - .transformXml(formDoc) - .then(doc => { - this.replaceJavarosaMediaWithLoaders(doc.html); - const xmlFormContext: XmlFormContext = { - doc, - wrapper: $selector, - instanceData, - titleKey, - }; - return this.renderFromXmls(xmlFormContext); - }) - .then((form) => { - const formContainer = $selector.find('.container').first(); - this.replaceMediaLoaders(formContainer, formDoc); - this.registerAddrepeatListener($selector, formDoc); - this.registerEditedListener($selector, editedListener); - this.registerValuechangeListener($selector, valuechangeListener); - this.registerValuechangeListener($selector, - () => this.setupNavButtons($selector, form.pages._getCurrentIndex())); - - window.CHTCore.debugFormModel = () => form.model.getStr(); - return form; - }); + return form; } render(selector, form, instanceData, editedListener, valuechangeListener) { - return this.ngZone.runOutsideAngular(() => { - return this._render(selector, form, instanceData, editedListener, valuechangeListener); - }); - } - - private _render(selector, form, instanceData, editedListener, valuechangeListener) { - return Promise - .all([ - this.inited, - this.getUserContact(), - ]) - .then(() => { - const formContext: EnketoFormContext = { - selector, - formDoc: form, - instanceData, - editedListener, - valuechangeListener, - }; - return this.renderForm(formContext); + return this.inited.then(() => { + return this.ngZone.runOutsideAngular(() => { + return this.enketoFormMgr.render(selector, form, instanceData) + .then(form => this.registerListeners(selector, form, editedListener, valuechangeListener)); }); + }); } renderContactForm(formContext: EnketoFormContext) { - return this.renderForm(formContext); + return this.enketoFormMgr.renderContactForm(formContext) + .then(form => this.registerListeners( + formContext.selector, + form, + formContext.editedListener, + formContext.valuechangeListener + )); } - 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 = []; - $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); - - const attach = (elem, file, type, alreadyEncoded, xpath?) => { - xpath = 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=file]') - .each((idx, element) => { - const xpath = Xpath.getElementXPath(element); - const $input: any = $('input[type=file][name="' + xpath + '"]'); - const file = $input[0].files[0]; - if (file) { - attach(element, file, file.type, false, xpath); - } - }); - - $record - .find('[type=binary]') - .each((idx, element) => { - const file = $(element).text(); - if (file) { - $(element).text(''); - attach(element, file, 'image/png', true); - } - }); - - record = getOuterHTML($record[0]); - - // remove old style content attachment - this.attachmentService.remove(doc, this.getReportContentService.REPORT_ATTACHMENT_NAME); - docsToStore.unshift(doc); - - doc.fields = this.enketoTranslationService.reportRecordToJs(record, formXml); - return docsToStore; - } + save(formInternalId, form, geoHandle, docId?) { + return this.enketoFormMgr + .validate(form) + .then(() => { + $('form.or').trigger('beforesave'); - private saveDocs(docs) { - return this.dbService - .get() - .bulkDocs(docs) - .then((results) => { - results.forEach((result) => { - if (result.error) { - console.error('Error saving report', result); - throw new Error('Error saving report'); - } - const idx = docs.findIndex(doc => doc._id === result.id); - docs[idx] = { ...docs[idx], _rev: result.rev }; + return this.ngZone.runOutsideAngular(() => { + return this.enketoFormMgr.save(formInternalId, form, geoHandle, docId) + .then((docs) => { + this.servicesActions.setLastChangedDoc(docs[0]); + // submit by sms _after_ saveDocs so that the main doc's ID is available + this.submitFormBySmsService.submit(docs[0]); + return docs; + }); }); - return docs; - }); - } - - private 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. - return this.dbService.get().get(docId).then((doc) => { - // 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? - delete doc.content; - return doc; - }); - } - - private getUserContact() { - return this.userContactService - .get() - .then((contact) => { - if (!contact) { - const err: any = new Error('Your user does not have an associated contact, or does not have access to the ' + - 'associated contact. Talk to your administrator to correct this.'); - err.translationKey = 'error.loading.form.no_contact'; - throw err; - } - return contact; }); } - private create(formInternalId) { - return this.getUserContact().then((contact) => { - return { - form: formInternalId, - type: 'data_record', - content_type: 'xml', - reported_date: Date.now(), - contact: this.extractLineageService.extract(contact), - from: contact && contact.phone - }; - }); - } - - private forceRecalculate(form) { - // Work-around for stale jr:choice-name() references in labels. ref #3870 - form.calc.update(); - - // Force forms to update jr:itext references in output fields that contain - // calculated values. ref #4111 - form.output.update(); - } - - private setupNavButtons($wrapper, currentIndex) { - if(!this.currentForm?.pages) { - return; - } - const lastIndex = this.currentForm.pages.activePages.length - 1; - const footer = $wrapper.find('.form-footer'); - footer.removeClass('end'); - footer.find('.previous-page, .next-page').removeClass('disabled'); - - if (currentIndex >= lastIndex) { - footer.addClass('end'); - footer.find('.next-page').addClass('disabled'); - } - if (currentIndex <= 0) { - footer.find('.previous-page').addClass('disabled'); - } - } - - private saveGeo(geoHandle, docs) { - if (!geoHandle) { - return docs; - } - - return geoHandle() - .catch(err => err) - .then(geoData => { - docs.forEach(doc => { - doc.geolocation_log = doc.geolocation_log || []; - doc.geolocation_log.push({ - timestamp: Date.now(), - recording: geoData - }); - doc.geolocation = geoData; + saveContactForm(form, docId, type, xmlVersion) { + return this.ngZone.runOutsideAngular(() => { + return this.enketoFormMgr.saveContactForm(form, docId, type, xmlVersion) + .then(docs => { + const primaryDoc = docs.preparedDocs.find(doc => doc.type === type); + this.servicesActions.setLastChangedDoc(primaryDoc || docs.preparedDocs[0]); + return docs; }); - return docs; - }); - } - - private async validateAttachments(docs) { - const oversizeDoc = docs.find(doc => { - let attachmentsSize = 0; - - if (doc._attachments) { - Object - .keys(doc._attachments) - .forEach(name => { - const data = doc._attachments[name]?.data; // It can be Base64 (binary) or object (file) - const size = typeof data === 'string' ? data.length : (data?.size || 0); - attachmentsSize += size; - }); - } - - return attachmentsSize > enketoConstants.maxAttachmentSize; }); - - if (oversizeDoc) { - const errorMessage = await this.translateService.get('enketo.error.max_attachment_size'); - this.globalActions.setSnackbarContent(errorMessage); - return Promise.reject(new Error(errorMessage)); - } - - return docs; - } - - save(formInternalId, form, geoHandle, docId?) { - return Promise - .resolve(form.validate()) - .then((valid) => { - if (!valid) { - throw new Error('Form is invalid'); - } - - $('form.or').trigger('beforesave'); - - return this.ngZone.runOutsideAngular(() => this._save(formInternalId, form, geoHandle, docId)); - }); - } - - private _save(formInternalId, form, geoHandle, docId?) { - const getDocPromise = docId ? this.update(docId) : this.create(formInternalId); - - return Promise - .all([ - getDocPromise, - this.xmlFormsService.getDocAndFormAttachment(formInternalId) - ]) - .then(([doc, formDoc]) => { - const dataString = form.getDataStr({ irrelevant: false }); - return this.xmlToDocs(doc, formDoc.xml, formDoc.doc.xmlVersion, dataString); - }) - .then(docs => this.validateAttachments(docs)) - .then((docs) => this.saveGeo(geoHandle, docs)) - .then((docs) => this.transitionsService.applyTransitions(docs)) - .then((docs) => this.saveDocs(docs)) - .then((docs) => { - this.servicesActions.setLastChangedDoc(docs[0]); - // submit by sms _after_ saveDocs so that the main doc's ID is available - this.submitFormBySmsService.submit(docs[0]); - return docs; - }); } unload(form) { - $(window).off('.enketo-pagemode'); - if (form) { - form.resetView(); - } - // unload blobs - this.objUrls.forEach((url) => { - (window.URL || window.webkitURL).revokeObjectURL(url); - }); - - delete window.CHTCore.debugFormModel; - delete this.currentForm; - this.objUrls.length = 0; + this.enketoFormMgr.unload(form); } } -interface ContactSummary { - id: string; - xmlStr: string; -} - -interface EnketoOptions { - modelStr: string; - instanceStr: string; - external?: ContactSummary[]; -} - -interface XmlFormContext { - doc: { - html: JQuery; - model: string; - title: string; - hasContactSummary: boolean; - }; - wrapper: JQuery; - instanceData: string|Record; // String for report forms, Record<> for contact forms. - titleKey: string; -} - export interface EnketoFormContext { selector: string; formDoc: Record; diff --git a/webapp/src/ts/services/form2sms.service.ts b/webapp/src/ts/services/form2sms.service.ts index 09087b674e4..dbf07c9dd26 100644 --- a/webapp/src/ts/services/form2sms.service.ts +++ b/webapp/src/ts/services/form2sms.service.ts @@ -5,19 +5,26 @@ import { DbService } from '@mm-services/db.service'; import { GetReportContentService } from '@mm-services/get-report-content.service'; import { ParseProvider } from '@mm-providers/parse.provider'; import { FileReaderService } from '@mm-services/file-reader.service'; -import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulation-data.service'; +import { UserSettingsService } from '@mm-services/user-settings.service'; +import { LanguageService } from '@mm-services/language.service'; + +import EnketoDataPrepopulator from '../../js/enketo/enketo-data-prepopulator'; @Injectable({ providedIn: 'root' }) export class Form2smsService { + private enketoDataPrepopulator: EnketoDataPrepopulator; + constructor( private dbService:DbService, private getReportContentService:GetReportContentService, private fileReaderService: FileReaderService, private parseProvider:ParseProvider, - private enketoPrepopulationDataService: EnketoPrepopulationDataService, + userSettingsService:UserSettingsService, + languageService:LanguageService, ) { + this.enketoDataPrepopulator = new EnketoDataPrepopulator(userSettingsService, languageService); } private concat(...args) { @@ -54,7 +61,7 @@ export class Form2smsService { this.getFormModel(form) ]) .then(([reportModel, formModel]) => { - return this.enketoPrepopulationDataService.get(formModel, reportModel); + return this.enketoDataPrepopulator.get(formModel, reportModel); }); } diff --git a/webapp/tests/karma/ts/services/contact-save.service.spec.ts b/webapp/tests/karma/js/enketo/contact-saver.spec.ts similarity index 53% rename from webapp/tests/karma/ts/services/contact-save.service.spec.ts rename to webapp/tests/karma/js/enketo/contact-saver.spec.ts index e7ae2c5655e..2fd1fff748d 100644 --- a/webapp/tests/karma/ts/services/contact-save.service.spec.ts +++ b/webapp/tests/karma/js/enketo/contact-saver.spec.ts @@ -1,53 +1,46 @@ -import { TestBed } from '@angular/core/testing'; import sinon from 'sinon'; import { assert } from 'chai'; -import { provideMockStore } from '@ngrx/store/testing'; import { cloneDeep } from 'lodash-es'; - -import { DbService } from '@mm-services/db.service'; -import { ContactTypesService } from '@mm-services/contact-types.service'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; -import { ExtractLineageService } from '@mm-services/extract-lineage.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; -import { ServicesActions } from '@mm-actions/services'; -import { TransitionsService } from '@mm-services/transitions.service'; +import EnketoDataTranslator from '../../../../src/js/enketo/enketo-data-translator'; +import ContactSaver from '../../../../src/js/enketo/contact-saver'; +import { + ContactServices, + FileServices +} from '../../../../src/js/enketo/enketo-form-manager'; describe('ContactSave service', () => { - - let service; - let bulkDocs; - let get; - let contactTypesService; - let enketoTranslationService; + let contactRecordToJs; + let contactSaver; + let dbBulkDocs; + let dbGet; let extractLineageService; let transitionsService; - let setLastChangedDoc; let clock; + let form; + + const DEFAULT_DOC_ID = null; + const DEFAULT_TYPE = 'some-contact-type'; beforeEach(() => { - enketoTranslationService = { - contactRecordToJs: sinon.stub(), - }; + contactRecordToJs = sinon.stub(EnketoDataTranslator, 'contactRecordToJs'); - contactTypesService = { isHardcodedType: sinon.stub().returns(false) }; + const contactTypesService = { isHardcodedType: sinon.stub().returns(false) }; extractLineageService = { extract: sinon.stub() }; transitionsService = { applyTransitions: sinon.stub().resolvesArg(0) }; - bulkDocs = sinon.stub(); - get = sinon.stub(); - setLastChangedDoc = sinon.stub(ServicesActions.prototype, 'setLastChangedDoc'); - - TestBed.configureTestingModule({ - providers: [ - provideMockStore(), - { provide: DbService, useValue: { get: () => ({ get, bulkDocs }) } }, - { provide: ContactTypesService, useValue: contactTypesService }, - { provide: EnketoTranslationService, useValue: enketoTranslationService }, - { provide: ExtractLineageService, useValue: extractLineageService }, - { provide: TransitionsService, useValue: transitionsService }, - ] - }); + dbBulkDocs = sinon.stub().resolves([]); + dbGet = sinon.stub(); + + const contactServices = new ContactServices(extractLineageService, null, contactTypesService); + const dbService = { + get: sinon.stub().returns({ + bulkDocs: dbBulkDocs, + get: dbGet, + }) + }; + const fileServices = new FileServices(dbService); + contactSaver = new ContactSaver(contactServices, fileServices, transitionsService); - service = TestBed.inject(ContactSaveService); + form = { getDataStr: sinon.stub().returns('') }; }); afterEach(() => { @@ -56,26 +49,21 @@ describe('ContactSave service', () => { }); it('fetches and binds db types and minifies string contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ + contactRecordToJs.returns({ doc: { _id: 'main1', type: 'main', contact: 'abc' } }); - bulkDocs.resolves([]); - get.resolves({ _id: 'abc', name: 'gareth', parent: { _id: 'def' } }); + dbGet.resolves({ _id: 'abc', name: 'gareth', parent: { _id: 'def' } }); extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - return service - .save(form, docId, type) + return contactSaver + .save(form, DEFAULT_DOC_ID, DEFAULT_TYPE) .then(() => { - assert.equal(get.callCount, 1); - assert.equal(get.args[0][0], 'abc'); + assert.equal(dbGet.callCount, 1); + assert.equal(dbGet.args[0][0], 'abc'); - assert.equal(bulkDocs.callCount, 1); + assert.equal(dbBulkDocs.callCount, 1); - const savedDocs = bulkDocs.args[0][0]; + const savedDocs = dbBulkDocs.args[0][0]; assert.equal(savedDocs.length, 1); assert.deepEqual(savedDocs[0].contact, { @@ -84,32 +72,25 @@ describe('ContactSave service', () => { _id: 'def' } }); - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); }); }); it('fetches and binds db types and minifies object contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ + contactRecordToJs.returns({ doc: { _id: 'main1', type: 'main', contact: { _id: 'abc', name: 'Richard' } } }); - bulkDocs.resolves([]); - get.resolves({ _id: 'abc', name: 'Richard', parent: { _id: 'def' } }); + dbGet.resolves({ _id: 'abc', name: 'Richard', parent: { _id: 'def' } }); extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - return service - .save(form, docId, type) + return contactSaver + .save(form, DEFAULT_DOC_ID, DEFAULT_TYPE) .then(() => { - assert.equal(get.callCount, 1); - assert.equal(get.args[0][0], 'abc'); + assert.equal(dbGet.callCount, 1); + assert.equal(dbGet.args[0][0], 'abc'); - assert.equal(bulkDocs.callCount, 1); + assert.equal(dbBulkDocs.callCount, 1); - const savedDocs = bulkDocs.args[0][0]; + const savedDocs = dbBulkDocs.args[0][0]; assert.equal(savedDocs.length, 1); assert.deepEqual(savedDocs[0].contact, { @@ -118,23 +99,17 @@ describe('ContactSave service', () => { _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'}, + 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', } ], + child_data: [{ _id: 'kid1', type: 'child', parent: 'PARENT', }], }, }); @@ -143,14 +118,12 @@ describe('ContactSave service', () => { return contact; }); - bulkDocs.resolves([]); - - return service - .save(form, docId, type) + return contactSaver + .save(form, DEFAULT_DOC_ID, DEFAULT_TYPE) .then(() => { - assert.isTrue(bulkDocs.calledOnce); + assert.isTrue(dbBulkDocs.calledOnce); - const savedDocs = bulkDocs.args[0][0]; + const savedDocs = dbBulkDocs.args[0][0]; assert.equal(savedDocs[0]._id, 'main1'); @@ -163,18 +136,11 @@ describe('ContactSave service', () => { 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 include form_version if provided', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ + contactRecordToJs.returns({ doc: { _id: 'main1', type: 'main', contact: 'NEW'}, siblings: { contact: { _id: 'sis1', type: 'sister', parent: 'PARENT', }, @@ -189,18 +155,18 @@ describe('ContactSave service', () => { return contact; }); - bulkDocs.resolves([]); + dbBulkDocs.resolves([]); const xmlVersion = { time: 123456, sha256: '654321' }; - return service - .save(form, docId, type, xmlVersion) + return contactSaver + .save(form, DEFAULT_DOC_ID, DEFAULT_TYPE, xmlVersion) .then(() => { - assert.isTrue(bulkDocs.calledOnce); - const savedDocs = bulkDocs.args[0][0]; + assert.isTrue(dbBulkDocs.calledOnce); + const savedDocs = dbBulkDocs.args[0][0]; assert.equal(savedDocs.length, 3); for (const savedDoc of savedDocs) { assert.equal(savedDoc.form_version.time, 123456); @@ -210,11 +176,9 @@ describe('ContactSave service', () => { }); it('should copy old properties for existing contacts', () => { - const form = { getDataStr: () => '' }; const docId = 'main1'; - const type = 'some-contact-type'; - enketoTranslationService.contactRecordToJs.returns({ + contactRecordToJs.returns({ doc: { _id: 'main1', type: 'contact', @@ -223,8 +187,7 @@ describe('ContactSave service', () => { value: undefined, } }); - bulkDocs.resolves([]); - get + dbGet .withArgs('main1') .resolves({ _id: 'main1', @@ -244,16 +207,16 @@ describe('ContactSave service', () => { .returns({ _id: 'def' }); clock = sinon.useFakeTimers(5000); - return service - .save(form, docId, type) + return contactSaver + .save(form, docId, DEFAULT_TYPE) .then(() => { - assert.equal(get.callCount, 2); - assert.deepEqual(get.args[0], ['main1']); - assert.deepEqual(get.args[1], ['contact']); + assert.equal(dbGet.callCount, 2); + assert.deepEqual(dbGet.args[0], ['main1']); + assert.deepEqual(dbGet.args[1], ['contact']); - assert.equal(bulkDocs.callCount, 1); + assert.equal(dbBulkDocs.callCount, 1); - const savedDocs = bulkDocs.args[0][0]; + const savedDocs = dbBulkDocs.args[0][0]; assert.equal(savedDocs.length, 1); assert.deepEqual(savedDocs[0], { @@ -268,22 +231,14 @@ describe('ContactSave service', () => { data: 'is present', reported_date: 5000, }); - - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); }); }); it('should pass the contacts to transitions service before saving and save modified contacts', () => { - const form = { getDataStr: () => '' }; - const docId = null; - const type = 'some-contact-type'; - - enketoTranslationService.contactRecordToJs.returns({ + contactRecordToJs.returns({ doc: { _id: 'main1', type: 'main', contact: { _id: 'abc', name: 'Richard' } } }); - bulkDocs.resolves([]); - get.resolves({ _id: 'abc', name: 'Richard', parent: { _id: 'def' } }); + dbGet.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 @@ -293,33 +248,33 @@ describe('ContactSave service', () => { }); clock = sinon.useFakeTimers(1000); - return service - .save(form, docId, type) + return contactSaver + .save(form, DEFAULT_DOC_ID, DEFAULT_TYPE) .then(() => { - assert.equal(get.callCount, 1); - assert.equal(get.args[0][0], 'abc'); + assert.equal(dbGet.callCount, 1); + assert.equal(dbGet.args[0][0], 'abc'); assert.equal(transitionsService.applyTransitions.callCount, 1); assert.deepEqual(transitionsService.applyTransitions.args[0], [[ { _id: 'main1', contact: { _id: 'abc', parent: { _id: 'def' } }, - contact_type: type, + contact_type: DEFAULT_TYPE, type: 'contact', parent: undefined, reported_date: 1000 } ]]); - assert.equal(bulkDocs.callCount, 1); - const savedDocs = bulkDocs.args[0][0]; + 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, + contact_type: DEFAULT_TYPE, type: 'contact', parent: undefined, reported_date: 1000, @@ -327,9 +282,6 @@ describe('ContactSave service', () => { }, { this: 'is a new doc' }, ]); - assert.equal(setLastChangedDoc.callCount, 1); - assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); }); }); - }); diff --git a/webapp/tests/karma/js/enketo/enketo-data-prepopulator.spec.ts b/webapp/tests/karma/js/enketo/enketo-data-prepopulator.spec.ts new file mode 100644 index 00000000000..283cf83478b --- /dev/null +++ b/webapp/tests/karma/js/enketo/enketo-data-prepopulator.spec.ts @@ -0,0 +1,263 @@ +import sinon from 'sinon'; +import { expect, assert } from 'chai'; +import $ from 'jquery'; + +import EnketoDataPrepopulator from '../../../../src/js/enketo/enketo-data-prepopulator'; + +describe('EnketoPrepopulationData service', () => { + let service; + let UserSettings; + let languageSettings; + + const generatedForm = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + const editPersonForm = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'person' + + 'PARENT' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + const editPersonFormWithoutInputs = + '' + + '' + + '' + + '' + + '' + + 'person' + + 'PARENT' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + const pregnancyForm = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'person' + + 'PARENT' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + beforeEach(() => { + UserSettings = sinon.stub(); + languageSettings = sinon.stub(); + + const bindJsonToXml = sinon.stub().callsFake((elem, data, childMatcher) => { + Object.keys(data).forEach((key) => { + const value = data[key]; + const current = childMatcher ? elem.find(childMatcher(key)) : elem.children(key); + if (value !== null && typeof value === 'object') { + if (current.children().length) { + bindJsonToXml(current, value); + } else { + current.text(value._id); + } + } else { + current.text(value); + } + }); + }); + + service = new EnketoDataPrepopulator({ get: UserSettings }, { get:languageSettings }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('exists', function() { + assert.isDefined(service); + }); + + it('returns the given string', () => { + const model = ''; + const data = ''; + return service.get(model, data).then((actual) => { + expect(actual).to.equal(data); + }); + }); + + it('rejects when user settings fails', () => { + const model = ''; + const data = {}; + UserSettings.rejects('phail'); + return service + .get(model, data) + .then(() => assert.fail('Expected fail')) + .catch((err) => { + expect(err.name).to.equal('phail'); + expect(UserSettings.callCount).to.equal(1); + }); + }); + + it('binds user details into model', () => { + const data = {}; + const user = { name: 'geoff' }; + UserSettings.resolves(user); + return service + .get(editPersonForm, data) + .then((actual) => { + const xml = $($.parseXML(actual)); + expect(xml.find('inputs > user > name')[0].innerHTML).to.equal(user.name); + expect(UserSettings.callCount).to.equal(1); + }); + }); + + it('binds form content into model', () => { + const data = { person: { last_name: 'salmon' } }; + const user = { name: 'geoff' }; + UserSettings.resolves(user); + return service + .get(editPersonFormWithoutInputs, data) + .then((actual) => { + const xml = $($.parseXML(actual)); + expect(xml.find('data > person > last_name')[0].innerHTML).to.equal(data.person.last_name); + expect(UserSettings.callCount).to.equal(1); + }); + }); + + it('binds form content into generated form model', () => { + const data = { person: { name: 'sally' } }; + const user = { name: 'geoff' }; + UserSettings.resolves(user); + return service + .get(generatedForm, data) + .then((actual) => { + const xml = $($.parseXML(actual)); + expect(xml.find('data > person > name')[0].innerHTML).to.equal(data.person.name); + expect(UserSettings.callCount).to.equal(1); + }); + }); + + it('binds user details, user language and form content into model', () => { + const data = { person: { last_name: 'salmon' } }; + const user = { name: 'geoff' }; + UserSettings.resolves(user); + languageSettings.resolves('en'); + return service + .get(editPersonForm, data) + .then((actual) => { + const xml = $($.parseXML(actual)); + expect(xml.find('inputs > user > name')[0].innerHTML).to.equal(user.name); + expect(xml.find('inputs > user > language')[0].innerHTML).to.equal('en'); + expect(xml.find('data > person > last_name')[0].innerHTML).to.equal(data.person.last_name); + expect(UserSettings.callCount).to.equal(1); + expect(languageSettings.callCount).to.equal(1); + }); + }); + + it('binds form content into model with custom root node', () => { + const data = { person: { last_name: 'salmon' } }; + const user = { name: 'geoff' }; + UserSettings.resolves(user); + return service + .get(pregnancyForm, data) + .then((actual) => { + const xml = $($.parseXML(actual)); + 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); + expect(UserSettings.callCount).to.equal(1); + }); + }); +}); diff --git a/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts b/webapp/tests/karma/js/enketo/enketo-data-translator.spec.ts similarity index 92% rename from webapp/tests/karma/ts/services/enketo-translation.service.spec.ts rename to webapp/tests/karma/js/enketo/enketo-data-translator.spec.ts index 9c70fd901ab..cb8dbfb8f37 100644 --- a/webapp/tests/karma/ts/services/enketo-translation.service.spec.ts +++ b/webapp/tests/karma/js/enketo/enketo-data-translator.spec.ts @@ -1,7 +1,7 @@ -import { TestBed } from '@angular/core/testing'; import { assert } from 'chai'; -import { EnketoTranslationService } from '@mm-services/enketo-translation.service'; +import $ from 'jquery'; +import EnketoDataTranslator from '../../../../src/js/enketo/enketo-data-translator'; const serialize = (element) => { if (element.nodeType !== Node.ELEMENT_NODE) { @@ -16,267 +16,7 @@ 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('EnketoDataTranslator', () => { describe('#reportRecordToJs()', () => { it('should convert nested nodes to nested JSON', () => { // given @@ -306,7 +46,7 @@ describe('EnketoTranslation service', () => { `; // when - const js = service.reportRecordToJs(xml); + const js = EnketoDataTranslator.reportRecordToJs(xml); // then assert.deepEqual(js, { @@ -395,7 +135,7 @@ describe('EnketoTranslation service', () => { `; // when - const js = service.reportRecordToJs(record, form); + const js = EnketoDataTranslator.reportRecordToJs(record, form); // then assert.deepEqual(js, { @@ -419,7 +159,7 @@ describe('EnketoTranslation service', () => { `; // when - const hidden_fields = service.getHiddenFieldList(xml); + const hidden_fields = EnketoDataTranslator.getHiddenFieldList(xml); // then assert.deepEqual(hidden_fields, []); @@ -436,10 +176,10 @@ describe('EnketoTranslation service', () => { `; // when - const hidden_fields = service.getHiddenFieldList(xml); + const hidden_fields = EnketoDataTranslator.getHiddenFieldList(xml); // then - assert.deepEqual(hidden_fields, [ 'secret_code_name_one', 'secret_code_name_two' ]); + assert.deepEqual(hidden_fields, ['secret_code_name_one', 'secret_code_name_two']); }); it('hides sections tagged `hidden`', () => { @@ -455,10 +195,10 @@ describe('EnketoTranslation service', () => { `; // when - const hidden_fields = service.getHiddenFieldList(xml); + const hidden_fields = EnketoDataTranslator.getHiddenFieldList(xml); // then - assert.deepEqual(hidden_fields, [ 'secret' ]); + assert.deepEqual(hidden_fields, ['secret']); }); it('recurses to find `hidden` children', () => { @@ -474,7 +214,7 @@ describe('EnketoTranslation service', () => { `; // when - const hidden_fields = service.getHiddenFieldList(xml); + const hidden_fields = EnketoDataTranslator.getHiddenFieldList(xml); // then assert.deepEqual(hidden_fields, [ 'secret.first', 'lmp' ]); @@ -495,10 +235,10 @@ describe('EnketoTranslation service', () => { `; // when - const hidden_fields = service.getHiddenFieldList(xml); + const hidden_fields = EnketoDataTranslator.getHiddenFieldList(xml); // then - assert.deepEqual(hidden_fields, [ 'secret.first', 'lmp' ]); + assert.deepEqual(hidden_fields, ['secret.first', 'lmp']); }); }); @@ -527,7 +267,7 @@ describe('EnketoTranslation service', () => { }; // when - service.bindJsonToXml(element, data); + EnketoDataTranslator.bindJsonToXml(element, data); // then assert.equal(element.find('name').text(), 'Davesville'); @@ -564,7 +304,7 @@ describe('EnketoTranslation service', () => { }; // when - service.bindJsonToXml(element, data); + EnketoDataTranslator.bindJsonToXml(element, data); // then assert.equal(element.find('name').text(), 'Davesville'); @@ -605,7 +345,7 @@ describe('EnketoTranslation service', () => { }; // when - service.bindJsonToXml(element, data); + EnketoDataTranslator.bindJsonToXml(element, data); // then assert.equal(element.find('district_hospital > name').text(), 'Davesville'); @@ -645,7 +385,7 @@ describe('EnketoTranslation service', () => { }, }; - service.bindJsonToXml(element, data); + EnketoDataTranslator.bindJsonToXml(element, data); assert.equal(element.find('contact > name').text(), '', 'The contact name should not get the value of the district hospital'); @@ -671,7 +411,7 @@ describe('EnketoTranslation service', () => { }, }; - service.bindJsonToXml(element, data); + EnketoDataTranslator.bindJsonToXml(element, data); assert.equal(element.find('smang').text(), DEEP_TEST_VALUE); }); @@ -746,7 +486,7 @@ describe('EnketoTranslation service', () => { ] } }; - service.bindJsonToXml(element, data, (name) => { + EnketoDataTranslator.bindJsonToXml(element, data, (name) => { return '>%, >inputs>%'.replace(/%/g, name); }); @@ -828,7 +568,7 @@ describe('EnketoTranslation service', () => { }, }; - service.bindJsonToXml(element, data); + EnketoDataTranslator.bindJsonToXml(element, data); assert.equal(element.find('district_hospital')[0].hasAttribute('jr:template'), false); assert.equal(element.find('district_hospital')[0].hasAttribute('template'), false); @@ -843,4 +583,259 @@ describe('EnketoTranslation service', () => { assert.equal(element.find('notes')[0].hasAttribute('template'), false); }); }); + + 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 = EnketoDataTranslator.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 = EnketoDataTranslator.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 = EnketoDataTranslator.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 = EnketoDataTranslator.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 = EnketoDataTranslator.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 = EnketoDataTranslator.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', + }, + }, + }); + }); + }); }); diff --git a/webapp/tests/karma/js/enketo/enketo-form-manager.spec.ts b/webapp/tests/karma/js/enketo/enketo-form-manager.spec.ts new file mode 100644 index 00000000000..7924a7711db --- /dev/null +++ b/webapp/tests/karma/js/enketo/enketo-form-manager.spec.ts @@ -0,0 +1,1741 @@ +import { expect, assert } from 'chai'; +import $ from 'jquery'; +import sinon from 'sinon'; +import _ from 'lodash'; +import chai from 'chai'; +import chaiExclude from 'chai-exclude'; +import { + ContactServices, + FileServices, + FormDataServices, + TranslationServices, + XmlServices, + EnketoFormManager +} from '../../../../src/js/enketo/enketo-form-manager'; + +chai.use(chaiExclude); + +describe('Enketo Form Manager', () => { + let contactServices; + let dbBulkDocs; + let dbGet; + let dbGetAttachment; + let fileServices; + let formDataService; + let translationServices; + let xmlFormGet; + let xmlFormGetWithAttachment; + let xmlServices; + let transitionsService; + let globalActions; + let enketoFormMgr; + + let form; + let EnketoForm; + + // return a mock form ready for putting in #dbContent + const mockEnketoDoc = formInternalId => { + return { + _id: `form:${formInternalId}`, + internalId: formInternalId, + _attachments: { xml: { something: true } }, + }; + }; + + const loadXML = (name) => require(`./enketo-xml/${name}.xml`).default; + + const VISIT_MODEL = loadXML('visit'); + const VISIT_MODEL_WITH_CONTACT_SUMMARY = loadXML('visit-contact-summary'); + + beforeEach(() => { + const extractLineageService = { + extract: sinon.stub().callsFake(contact => ({ _id: contact._id })) + }; + const userContactService = { + get: sinon.stub().resolves({ _id: '123', phone: '555' }) + }; + contactServices = new ContactServices(extractLineageService, userContactService); + + dbBulkDocs = sinon.stub() + .callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); + dbGet = sinon.stub(); + dbGetAttachment = sinon.stub() + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL); + const dbService = { + get: sinon.stub().returns({ + bulkDocs: dbBulkDocs, + get: dbGet, + getAttachment: dbGetAttachment + }) + }; + const fileReaderService = { + utf8: sinon.stub().resolves('') + }; + fileServices = new FileServices(dbService, fileReaderService); + + const contactSummaryService = { get: sinon.stub() }; + const languageService = { + get: sinon.stub().resolves('en') + }; + const lineageModelGeneratorService = { contact: sinon.stub() }; + xmlFormGet = sinon.stub().resolves({ _id: 'abc' }); + xmlFormGetWithAttachment = sinon.stub().resolves({ doc: { _id: 'abc', xml: '
' } }); + const searchService = { search: sinon.stub() }; + formDataService = new FormDataServices( + contactSummaryService, + null, + languageService, + lineageModelGeneratorService, + searchService + ); + formDataService.enketoDataPrepopulatorService = { + get: sinon.stub().resolves('') + }; + + const translateService = { + get: sinon.stub() + }; + const translateFromService = { + get: sinon.stub() + }; + translationServices = new TranslationServices(translateService, translateFromService); + + const addAttachmentService = { + add: sinon.stub(), + remove: sinon.stub() + }; + const getReportContentService = { + REPORT_ATTACHMENT_NAME: 'content' + }; + const xmlFormsService = { + get: xmlFormGet, + getDocAndFormAttachment: xmlFormGetWithAttachment + }; + xmlServices = new XmlServices( + addAttachmentService, + getReportContentService, + xmlFormsService + ); + + transitionsService = { + applyTransitions: sinon.stub().returnsArg(0) + }; + globalActions = { + setSnackbarContent: sinon.stub() + }; + + enketoFormMgr = new EnketoFormManager( + contactServices, + fileServices, + formDataService, + translationServices, + xmlServices, + transitionsService, + globalActions + ); + + form = { + calc: { update: sinon.stub() }, + getDataStr: sinon.stub(), + init: sinon.stub(), + model: { getStr: sinon.stub().returns(VISIT_MODEL) }, + output: { update: sinon.stub() }, + validate: sinon.stub(), + relevant: { update: sinon.stub() }, + resetView: sinon.stub(), + pages: { + activePages: { + length: 1 + }, + _next: sinon.stub(), + _getCurrentIndex: sinon.stub() + } + }; + + EnketoForm = sinon.stub().returns(form); + window.EnketoForm = EnketoForm; + + window.CHTCore = {}; + }); + + afterEach(() => { + sinon.restore(); + delete window.CHTCore; + }); + + describe('render', () => { + it('renders error when user does not have associated contact', () => { + contactServices.userContact.get.resolves(); + return enketoFormMgr + .render(null, 'not-defined') + .then(() => { + assert.fail('Should throw error'); + }) + .catch(actual => { + expect(actual.message).to.equal('Your user does not have an associated contact, or does not have access ' + + 'to the associated contact. Talk to your administrator to correct this.'); + expect(actual.translationKey).to.equal('error.loading.form.no_contact'); + }); + }); + + it('return error when form initialisation fails', () => { + const expected = ['nope', 'still nope']; + form.init.returns(expected); + return enketoFormMgr + .render($('
'), mockEnketoDoc('myform')) + .then(() => { + assert.fail('Should throw error'); + }) + .catch(actual => { + expect(form.init.callCount).to.equal(1); + expect(actual.message).to.equal(JSON.stringify(expected)); + }); + }); + + it('return form when everything works', () => { + return enketoFormMgr + .render($('
'), mockEnketoDoc('myform')) + .then(() => { + expect(contactServices.userContact.get.callCount).to.equal(1); + expect(formDataService.enketoDataPrepopulator.get.callCount).to.equal(1); + expect(fileServices.fileReader.utf8.callCount).to.equal(2); + expect(fileServices.fileReader.utf8.args[0][0]).to.equal('
my form
'); + expect(fileServices.fileReader.utf8.args[1][0]).to.equal(VISIT_MODEL); + expect(form.init.callCount).to.equal(1); + 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'); + expect(window.CHTCore.debugFormModel()).to.equal(VISIT_MODEL); + }); + }); + + it('replaces img src with obj urls', async() => { + dbGetAttachment + .onFirstCall().resolves('
') + .onSecondCall().resolves(VISIT_MODEL) + .onThirdCall().resolves('myobjblob'); + const createObjectURL = sinon.stub().returns('myobjurl'); + window.URL.createObjectURL = createObjectURL; + fileServices.fileReader.utf8.resolves('
'); + const wrapper = $('
'); + await enketoFormMgr.render(wrapper, mockEnketoDoc('myform')); + 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 => { + // different browsers return different values but both are equivalent + return val === '' || val === 'visible'; + }); + expect(form.init.callCount).to.equal(1); + expect(createObjectURL.callCount).to.equal(1); + expect(createObjectURL.args[0][0]).to.equal('myobjblob'); + }); + + it('leaves img wrapped and hides loader if failed to load', () => { + const consoleErrorMock = sinon.stub(console, 'error'); + const createObjectURL = sinon.stub(); + window.URL.createObjectURL = createObjectURL; + dbGetAttachment + .onFirstCall().resolves('
') + .onSecondCall().resolves(VISIT_MODEL) + .onThirdCall().rejects('not found'); + fileServices.fileReader.utf8.resolves('
'); + const wrapper = $('
'); + return enketoFormMgr.render(wrapper, mockEnketoDoc('myform')).then(() => { + const img = wrapper.find('img').first(); + expect(img.attr('src')).to.equal(undefined); + expect(img.attr('data-media-src')).to.equal('myimg'); + expect(img.css('visibility')).to.equal('hidden'); + const loader = img.closest('div'); + expect(loader.hasClass('loader')).to.equal(true); + expect(loader.is(':hidden')).to.equal(true); + expect(form.init.callCount).to.equal(1); + expect(createObjectURL.callCount).to.equal(0); + expect(consoleErrorMock.callCount).to.equal(1); + expect(consoleErrorMock.args[0][0]).to.equal('Error fetching media file'); + }); + }); + + it('passes users language to Enketo', () => { + const data = '123'; + dbGetAttachment + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves('my model'); + fileServices.fileReader.utf8 + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves('my model'); + formDataService.enketoDataPrepopulator.get.resolves(data); + formDataService.languageService.get.resolves('sw'); + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), data).then(() => { + expect(formDataService.languageService.get.callCount).to.equal(1); + expect(EnketoForm.callCount).to.equal(1); + expect(EnketoForm.args[0][2].language).to.equal('sw'); + }); + }); + + it('passes xml instance data through to Enketo', () => { + const data = '123'; + fileServices.fileReader.utf8 + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves('my model'); + formDataService.enketoDataPrepopulator.get.resolves(data); + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), data).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); + }); + }); + + it('passes json instance data through to Enketo', () => { + const data = '123'; + fileServices.fileReader.utf8 + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL); + formDataService.enketoDataPrepopulator.get.resolves(data); + const instanceData = { + inputs: { + patient_id: 123, + name: 'sharon' + } + }; + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), instanceData).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); + }); + }); + + it('passes contact summary data to enketo', () => { + dbGetAttachment + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + fileServices.fileReader.utf8 + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + const instanceData = { + contact: { + _id: 'fffff', + patient_id: '44509' + }, + inputs: { + patient_id: 123, + name: 'sharon' + } + }; + formDataService.contactSummary.get.resolves({ context: { pregnant: true } }); + formDataService.search.search.resolves([{ _id: 'somereport' }]); + formDataService.lineageModelGenerator.contact.resolves({ lineage: [{ _id: 'someparent' }] }); + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), instanceData).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]; + expect(summary.id).to.equal('contact-summary'); + const xmlStr = new XMLSerializer().serializeToString(summary.xml); + expect(xmlStr).to.equal('true'); + expect(formDataService.search.search.callCount).to.equal(1); + expect(formDataService.search.search.args[0][0]).to.equal('reports'); + expect(formDataService.search.search.args[0][1].subjectIds).to.deep.equal(['fffff', '44509']); + expect(formDataService.lineageModelGenerator.contact.callCount).to.equal(1); + expect(formDataService.lineageModelGenerator.contact.args[0][0]).to.equal('fffff'); + expect(formDataService.contactSummary.get.callCount).to.equal(1); + expect(formDataService.contactSummary.get.args[0][0]._id).to.equal('fffff'); + expect(formDataService.contactSummary.get.args[0][1].length).to.equal(1); + expect(formDataService.contactSummary.get.args[0][1][0]._id).to.equal('somereport'); + expect(formDataService.contactSummary.get.args[0][2].length).to.equal(1); + expect(formDataService.contactSummary.get.args[0][2][0]._id).to.equal('someparent'); + }); + }); + + it('handles arrays and escaping characters', () => { + dbGetAttachment + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + fileServices.fileReader.utf8 + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + const instanceData = { + contact: { + _id: 'fffff' + }, + inputs: { + patient_id: 123, + name: 'sharon' + } + }; + formDataService.contactSummary.get.resolves({ + context: { + pregnant: true, + previousChildren: [{ dob: 2016 }, { dob: 2013 }, { dob: 2010 }], + notes: `always reserved "characters" & 'words'` + } + }); + formDataService.lineageModelGenerator.contact.resolves({ lineage: [] }); + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), instanceData).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]; + expect(summary.id).to.equal('contact-summary'); + const xmlStr = new XMLSerializer().serializeToString(summary.xml); + expect(xmlStr).to.equal('true2016' + + '20132010always <uses> reserved "' + + 'characters" & \'words\''); + expect(formDataService.contactSummary.get.callCount).to.equal(1); + expect(formDataService.contactSummary.get.args[0][0]._id).to.equal('fffff'); + }); + }); + + it('does not get contact summary when the form has no instance for it', () => { + dbGetAttachment + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL); + fileServices.fileReader.utf8.resolves(''); + const instanceData = { + contact: { + _id: 'fffff' + }, + inputs: { + patient_id: 123, + name: 'sharon' + } + }; + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), instanceData).then(() => { + expect(EnketoForm.callCount).to.equal(1); + expect(EnketoForm.args[0][1].external).to.equal(undefined); + expect(formDataService.contactSummary.get.callCount).to.equal(0); + expect(formDataService.lineageModelGenerator.contact.callCount).to.equal(0); + }); + }); + + it('ContactSummary receives empty lineage if contact doc is missing', () => { + const consoleWarnMock = sinon.stub(console, 'warn'); + formDataService.lineageModelGenerator.contact.rejects({ code: 404 }); + + fileServices.fileReader.utf8 + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + dbGetAttachment + .onFirstCall().resolves('
my form
') + .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); + const instanceData = { + contact: { + _id: 'fffff', + patient_id: '44509' + } + }; + formDataService.contactSummary.get.resolves({ context: { pregnant: true } }); + formDataService.search.search.resolves([{ _id: 'somereport' }]); + return enketoFormMgr.render($('
'), mockEnketoDoc('myform'), instanceData).then(() => { + expect(formDataService.lineageModelGenerator.contact.callCount).to.equal(1); + expect(formDataService.lineageModelGenerator.contact.args[0][0]).to.equal('fffff'); + expect(formDataService.contactSummary.get.callCount).to.equal(1); + expect(formDataService.contactSummary.get.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; + }); + }); + }); + + describe('validate', () => { + let inputRelevant; + let inputNonRelevant; + let inputNoDataset; + + beforeEach(() => { + inputRelevant = { dataset: { relevant: 'true' } }; + inputNonRelevant = { dataset: { relevant: 'false' } }; + inputNoDataset = {}; + const toArray = sinon.stub().returns([inputRelevant, inputNoDataset, inputNonRelevant]); + // @ts-ignore + sinon.stub($.fn, 'find').returns({ toArray }); + }); + + it('rejects on invalid form', () => { + form.validate.resolves(false); + + return enketoFormMgr.validate(form) + .then(() => assert.fail('An error should have been thrown.')) + .catch(actual => { + expect(actual.message).to.equal('Form is invalid'); + expect(inputRelevant.dataset.relevant).to.equal('true'); + expect(inputNonRelevant.dataset.relevant).to.equal('false'); + expect(inputNoDataset.dataset).to.be.undefined; + expect(form.validate.callCount).to.equal(1); + }); + }); + }); + + describe('save', () => { + it('creates report', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + return enketoFormMgr.save('V', form).then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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(xmlFormGetWithAttachment.callCount).to.equal(1); + expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); + expect(xmlServices.addAttachment.remove.callCount).to.equal(1); + expect(xmlServices.addAttachment.remove.args[0][0]._id).to.equal(actual._id); + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + + it('saves form version if found', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + xmlFormGetWithAttachment.resolves({ + doc: { _id: 'abc', xmlVersion: { time: '1', sha256: 'imahash' } }, + xml: '
' + }); + return enketoFormMgr.save('V', form).then(actual => { + actual = actual[0]; + expect(actual.form_version).to.deep.equal({ time: '1', sha256: 'imahash' }); + expect(xmlFormGetWithAttachment.callCount).to.equal(1); + expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); + }); + }); + + describe('Geolocation recording', () => { + it('saves geolocation data into a new report', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + const geoData = { + latitude: 1, + longitude: 2, + altitude: 3, + accuracy: 4, + altitudeAccuracy: 5, + heading: 6, + speed: 7 + }; + return enketoFormMgr.save('V', form, () => Promise.resolve(geoData)).then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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(xmlServices.addAttachment.remove.callCount).to.equal(1); + expect(xmlServices.addAttachment.remove.args[0][0]._id).to.equal(actual._id); + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + + it('saves a geolocation error into a new report', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + const geoError = { + code: 42, + message: 'some bad geo' + }; + return enketoFormMgr.save('V', form, () => Promise.reject(geoError)).then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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(xmlServices.addAttachment.remove.callCount).to.equal(1); + expect(xmlServices.addAttachment.remove.args[0][0]._id).to.equal(actual._id); + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + + it('overwrites existing geolocation info on edit with new info and appends to the log', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + const originalGeoData = { + latitude: 1, + longitude: 2, + altitude: 3, + accuracy: 4, + altitudeAccuracy: 5, + heading: 6, + speed: 7 + }; + const originalGeoLogEntry = { + timestamp: 12345, + recording: originalGeoData + }; + dbGet.resolves({ + _id: '6', + _rev: '1-abc', + form: 'V', + fields: { name: 'Silly' }, + content: 'Silly', + content_type: 'xml', + type: 'data_record', + reported_date: 500, + geolocation: originalGeoData, + geolocation_log: [originalGeoLogEntry] + }); + dbBulkDocs.resolves([{ ok: true, id: '6', rev: '2-abc' }]); + const geoData = { + latitude: 10, + longitude: 11, + altitude: 12, + accuracy: 13, + altitudeAccuracy: 14, + heading: 15, + speed: 16 + }; + return enketoFormMgr.save('V', form, () => Promise.resolve(geoData), '6').then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbGet.callCount).to.equal(1); + expect(dbGet.args[0][0]).to.equal('6'); + 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(xmlServices.addAttachment.remove.callCount).to.equal(1); + expect(xmlServices.addAttachment.remove.args[0][0]._id).to.equal(actual._id); + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + + it('creates report with erroring geolocation', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + const geoError = { + code: 42, + message: 'geolocation failed for some reason' + }; + return enketoFormMgr.save('V', form, () => Promise.reject(geoError)).then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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(xmlServices.addAttachment.remove.callCount).to.equal(1); + expect(xmlServices.addAttachment.remove.args[0][0]._id).to.equal(actual._id); + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + }); + + it('creates report with hidden fields', () => { + const content = loadXML('hidden-field'); + form.getDataStr.returns(content); + dbBulkDocs.resolves([{ ok: true, id: '(generated-in-service)', rev: '1-abc' }]); + + return enketoFormMgr.save('V', form, null, null).then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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']); + }); + }); + + it('updates report', () => { + const content = loadXML('sally-lmp'); + form.getDataStr.returns(content); + dbGet.resolves({ + _id: '6', + _rev: '1-abc', + form: 'V', + fields: { name: 'Silly' }, + content: 'Silly', + content_type: 'xml', + type: 'data_record', + reported_date: 500, + }); + dbBulkDocs.resolves([{ ok: true, id: '6', rev: '2-abc' }]); + return enketoFormMgr.save('V', form, null, '6').then(actual => { + actual = actual[0]; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbGet.callCount).to.equal(1); + expect(dbGet.args[0][0]).to.equal('6'); + 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(xmlServices.addAttachment.remove.callCount).to.equal(1); + expect(xmlServices.addAttachment.remove.args[0][0]._id).to.equal(actual._id); + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + + it('creates extra docs', () => { + const startTime = Date.now() - 1; + + 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` }; + })); + }); + + return enketoFormMgr.save('V', form, null, null).then(actual => { + const endTime = Date.now() + 1;//console.log(JSON.stringify(actual)) + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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.deep.equal(['doc1', 'doc2', 'secret_code_name']); + + 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 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); + }); + }); + + it('creates extra docs with geolocation', () => { + + const startTime = Date.now() - 1; + + 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' } + ]); + const geoData = { + latitude: 1, + longitude: 2, + altitude: 3, + accuracy: 4, + altitudeAccuracy: 5, + heading: 6, + speed: 7 + }; + return enketoFormMgr.save('V', form, () => Promise.resolve(geoData)).then(actual => { + const endTime = Date.now() + 1; + + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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.deep.equal(['doc1', 'doc2', 'secret_code_name']); + + 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); + + 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'); + + expect(actualThing2.geolocation).to.deep.equal(geoData); + + expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); + }); + }); + + it('creates extra docs with references', () => { + const content = loadXML('extra-docs-with-references'); + 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' } + ]); + + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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.deep.equal(['doc1', 'doc2', 'secret_code_name']); + + 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); + }); + }); + + it('creates extra docs with repeats', () => { + const content = loadXML('extra-docs-with-repeat'); + 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' }, + { ok: true, id: '9', rev: '1-ghi' } + ]); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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.deep.equal(['repeat_doc', 'secret_code_name']); + + 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); + } + + expect(_.uniq(_.map(actual, '_id')).length).to.equal(4); + }); + }); + + it('db-doc-ref with repeats', () => { + const content = loadXML('db-doc-ref-in-repeat'); + 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' }, + { ok: true, id: '9', rev: '1-ghi' } + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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, + }); + }); + }); + + it('db-doc-ref with deep repeats', () => { + const content = loadXML('db-doc-ref-in-deep-repeat'); + 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' }, + { ok: true, id: '9', rev: '1-ghi' } + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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, + }); + }); + }); + + it('db-doc-ref with deep repeats and non-db-doc repeats', () => { + const content = loadXML('db-doc-ref-in-deep-repeats-extra-repeats'); + 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' }, + { ok: true, id: '9', rev: '1-ghi' } + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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, + }); + }); + }); + + it('db-doc-ref with repeats and local references', () => { + const content = loadXML('db-doc-ref-in-repeats-with-local-references'); + 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' }, + { ok: true, id: '9', rev: '1-ghi' } + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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, + }); + }); + }); + + it('db-doc-ref with deep repeats and local references', () => { + const content = loadXML('db-doc-ref-in-deep-repeats-with-local-references'); + 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' }, + { ok: true, id: '9', rev: '1-ghi' } + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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, + }); + }); + }); + + it('db-doc-ref with repeats with refs outside of repeat', () => { + const content = loadXML('db-doc-ref-outside-of-repeat'); + form.getDataStr.returns(content); + + dbBulkDocs.resolves([ + { ok: true, id: '6', rev: '1-abc' }, + { ok: true, id: '7', rev: '1-def' }, + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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, + }); + }); + }); + + it('db-doc-ref with repeats with db-doc as repeat', () => { + const content = loadXML('db-doc-ref-same-as-repeat'); + form.getDataStr.returns(content); + + dbBulkDocs.resolves([ + { ok: true, id: '6', rev: '1-abc' }, + { ok: true, id: '7', rev: '1-def' }, + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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('db-doc-ref with repeats with invalid ref', () => { + const content = loadXML('db-doc-ref-broken-ref'); + form.getDataStr.returns(content); + + dbBulkDocs.resolves([ + { ok: true, id: '6', rev: '1-abc' }, + { ok: true, id: '7', rev: '1-def' }, + ]); + xmlFormGetWithAttachment.resolves({ + xml: ` + + + + `, + doc: { _id: 'abc' } + }); + return enketoFormMgr.save('V', form).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(contactServices.userContact.get.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', + }); + }); + }); + + describe('Saving attachments', () => { + it('should save attachments', () => { + const jqFind = $.fn.find; + sinon.stub($.fn, 'find'); + //@ts-ignore + $.fn.find.callsFake(jqFind); + + $.fn.find + //@ts-ignore + .withArgs('input[type=file][name="/my-form/my_file"]') + .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); + + const content = loadXML('file-field'); + + form.getDataStr.returns(content); + + return enketoFormMgr + .save('my-form', form, () => Promise.resolve(true)) + .then(() => { + expect(xmlServices.addAttachment.add.calledTwice); + expect(dbBulkDocs.calledOnce); + + expect(xmlServices.addAttachment.add.args[0][1]).to.equal('user-file/my-form/my_file'); + expect(xmlServices.addAttachment.add.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); + expect(xmlServices.addAttachment.add.args[0][3]).to.equal('image'); + + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + expect(globalActions.setSnackbarContent.notCalled); + }); + }); + + it('should throw exception if attachments are big', () => { + translationServices.translateService.get.resolvesArg(0); + + const jqFind = $.fn.find; + sinon.stub($.fn, 'find'); + //@ts-ignore + $.fn.find.callsFake(jqFind); + + $.fn.find + //@ts-ignore + .withArgs('input[type=file][name="/my-form/my_file"]') + .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); + + const docsToStoreStub = sinon.stub().returns([ + { _id: '1a' }, + { _id: '1b', _attachments: {} }, + { + _id: '1c', + _attachments: { + a_file: { data: { size: 10 * 1024 * 1024 } }, + b_file: { data: 'SSdtIGJhdG1hbg==' }, + c_file: { data: { size: 20 * 1024 * 1024 } }, + } + }, + { + _id: '1d', + _attachments: { + a_file: { content_type: 'image/png' } + } + } + ]); + $.fn.find + //@ts-ignore + .withArgs('[db-doc=true]') + .returns({ map: sinon.stub().returns({ get: docsToStoreStub }) }); + + const content = loadXML('file-field'); + form.getDataStr.returns(content); + + return enketoFormMgr + .save('my-form', form, () => Promise.resolve(true)) + .then(() => expect.fail('Should have thrown exception.')) + .catch(error => { + expect(docsToStoreStub.calledOnce); + expect(error.message).to.equal('enketo.error.max_attachment_size'); + expect(dbBulkDocs.notCalled); + expect(xmlServices.addAttachment.add.notCalled); + expect(globalActions.setSnackbarContent.calledOnce); + expect(globalActions.setSnackbarContent.args[0]).to.have.members(['enketo.error.max_attachment_size']); + }); + }); + + it('should remove binary data from content', () => { + const content = loadXML('binary-field'); + + form.getDataStr.returns(content); + return enketoFormMgr.save('my-form', form, () => Promise.resolve(true)).then(() => { + expect(xmlServices.addAttachment.add.callCount).to.equal(1); + + expect(xmlServices.addAttachment.add.args[0][1]).to.equal('user-file/my-form/my_file'); + expect(xmlServices.addAttachment.add.args[0][2]).to.deep.equal('some image data'); + expect(xmlServices.addAttachment.add.args[0][3]).to.equal('image/png'); + + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + + it('should assign attachment names relative to the form name not the root node name', () => { + const jqFind = $.fn.find; + sinon.stub($.fn, 'find'); + //@ts-ignore + $.fn.find.callsFake(jqFind); + $.fn.find + //@ts-ignore + .withArgs('input[type=file][name="/my-root-element/my_file"]') + .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); + $.fn.find + //@ts-ignore + .withArgs('input[type=file][name="/my-root-element/sub_element/sub_sub_element/other_file"]') + .returns([{ files: [{ type: 'mytype', foo: 'baz' }] }]); + const content = loadXML('deep-file-fields'); + + form.getDataStr.returns(content); + return enketoFormMgr.save('my-form-internal-id', form, () => Promise.resolve(true)).then(() => { + expect(xmlServices.addAttachment.add.callCount).to.equal(2); + + expect(xmlServices.addAttachment.add.args[0][1]).to.equal('user-file/my-form-internal-id/my_file'); + expect(xmlServices.addAttachment.add.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); + expect(xmlServices.addAttachment.add.args[0][3]).to.equal('image'); + + expect(xmlServices.addAttachment.add.args[1][1]) + .to.equal('user-file/my-form-internal-id/sub_element/sub_sub_element/other_file'); + expect(xmlServices.addAttachment.add.args[1][2]).to.deep.equal({ type: 'mytype', foo: 'baz' }); + expect(xmlServices.addAttachment.add.args[1][3]).to.equal('mytype'); + + expect(xmlServices.addAttachment.remove.args[0][1]).to.equal('content'); + }); + }); + }); + + it('should pass docs to transitions and save results', () => { + const content = + ` + Sally + 10 + + repeater + some_value_1 + + + repeater + some_value_2 + + + repeater + some_value_3 + + `; + form.getDataStr.returns(content); + + const geoHandle = sinon.stub().resolves({ geo: 'data' }); + transitionsService.applyTransitions = sinon.stub().callsFake((docs) => { + const clones = _.cloneDeep(docs); // cloning for clearer assertions, as the main array gets mutated + clones.forEach(clone => clone.transitioned = true); + clones.push({ _id: 'new doc', type: 'existent doc updated by the transition' }); + return Promise.resolve(clones); + }); + + return enketoFormMgr.save('V', form, geoHandle).then(actual => { + expect(form.getDataStr.callCount).to.equal(1); + expect(dbBulkDocs.callCount).to.equal(1); + expect(transitionsService.applyTransitions.callCount).to.equal(1); + expect(contactServices.userContact.get.callCount).to.equal(1); + + expect(transitionsService.applyTransitions.args[0][0].length).to.equal(4); + expect(transitionsService.applyTransitions.args[0][0]) + .excludingEvery(['_id', 'reported_date', 'timestamp']) + .to.deep.equal([ + { + contact: {}, + content_type: 'xml', + fields: { name: 'Sally', lmp: '10', repeat_doc: { some_property: 'some_value_3', type: 'repeater' } }, + hidden_fields: ['repeat_doc'], + form: 'V', + from: '555', + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'data_record', + }, + { + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'repeater', + some_property: 'some_value_1', + }, + { + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'repeater', + some_property: 'some_value_2', + }, + { + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'repeater', + some_property: 'some_value_3', + }, + ]); + + expect(actual.length).to.equal(5); + expect(actual) + .excludingEvery(['_id', 'reported_date', 'timestamp', '_rev']) + .to.deep.equal([ + { + contact: {}, + content_type: 'xml', + fields: { name: 'Sally', lmp: '10', repeat_doc: { some_property: 'some_value_3', type: 'repeater' } }, + hidden_fields: ['repeat_doc'], + form: 'V', + from: '555', + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'data_record', + transitioned: true, + }, + { + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'repeater', + some_property: 'some_value_1', + transitioned: true, + }, + { + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'repeater', + some_property: 'some_value_2', + transitioned: true, + }, + { + geolocation: { geo: 'data' }, + geolocation_log: [{ recording: { geo: 'data' } }], + type: 'repeater', + some_property: 'some_value_3', + transitioned: true, + }, + { + // docs that transitions push don't have geodata, this is intentional! + type: 'existent doc updated by the transition', + }, + ]); + }); + }); + }); + + describe('saveContactForm', () => { + it('saves contact form and sets last changed doc', () => { + const docId = '12345'; + const docType = 'doc-type'; + const savedDocs = {}; + const contactSave = sinon.stub().resolves(savedDocs); + enketoFormMgr.contactSaver.save = contactSave; + + return enketoFormMgr + .saveContactForm(form, docId, docType) + .then((docs) => { + expect(docs).to.deep.equal(savedDocs); + expect(contactSave.callCount).to.equal(1); + expect(contactSave.args).to.deep.equal([[form, docId, docType, undefined]]); + }); + }); + }); + + describe('renderContactForm', () => { + let titleTextStub; + + beforeEach(() => { + titleTextStub = sinon.stub(); + + const jqFind = $.fn.find; + sinon.stub($.fn, 'find'); + //@ts-ignore + $.fn.find.callsFake(jqFind); + + $.fn.find + //@ts-ignore + .withArgs('#form-title') + .returns({ text: titleTextStub }); + dbGetAttachment.resolves(''); + translationServices.translate.get.callsFake((key) => `translated key ${key}`); + translationServices.translateFrom.get.callsFake((sentence) => `translated sentence ${sentence}`); + }); + + const callbackMock = () => {}; + const instanceData = { + health_center: { + type: 'contact', + contact_type: 'health_center', + parent: 'parent', + }, + }; + const formDoc = { + ...mockEnketoDoc('myform'), + title: 'New Area', + }; + + it('should translate titleKey when provided', async() => { + await enketoFormMgr.renderContactForm({ + selector: $('
'), + formDoc, + instanceData, + editedListener: callbackMock, + valuechangeListener: callbackMock, + titleKey: 'contact.type.health_center.new', + }); + + expect(titleTextStub.callCount).to.be.equal(1); + expect(titleTextStub.args[0][0]).to.be.equal('translated key contact.type.health_center.new'); + }); + + it('should fallback to translate document title when the titleKey is not available', async() => { + await enketoFormMgr.renderContactForm({ + selector: $('
'), + formDoc, + instanceData, + editedListener: callbackMock, + valuechangeListener: callbackMock, + }); + + expect(titleTextStub.callCount).to.be.equal(1); + expect(titleTextStub.args[0][0]).to.be.equal('translated sentence New Area'); + }); + }); + + describe('multimedia', () => { + let pauseStubs; + let $form; + let $nextBtn; + let $prevBtn; + let originalJQueryFind; + + before(() => { + $nextBtn = $(''); + $prevBtn = $(''); + originalJQueryFind = $.fn.find; + }); + + beforeEach(() => { + $form = $(`
`); + $form + .append($nextBtn) + .append($prevBtn); + + pauseStubs = {}; + sinon + .stub($.fn, 'find') + .callsFake(selector => { + const result = originalJQueryFind.call($form, selector); + + result.each((idx, element) => { + if (element.pause) { + pauseStubs[element.id] = sinon.stub(element, 'pause'); + } + }); + return result; + }); + }); + + after(() => $.fn.find = originalJQueryFind); + + it('should pause the multimedia when going to the previous page', function(done) { + $form.prepend(''); + + // eslint-disable-next-line promise/catch-or-return + enketoFormMgr + .render($form, mockEnketoDoc('myform')) + .then(() => { + $prevBtn.trigger('click.pagemode'); + + setTimeout(() => { + expect(pauseStubs.video.calledOnce).to.be.true; + expect(pauseStubs.audio.calledOnce).to.be.true; + done(); + }, 0); + }); + }); + + it('should pause the multimedia when going to the next page', function(done) { + form.pages._next.resolves(true); + $form.prepend(''); + + // eslint-disable-next-line promise/catch-or-return + enketoFormMgr + .render($form, mockEnketoDoc('myform')) + .then(() => { + $nextBtn.trigger('click.pagemode'); + + setTimeout(() => { + expect(pauseStubs.video.calledOnce).to.be.true; + expect(pauseStubs.audio.calledOnce).to.be.true; + done(); + }, 0); + }); + }); + + it('should not pause the multimedia when trying to go to the next page and form is invalid', function(done) { + form.pages._next.resolves(false); + $form.prepend(''); + + // eslint-disable-next-line promise/catch-or-return + enketoFormMgr + .render($form, mockEnketoDoc('myform')) + .then(() => { + $nextBtn.trigger('click.pagemode'); + + setTimeout(() => { + expect(pauseStubs.video).to.be.undefined; + expect(pauseStubs.audio).to.be.undefined; + done(); + }, 0); + }); + }); + }); +}); diff --git a/webapp/tests/karma/ts/services/enketo-xml/binary-field.xml b/webapp/tests/karma/js/enketo/enketo-xml/binary-field.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/binary-field.xml rename to webapp/tests/karma/js/enketo/enketo-xml/binary-field.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-broken-ref.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-broken-ref.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-broken-ref.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-broken-ref.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-deep-repeat.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-deep-repeat.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-deep-repeat.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-deep-repeat.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-deep-repeats-extra-repeats.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-deep-repeats-extra-repeats.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-deep-repeats-extra-repeats.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-deep-repeats-extra-repeats.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-deep-repeats-with-local-references.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-deep-repeats-with-local-references.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-deep-repeats-with-local-references.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-deep-repeats-with-local-references.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-repeat.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-repeat.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-repeat.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-repeat.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-repeats-with-local-references.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-repeats-with-local-references.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-in-repeats-with-local-references.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-in-repeats-with-local-references.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-outside-of-repeat.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-outside-of-repeat.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-outside-of-repeat.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-outside-of-repeat.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-same-as-repeat.xml b/webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-same-as-repeat.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/db-doc-ref-same-as-repeat.xml rename to webapp/tests/karma/js/enketo/enketo-xml/db-doc-ref-same-as-repeat.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/deep-file-fields.xml b/webapp/tests/karma/js/enketo/enketo-xml/deep-file-fields.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/deep-file-fields.xml rename to webapp/tests/karma/js/enketo/enketo-xml/deep-file-fields.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/extra-docs-with-references.xml b/webapp/tests/karma/js/enketo/enketo-xml/extra-docs-with-references.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/extra-docs-with-references.xml rename to webapp/tests/karma/js/enketo/enketo-xml/extra-docs-with-references.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/extra-docs-with-repeat.xml b/webapp/tests/karma/js/enketo/enketo-xml/extra-docs-with-repeat.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/extra-docs-with-repeat.xml rename to webapp/tests/karma/js/enketo/enketo-xml/extra-docs-with-repeat.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/extra-docs.xml b/webapp/tests/karma/js/enketo/enketo-xml/extra-docs.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/extra-docs.xml rename to webapp/tests/karma/js/enketo/enketo-xml/extra-docs.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/file-field.xml b/webapp/tests/karma/js/enketo/enketo-xml/file-field.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/file-field.xml rename to webapp/tests/karma/js/enketo/enketo-xml/file-field.xml diff --git a/webapp/tests/karma/ts/services/enketo-xml/hidden-field.xml b/webapp/tests/karma/js/enketo/enketo-xml/hidden-field.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/hidden-field.xml rename to webapp/tests/karma/js/enketo/enketo-xml/hidden-field.xml diff --git a/webapp/tests/karma/js/enketo/enketo-xml/sally-lmp.xml b/webapp/tests/karma/js/enketo/enketo-xml/sally-lmp.xml new file mode 100644 index 00000000000..a434e2dd0b7 --- /dev/null +++ b/webapp/tests/karma/js/enketo/enketo-xml/sally-lmp.xml @@ -0,0 +1,4 @@ + + Sally + 10 + diff --git a/webapp/tests/karma/ts/services/enketo-xml/visit-contact-summary.xml b/webapp/tests/karma/js/enketo/enketo-xml/visit-contact-summary.xml similarity index 100% rename from webapp/tests/karma/ts/services/enketo-xml/visit-contact-summary.xml rename to webapp/tests/karma/js/enketo/enketo-xml/visit-contact-summary.xml diff --git a/webapp/tests/karma/js/enketo/enketo-xml/visit.xml b/webapp/tests/karma/js/enketo/enketo-xml/visit.xml new file mode 100644 index 00000000000..c9ba609d42b --- /dev/null +++ b/webapp/tests/karma/js/enketo/enketo-xml/visit.xml @@ -0,0 +1,24 @@ + + + + + + + + + <_id tag="ui"/> + + + + + + + + + Patient ID + + + + + + 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 b58d4cbee2e..95a8221c1bd 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 @@ -16,7 +16,6 @@ import { DbService } from '@mm-services/db.service'; import { Selectors } from '@mm-selectors/index'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; import { EnketoService } from '@mm-services/enketo.service'; -import { ContactSaveService } from '@mm-services/contact-save.service'; import { GlobalActions } from '@mm-actions/global'; @@ -31,7 +30,6 @@ describe('ContactsEdit component', () => { let component; let enketoService; let lineageModelGeneratorService; - let contactSaveService; let routeSnapshot; beforeEach(() => { @@ -53,9 +51,9 @@ describe('ContactsEdit component', () => { enketoService = { renderContactForm: sinon.stub(), unload: sinon.stub(), + saveContactForm: sinon.stub() }; lineageModelGeneratorService = { contact: sinon.stub().resolves({ doc: { } }) }; - contactSaveService = { save: sinon.stub() }; sinon.stub(console, 'error'); @@ -83,7 +81,6 @@ describe('ContactsEdit component', () => { { provide: LineageModelGeneratorService, useValue: lineageModelGeneratorService }, { provide: EnketoService, useValue: enketoService }, { provide: ContactTypesService, useValue: contactTypesService }, - { provide: ContactSaveService, useValue: contactSaveService }, ], declarations: [ EnketoComponent, @@ -556,7 +553,7 @@ describe('ContactsEdit component', () => { component.enketoSaving = true; await component.save(); - expect(contactSaveService.save.callCount).to.equal(0); + expect(enketoService.saveContactForm.callCount).to.equal(0); expect(setEnketoSavingStatus.callCount).to.equal(0); expect(setEnketoError.callCount).to.equal(0); }); @@ -577,7 +574,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(setEnketoError.args).to.deep.equal([[null]]); expect(component.enketoContact.formInstance.validate.callCount).to.equal(1); - expect(contactSaveService.save.callCount).to.equal(0); + expect(enketoService.saveContactForm.callCount).to.equal(0); }); it('should catch save errors', async () => { @@ -590,13 +587,13 @@ describe('ContactsEdit component', () => { }, type: 'some_contact', }; - contactSaveService.save.rejects({ some: 'error' }); + enketoService.saveContactForm.rejects({ some: 'error' }); 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(contactSaveService.save.callCount).to.equal(1); + expect(enketoService.saveContactForm.callCount).to.equal(1); expect(setEnketoError.callCount).to.equal(2); }); @@ -615,15 +612,15 @@ describe('ContactsEdit component', () => { await createComponent(); await fixture.whenStable(); - contactSaveService.save.resolves({ docId: 'new_clinic_id' }); + enketoService.saveContactForm.resolves({ docId: 'new_clinic_id' }); await component.save(); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); - expect(contactSaveService.save.callCount).to.equal(1); - expect(contactSaveService.save.args[0]).to.deep.equal([ form, null, 'clinic', undefined ]); + expect(enketoService.saveContactForm.callCount).to.equal(1); + expect(enketoService.saveContactForm.args[0]).to.deep.equal([ form, null, 'clinic', undefined ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); }); @@ -650,15 +647,15 @@ describe('ContactsEdit component', () => { await createComponent(); await fixture.whenStable(); - contactSaveService.save.resolves({ docId: 'the_person' }); + enketoService.saveContactForm.resolves({ docId: 'the_person' }); await component.save(); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); - expect(contactSaveService.save.callCount).to.equal(1); - expect(contactSaveService.save.args[0]).to.deep.equal([ form, 'the_person', 'person', undefined ]); + expect(enketoService.saveContactForm.callCount).to.equal(1); + expect(enketoService.saveContactForm.args[0]).to.deep.equal([ form, 'the_person', 'person', undefined ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]); }); @@ -685,15 +682,15 @@ describe('ContactsEdit component', () => { await createComponent(); await fixture.whenStable(); - contactSaveService.save.resolves({ docId: 'the_patient' }); + enketoService.saveContactForm.resolves({ docId: 'the_patient' }); await component.save(); expect(setEnketoSavingStatus.callCount).to.equal(2); expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); - expect(contactSaveService.save.callCount).to.equal(1); - expect(contactSaveService.save.args[0]).to.deep.equal([ form, 'the_patient', 'patient', undefined ]); + expect(enketoService.saveContactForm.callCount).to.equal(1); + expect(enketoService.saveContactForm.args[0]).to.deep.equal([ form, 'the_patient', 'patient', undefined ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]); }); 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 deleted file mode 100644 index bef31d2e2a2..00000000000 --- a/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import sinon from 'sinon'; -import { expect, assert } from 'chai'; - -import { EnketoPrepopulationDataService } from '@mm-services/enketo-prepopulation-data.service'; -import { UserSettingsService } from '@mm-services/user-settings.service'; -import { LanguageService } from '@mm-services/language.service'; - -describe('EnketoPrepopulationData service', () => { - let service; - let UserSettings; - let languageSettings; - - const generatedForm = - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - const editPersonForm = - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - 'person' + - 'PARENT' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '0' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - const editPersonFormWithoutInputs = - '' + - '' + - '' + - '' + - '' + - 'person' + - 'PARENT' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '0' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - const pregnancyForm = - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - 'person' + - 'PARENT' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '0' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - - beforeEach(() => { - UserSettings = sinon.stub(); - languageSettings = sinon.stub(); - TestBed.configureTestingModule({ - providers: [ - { provide: UserSettingsService, useValue: { get: UserSettings } }, - { provide: LanguageService, useValue: { get: languageSettings } }, - ] - }); - service = TestBed.inject(EnketoPrepopulationDataService); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('exists', function() { - assert.isDefined(service); - }); - - it('returns the given string', () => { - const model = ''; - const data = ''; - return service.get(model, data).then((actual) => { - expect(actual).to.equal(data); - }); - }); - - it('rejects when user settings fails', () => { - const model = ''; - const data = {}; - UserSettings.rejects('phail'); - return service - .get(model, data) - .then(() => assert.fail('Expected fail')) - .catch((err) => { - expect(err.name).to.equal('phail'); - expect(UserSettings.callCount).to.equal(1); - }); - }); - - it('binds user details into model', () => { - const data = {}; - const user = { name: 'geoff' }; - UserSettings.resolves(user); - return service - .get(editPersonForm, data) - .then((actual) => { - const xml = $($.parseXML(actual)); - expect(xml.find('inputs > user > name')[0].innerHTML).to.equal(user.name); - expect(UserSettings.callCount).to.equal(1); - }); - }); - - it('binds form content into model', () => { - const data = { person: { last_name: 'salmon' } }; - const user = { name: 'geoff' }; - UserSettings.resolves(user); - return service - .get(editPersonFormWithoutInputs, data) - .then((actual) => { - const xml = $($.parseXML(actual)); - expect(xml.find('data > person > last_name')[0].innerHTML).to.equal(data.person.last_name); - expect(UserSettings.callCount).to.equal(1); - }); - }); - - it('binds form content into generated form model', () => { - const data = { person: { name: 'sally' } }; - const user = { name: 'geoff' }; - UserSettings.resolves(user); - return service - .get(generatedForm, data) - .then((actual) => { - const xml = $($.parseXML(actual)); - expect(xml.find('data > person > name')[0].innerHTML).to.equal(data.person.name); - expect(UserSettings.callCount).to.equal(1); - }); - }); - - it('binds user details, user language and form content into model', () => { - const data = { person: { last_name: 'salmon' } }; - const user = { name: 'geoff' }; - UserSettings.resolves(user); - languageSettings.resolves('en'); - return service - .get(editPersonForm, data) - .then((actual) => { - const xml = $($.parseXML(actual)); - expect(xml.find('inputs > user > name')[0].innerHTML).to.equal(user.name); - expect(xml.find('inputs > user > language')[0].innerHTML).to.equal('en'); - expect(xml.find('data > person > last_name')[0].innerHTML).to.equal(data.person.last_name); - expect(UserSettings.callCount).to.equal(1); - expect(languageSettings.callCount).to.equal(1); - }); - }); - - it('binds form content into model with custom root node', () => { - const data = { person: { last_name: 'salmon' } }; - const user = { name: 'geoff' }; - UserSettings.resolves(user); - return service - .get(pregnancyForm, data) - .then((actual) => { - const xml = $($.parseXML(actual)); - 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); - expect(UserSettings.callCount).to.equal(1); - }); - }); -}); diff --git a/webapp/tests/karma/ts/services/enketo.service.spec.ts b/webapp/tests/karma/ts/services/enketo.service.spec.ts index 5e7aacc8fc6..a60aa85a91c 100644 --- a/webapp/tests/karma/ts/services/enketo.service.spec.ts +++ b/webapp/tests/karma/ts/services/enketo.service.spec.ts @@ -1,8 +1,7 @@ -import { fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { 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 { toBik_text } from 'bikram-sambat'; import * as moment from 'moment'; @@ -11,22 +10,23 @@ 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 { ExtractLineageService } from '@mm-services/extract-lineage.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 { LanguageService } from '@mm-services/language.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 { EnketoService } from '@mm-services/enketo.service'; import { ServicesActions } from '@mm-actions/services'; import { ContactSummaryService } from '@mm-services/contact-summary.service'; +import { ContactTypesService } from '@mm-services/contact-types.service'; import { TransitionsService } from '@mm-services/transitions.service'; import { TranslateService } from '@mm-services/translate.service'; -import { GlobalActions } from '@mm-actions/global'; import * as medicXpathExtensions from '../../../../src/js/enketo/medic-xpath-extensions'; +import EnketoDataTranslator from '../../../../src/js/enketo/enketo-data-translator'; describe('Enketo service', () => { // return a mock form ready for putting in #dbContent @@ -41,7 +41,6 @@ describe('Enketo service', () => { const loadXML = (name) => require(`./enketo-xml/${name}.xml`).default; const VISIT_MODEL = loadXML('visit'); - const VISIT_MODEL_WITH_CONTACT_SUMMARY = loadXML('visit-contact-summary'); let service; let setLastChangedDoc; @@ -62,16 +61,16 @@ describe('Enketo service', () => { let AddAttachment; let removeAttachment; let EnketoForm; - let EnketoPrepopulationData; let Search; let LineageModelGenerator; + let contactTypesService; + let extractLineageService; let transitionsService; let translateService; let xmlFormGet; let xmlFormGetWithAttachment; let zScoreService; let zScoreUtil; - let globalActions; beforeEach(() => { enketoInit = sinon.stub(); @@ -93,7 +92,6 @@ describe('Enketo service', () => { AddAttachment = sinon.stub(); removeAttachment = sinon.stub(); EnketoForm = sinon.stub(); - EnketoPrepopulationData = sinon.stub(); Search = sinon.stub(); LineageModelGenerator = { contact: sinon.stub() }; xmlFormGet = sinon.stub().resolves({ _id: 'abc' }); @@ -107,12 +105,14 @@ describe('Enketo service', () => { }, init: enketoInit, langs: { - setAll: () => { }, + setAll: () => {}, $formLanguages: $(''), }, - calc: { update: () => { } }, - output: { update: () => { } }, + calc: { update: () => {} }, + output: { update: () => {} }, }); + contactTypesService = { isHardcodedType: sinon.stub().returns(false) }; + extractLineageService = { extract: sinon.stub() }; transitionsService = { applyTransitions: sinon.stub().resolvesArg(0) }; translateService = { instant: sinon.stub().returnsArg(0), @@ -120,7 +120,6 @@ describe('Enketo service', () => { }; zScoreUtil = sinon.stub(); zScoreService = { getScoreUtil: sinon.stub().resolves(zScoreUtil) }; - globalActions = { setSnackbarContent: sinon.stub(GlobalActions.prototype, 'setSnackbarContent') }; setLastChangedDoc = sinon.stub(ServicesActions.prototype, 'setLastChangedDoc'); TestBed.configureTestingModule({ @@ -137,12 +136,13 @@ describe('Enketo service', () => { { provide: SearchService, useValue: { search: Search } }, { provide: SettingsService, useValue: { get: sinon.stub().resolves({}) } }, { provide: LineageModelGeneratorService, useValue: LineageModelGenerator }, + { provide: ContactTypesService, useValue: contactTypesService }, + { provide: ExtractLineageService, useValue: extractLineageService }, { provide: FileReaderService, useValue: FileReader }, { provide: UserContactService, useValue: { get: UserContact } }, { provide: UserSettingsService, useValue: { get: UserSettings } }, { provide: LanguageService, useValue: { get: Language } }, { provide: TranslateFromService, useValue: { get: TranslateFrom } }, - { provide: EnketoPrepopulationDataService, useValue: { get: EnketoPrepopulationData } }, { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: XmlFormsService, @@ -195,50 +195,17 @@ describe('Enketo service', () => { describe('render', () => { - it('renders error when user does not have associated contact', () => { - UserContact.resolves(); - return service - .render(null, 'not-defined') - .then(() => { - expect.fail('Should throw error'); - }) - .catch(actual => { - expect(actual.message).to.equal('Your user does not have an associated contact, or does not have access ' + - 'to the associated contact. Talk to your administrator to correct this.'); - expect(actual.translationKey).to.equal('error.loading.form.no_contact'); - }); - }); - - it('return error when form initialisation fails', () => { - UserContact.resolves({ contact_id: '123' }); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); - EnketoPrepopulationData.resolves(''); - const expected = ['nope', 'still nope']; - enketoInit.returns(expected); - return service - .render($('
'), mockEnketoDoc('myform')) - .then(() => { - expect.fail('Should throw error'); - }) - .catch(actual => { - expect(enketoInit.callCount).to.equal(1); - expect(actual.message).to.equal(JSON.stringify(expected)); - }); - }); - it('return form when everything works', () => { UserContact.resolves({ contact_id: '123' }); dbGetAttachment .onFirstCall().resolves('
my form
') .onSecondCall().resolves(VISIT_MODEL); enketoInit.returns([]); - FileReader.utf8.resolves(''); - EnketoPrepopulationData.resolves(''); + FileReader.utf8.resolves(''); + UserSettings.resolves({ name: 'Jim' }); return service.render($('
'), mockEnketoDoc('myform')).then(() => { expect(UserContact.callCount).to.equal(1); - expect(EnketoPrepopulationData.callCount).to.equal(1); + expect(UserSettings.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); @@ -250,524 +217,20 @@ describe('Enketo service', () => { expect(dbGetAttachment.args[1][1]).to.equal('model.xml'); }); }); - - it('replaces img src with obj urls', async () => { - UserContact.resolves({ contact_id: '123' }); - dbGetAttachment - .onFirstCall().resolves('
') - .onSecondCall().resolves(VISIT_MODEL) - .onThirdCall().resolves('myobjblob'); - createObjectURL.returns('myobjurl'); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
'); - EnketoPrepopulationData.resolves(''); - const wrapper = $('
'); - await service.render(wrapper, mockEnketoDoc('myform')); - 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 => { - // different browsers return different values but both are equivalent - return val === '' || val === 'visible'; - }); - expect(enketoInit.callCount).to.equal(1); - expect(createObjectURL.callCount).to.equal(1); - expect(createObjectURL.args[0][0]).to.equal('myobjblob'); - }); - - it('leaves img wrapped and hides loader if failed to load', () => { - const consoleErrorMock = sinon.stub(console, 'error'); - UserContact.resolves({ contact_id: '123' }); - dbGetAttachment - .onFirstCall().resolves('
') - .onSecondCall().resolves(VISIT_MODEL) - .onThirdCall().rejects('not found'); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
'); - EnketoPrepopulationData.resolves(''); - const wrapper = $('
'); - return service.render(wrapper, mockEnketoDoc('myform')).then(() => { - const img = wrapper.find('img').first(); - expect(img.attr('src')).to.equal(undefined); - expect(img.attr('data-media-src')).to.equal('myimg'); - expect(img.css('visibility')).to.equal('hidden'); - const loader = img.closest('div'); - expect(loader.hasClass('loader')).to.equal(true); - expect(loader.is(':hidden')).to.equal(true); - expect(enketoInit.callCount).to.equal(1); - expect(createObjectURL.callCount).to.equal(0); - expect(consoleErrorMock.callCount).to.equal(1); - expect(consoleErrorMock.args[0][0]).to.equal('Error fetching media file'); - }); - }); - - it('passes users language to Enketo', () => { - const data = '123'; - UserContact.resolves({ contact_id: '123' }); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - EnketoPrepopulationData.resolves(data); - Language.resolves('sw'); - return service.render($('
'), mockEnketoDoc('myform'), data).then(() => { - expect(Language.callCount).to.equal(1); - expect(EnketoForm.callCount).to.equal(1); - expect(EnketoForm.args[0][2].language).to.equal('sw'); - }); - }); - - it('passes xml instance data through to Enketo', () => { - const data = '123'; - UserContact.resolves({ contact_id: '123' }); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves('my model'); - EnketoPrepopulationData.resolves(data); - return service.render($('
'), mockEnketoDoc('myform'), data).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); - }); - }); - - it('passes json instance data through to Enketo', () => { - const data = '123'; - UserContact.resolves({ - _id: '456', - contact_id: '123', - facility_id: '789' - }); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); - EnketoPrepopulationData.resolves(data); - const instanceData = { - inputs: { - patient_id: 123, - name: 'sharon' - } - }; - return service.render($('
'), mockEnketoDoc('myform'), instanceData).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); - }); - }); - - it('passes contact summary data to enketo', () => { - const data = '123'; - UserContact.resolves({ - _id: '456', - contact_id: '123', - facility_id: '789' - }); - 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.resolves(data); - const instanceData = { - contact: { - _id: 'fffff', - patient_id: '44509' - }, - inputs: { - patient_id: 123, - name: 'sharon' - } - }; - ContactSummary.resolves({ context: { pregnant: true } }); - Search.resolves([{ _id: 'somereport' }]); - LineageModelGenerator.contact.resolves({ lineage: [{ _id: 'someparent' }] }); - return service.render($('
'), mockEnketoDoc('myform'), instanceData).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]; - expect(summary.id).to.equal('contact-summary'); - const xmlStr = new XMLSerializer().serializeToString(summary.xml); - expect(xmlStr).to.equal('true'); - expect(Search.callCount).to.equal(1); - expect(Search.args[0][0]).to.equal('reports'); - expect(Search.args[0][1].subjectIds).to.deep.equal(['fffff', '44509']); - 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][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'); - }); - }); - - it('handles arrays and escaping characters', () => { - const data = '123'; - UserContact.resolves({ - _id: '456', - contact_id: '123', - facility_id: '789' - }); - 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.resolves(data); - const instanceData = { - contact: { - _id: 'fffff' - }, - inputs: { - patient_id: 123, - name: 'sharon' - } - }; - ContactSummary.resolves({ - context: { - pregnant: true, - previousChildren: [{ dob: 2016 }, { dob: 2013 }, { dob: 2010 }], - notes: `always reserved "characters" & 'words'` - } - }); - LineageModelGenerator.contact.resolves({ lineage: [] }); - return service.render($('
'), mockEnketoDoc('myform'), instanceData).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]; - expect(summary.id).to.equal('contact-summary'); - const xmlStr = new XMLSerializer().serializeToString(summary.xml); - expect(xmlStr).to.equal('true2016' + - '20132010always <uses> reserved "' + - 'characters" & \'words\''); - expect(ContactSummary.callCount).to.equal(1); - expect(ContactSummary.args[0][0]._id).to.equal('fffff'); - }); - }); - - it('does not get contact summary when the form has no instance for it', () => { - const data = '123'; - UserContact.resolves({ - _id: '456', - contact_id: '123', - facility_id: '789' - }); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL); - enketoInit.returns([]); - FileReader.utf8.resolves(''); - EnketoPrepopulationData.resolves(data); - const instanceData = { - contact: { - _id: 'fffff' - }, - inputs: { - patient_id: 123, - name: 'sharon' - } - }; - return service.render($('
'), mockEnketoDoc('myform'), instanceData).then(() => { - expect(EnketoForm.callCount).to.equal(1); - expect(EnketoForm.args[0][1].external).to.equal(undefined); - expect(ContactSummary.callCount).to.equal(0); - expect(LineageModelGenerator.contact.callCount).to.equal(0); - }); - }); - - it('ContactSummary receives empty lineage if contact doc is missing', () => { - const consoleWarnMock = sinon.stub(console, 'warn'); - LineageModelGenerator.contact.rejects({ code: 404 }); - - UserContact.resolves({ - _id: '456', - contact_id: '123', - facility_id: '789' - }); - enketoInit.returns([]); - FileReader.utf8 - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - EnketoPrepopulationData.resolves('123'); - dbGetAttachment - .onFirstCall().resolves('
my form
') - .onSecondCall().resolves(VISIT_MODEL_WITH_CONTACT_SUMMARY); - const instanceData = { - contact: { - _id: 'fffff', - patient_id: '44509' - } - }; - ContactSummary.resolves({ context: { pregnant: true } }); - Search.resolves([{ _id: 'somereport' }]); - return service.render($('
'), mockEnketoDoc('myform'), instanceData).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; - }); - }); }); describe('save', () => { - 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() }; - return service - .save('V', form) - .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; - }); - }); - it('creates report', () => { 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' }); - UserSettings.resolves({ name: 'Jim' }); - return service.save('V', form).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(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']); - }); - }); - - it('saves form version if found', () => { - 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' }])); - xmlFormGetWithAttachment.resolves({ - doc: { _id: 'abc', xmlVersion: { time: '1', sha256: 'imahash' } }, - xml: '
' - }); + dbBulkDocs.callsFake(docs => Promise.resolve([ { ok: true, id: docs[0]._id, rev: '1-abc' } ])); + dbGetAttachment.resolves(''); UserContact.resolves({ _id: '123', phone: '555' }); UserSettings.resolves({ name: 'Jim' }); return service.save('V', form).then(actual => { actual = actual[0]; - expect(actual.form_version).to.deep.equal({ time: '1', sha256: 'imahash' }); - expect(xmlFormGetWithAttachment.callCount).to.equal(1); - expect(xmlFormGetWithAttachment.args[0][0]).to.equal('V'); - }); - }); - - describe('Geolocation recording', () => { - it('saves geolocation data into a new report', () => { - 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' }])); - xmlFormGetWithAttachment.resolves({ doc: { _id: 'V' }, xml: '' }); - - UserContact.resolves({ _id: '123', phone: '555' }); - UserSettings.resolves({ name: 'Jim' }); - const geoData = { - latitude: 1, - longitude: 2, - altitude: 3, - accuracy: 4, - altitudeAccuracy: 5, - heading: 6, - speed: 7 - }; - return service.save('V', form, () => Promise.resolve(geoData)).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(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); - }); - }); - - it('saves a geolocation error into a new report', () => { - 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' }])); - xmlFormGetWithAttachment.resolves({ doc: { _id: 'V' }, xml: '' }); - UserContact.resolves({ _id: '123', phone: '555' }); - UserSettings.resolves({ name: 'Jim' }); - const geoError = { - code: 42, - message: 'some bad geo' - }; - 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(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); - }); - }); - - 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); - const originalGeoData = { - latitude: 1, - longitude: 2, - altitude: 3, - accuracy: 4, - altitudeAccuracy: 5, - heading: 6, - speed: 7 - }; - const originalGeoLogEntry = { - timestamp: 12345, - recording: originalGeoData - }; - dbGet.resolves({ - _id: '6', - _rev: '1-abc', - form: 'V', - fields: { name: 'Silly' }, - content: 'Silly', - content_type: 'xml', - type: 'data_record', - reported_date: 500, - geolocation: originalGeoData, - geolocation_log: [originalGeoLogEntry] - }); - dbBulkDocs.resolves([{ ok: true, id: '6', rev: '2-abc' }]); - const geoData = { - latitude: 10, - longitude: 11, - altitude: 12, - accuracy: 13, - altitudeAccuracy: 14, - heading: 15, - speed: 16 - }; - return service.save('V', form, () => Promise.resolve(geoData), '6').then(actual => { - actual = actual[0]; - - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0][0]).to.equal('6'); - 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]); - }); - }); - }); - - 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' }); - UserSettings.resolves({ name: 'Jim' }); - 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); @@ -780,1151 +243,52 @@ describe('Enketo service', () => { 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.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); - }); - }); - - it('creates report with hidden fields', () => { - form.validate.resolves(true); - const content = loadXML('hidden-field'); - form.getDataStr.returns(content); - dbBulkDocs.resolves([{ ok: true, id: '(generated-in-service)', rev: '1-abc' }]); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form, null, null).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.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']); - expect(setLastChangedDoc.callCount).to.equal(1); - expect(setLastChangedDoc.args[0]).to.deep.equal([actual]); - }); - }); - - it('updates report', () => { - form.validate.resolves(true); - const content = loadXML('sally-lmp'); - form.getDataStr.returns(content); - dbGet.resolves({ - _id: '6', - _rev: '1-abc', - form: 'V', - fields: { name: 'Silly' }, - content: 'Silly', - content_type: 'xml', - type: 'data_record', - reported_date: 500, - }); - dbBulkDocs.resolves([{ ok: true, id: '6', rev: '2-abc' }]); - return service.save('V', form, null, '6').then(actual => { - actual = actual[0]; - - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); - expect(dbGet.callCount).to.equal(1); - expect(dbGet.args[0][0]).to.equal('6'); - 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(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', () => { - - 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; - - 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 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'); - - expect(_.uniq(_.map(actual, '_id')).length).to.equal(3); - - expect(setLastChangedDoc.callCount).to.equal(1); - expect(setLastChangedDoc.args[0]).to.deep.equal([actualReport]); - }); - }); - - it('creates extra docs with geolocation', () => { - - 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' }); - const geoData = { - latitude: 1, - longitude: 2, - altitude: 3, - accuracy: 4, - altitudeAccuracy: 5, - 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', - }); - - 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 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(_.uniq(_.map(actual, '_id')).length).to.equal(3); - }); - }); - - it('creates extra docs with references', () => { - form.validate.resolves(true); - const content = loadXML('extra-docs-with-references'); - 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' }); - - return service.save('V', form).then(actual => { - 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 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); - }); - }); - - it('creates extra docs with repeats', () => { - form.validate.resolves(true); - const content = loadXML('extra-docs-with-repeat'); - 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' }, - { ok: true, id: '9', rev: '1-ghi' } - ]); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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); - } - - expect(_.uniq(_.map(actual, '_id')).length).to.equal(4); - }); - }); - - it('db-doc-ref with repeats', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-repeat'); - 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' }, - { ok: true, id: '9', rev: '1-ghi' } - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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, - }); - }); - }); - - it('db-doc-ref with deep repeats', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-deep-repeat'); - 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' }, - { ok: true, id: '9', rev: '1-ghi' } - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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, - }); - }); - }); - - it('db-doc-ref with deep repeats and non-db-doc repeats', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-deep-repeats-extra-repeats'); - 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' }, - { ok: true, id: '9', rev: '1-ghi' } - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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, - }); - }); - }); - - it('db-doc-ref with repeats and local references', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-in-repeats-with-local-references'); - 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' }, - { ok: true, id: '9', rev: '1-ghi' } - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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, - }); - }); - }); - - 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); - - dbBulkDocs.resolves([ - { ok: true, id: '6', rev: '1-abc' }, - { ok: true, id: '7', rev: '1-def' }, - { ok: true, id: '8', rev: '1-ghi' }, - { ok: true, id: '9', rev: '1-ghi' } - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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, - }); - }); - }); - - 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); - - dbBulkDocs.resolves([ - { ok: true, id: '6', rev: '1-abc' }, - { ok: true, id: '7', rev: '1-def' }, - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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, - }); - }); - }); - - 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); - - dbBulkDocs.resolves([ - { ok: true, id: '6', rev: '1-abc' }, - { ok: true, id: '7', rev: '1-def' }, - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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('db-doc-ref with repeats with invalid ref', () => { - form.validate.resolves(true); - const content = loadXML('db-doc-ref-broken-ref'); - form.getDataStr.returns(content); - - dbBulkDocs.resolves([ - { ok: true, id: '6', rev: '1-abc' }, - { ok: true, id: '7', rev: '1-def' }, - ]); - xmlFormGetWithAttachment.resolves({ - xml: ` - - - - `, - doc: { _id: 'abc' } - }); - UserContact.resolves({ _id: '123', phone: '555' }); - return service.save('V', form).then(actual => { - 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(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', - }); - }); - }); - - describe('Saving attachments', () => { - it('should save attachments', () => { - const jqFind = $.fn.find; - sinon.stub($.fn, 'find'); - //@ts-ignore - $.fn.find.callsFake(jqFind); - - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-form/my_file"]') - .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); - - 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' }])); - // @ts-ignore - const saveDocsSpy = sinon.spy(EnketoService.prototype, 'saveDocs'); - - return service - .save('my-form', form, () => Promise.resolve(true)) - .then(() => { - expect(AddAttachment.calledOnce); - expect(saveDocsSpy.calledOnce); - - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); - expect(AddAttachment.args[0][3]).to.equal('image'); - - expect(globalActions.setSnackbarContent.notCalled); - }); - }); - - 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(EnketoService.prototype, 'saveDocs'); - // @ts-ignore - const xmlToDocsStub = sinon.stub(EnketoService.prototype, 'xmlToDocs').resolves([ - { _id: '1a' }, - { _id: '1b', _attachments: {} }, - { - _id: '1c', - _attachments: { - a_file: { data: { size: 10 * 1024 * 1024 } }, - b_file: { data: 'SSdtIGJhdG1hbg==' }, - c_file: { data: { size: 20 * 1024 * 1024 } }, - } - }, - { - _id: '1d', - _attachments: { - a_file: { content_type: 'image/png' } - } - } - ]); - - return service - .save('my-form', form, () => Promise.resolve(true)) - .then(() => expect.fail('Should have thrown exception.')) - .catch(error => { - expect(xmlToDocsStub.calledOnce); - expect(error.message).to.equal('enketo.error.max_attachment_size'); - expect(saveDocsStub.notCalled); - expect(globalActions.setSnackbarContent.calledOnce); - expect(globalActions.setSnackbarContent.args[0]).to.have.members(['enketo.error.max_attachment_size']); - }); - }); - - it('should remove binary data from content', () => { - form.validate.resolves(true); - const content = loadXML('binary-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' }])); - return service.save('my-form', form, () => Promise.resolve(true)).then(() => { - expect(dbBulkDocs.args[0][0][0].fields).to.deep.equal({ - name: 'Mary', - age: '10', - gender: 'f', - my_file: '', - }); - 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'); - }); - }); - - it('should assign attachment names relative to the form name not the root node name', () => { - const jqFind = $.fn.find; - sinon.stub($.fn, 'find'); - //@ts-ignore - $.fn.find.callsFake(jqFind); - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-root-element/my_file"]') - .returns([{ files: [{ type: 'image', foo: 'bar' }] }]); - $.fn.find - //@ts-ignore - .withArgs('input[type=file][name="/my-root-element/sub_element/sub_sub_element/other_file"]') - .returns([{ files: [{ type: 'mytype', foo: 'baz' }] }]); - form.validate.resolves(true); - const content = loadXML('deep-file-fields'); - - 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' }])); - return service.save('my-form-internal-id', form, () => Promise.resolve(true)).then(() => { - expect(AddAttachment.callCount).to.equal(2); - - expect(AddAttachment.args[0][1]).to.equal('user-file/my-form-internal-id/my_file'); - expect(AddAttachment.args[0][2]).to.deep.equal({ type: 'image', foo: 'bar' }); - expect(AddAttachment.args[0][3]).to.equal('image'); + describe('saveContactForm', () => { + it('saves contact form and sets last changed doc', () => { + form.getDataStr.returns(''); + const type = 'some-contact-type'; - expect(AddAttachment.args[1][1]) - .to.equal('user-file/my-form-internal-id/sub_element/sub_sub_element/other_file'); - expect(AddAttachment.args[1][2]).to.deep.equal({ type: 'mytype', foo: 'baz' }); - expect(AddAttachment.args[1][3]).to.equal('mytype'); - }); + sinon.stub(EnketoDataTranslator, 'contactRecordToJs').returns({ + doc: { _id: 'main1', type: 'main', contact: 'abc' } }); - 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.resolves([]); + dbGet.resolves({ _id: 'abc', name: 'gareth', parent: { _id: 'def' } }); + extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); - dbBulkDocs.callsFake(docs => Promise.resolve(docs.map(doc => ({ - ok: true, id: doc._id, rev: '2' - })))); - UserContact.resolves({ _id: '123', phone: '555' }); - 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 - clones.forEach(clone => clone.transitioned = true); - 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' } }, - xml: `
` - }); + return service + .saveContactForm(form, null, type) + .then(() => { + expect(dbGet.callCount).to.equal(1); + expect(dbGet.args[0][0]).to.equal('abc'); - return service.save('V', form, geoHandle).then(actual => { - expect(form.validate.callCount).to.equal(1); - expect(form.getDataStr.callCount).to.equal(1); expect(dbBulkDocs.callCount).to.equal(1); - expect(transitionsService.applyTransitions.callCount).to.equal(1); - expect(UserContact.callCount).to.equal(1); - - expect(transitionsService.applyTransitions.args[0][0].length).to.equal(4); - expect(transitionsService.applyTransitions.args[0][0]) - .excludingEvery(['_id', 'reported_date', 'timestamp']) - .to.deep.equal([ - { - 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', - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'data_record', - }, - { - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'repeater', - some_property: 'some_value_1', - }, - { - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'repeater', - some_property: 'some_value_2', - }, - { - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'repeater', - some_property: 'some_value_3', - }, - ]); - - expect(actual.length).to.equal(5); - expect(actual) - .excludingEvery(['_id', 'reported_date', 'timestamp', '_rev']) - .to.deep.equal([ - { - 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', - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'data_record', - transitioned: true, - }, - { - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'repeater', - some_property: 'some_value_1', - transitioned: true, - }, - { - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'repeater', - some_property: 'some_value_2', - transitioned: true, - }, - { - geolocation: { geo: 'data' }, - geolocation_log: [{ recording: { geo: 'data' } }], - type: 'repeater', - some_property: 'some_value_3', - transitioned: true, - }, - { - // docs that transitions push don't have geodata, this is intentional! - type: 'existent doc updated by the transition', - }, - ]); - }); - }); - - describe('renderContactForm', () => { - beforeEach(() => { - service.setFormTitle = sinon.stub(); - dbGetAttachment.resolves('
'); - translateService.get.callsFake((key) => `translated key ${key}`); - TranslateFrom.callsFake((sentence) => `translated sentence ${sentence}`); - }); - - const callbackMock = () => { }; - const instanceData = { - health_center: { - type: 'contact', - contact_type: 'health_center', - parent: 'parent', - }, - }; - const formDoc = { - ...mockEnketoDoc('myform'), - title: 'New Area', - }; - - it('should translate titleKey when provided', async () => { - await service.renderContactForm({ - selector: $('
'), - formDoc, - instanceData, - editedListener: callbackMock, - valuechangeListener: callbackMock, - titleKey: 'contact.type.health_center.new', - }); - - expect(service.setFormTitle.callCount).to.be.equal(1); - expect(service.setFormTitle.args[0][1]).to.be.equal('translated key contact.type.health_center.new'); - }); - - it('should fallback to translate document title when the titleKey is not available', async () => { - await service.renderContactForm({ - selector: $('
'), - formDoc, - instanceData, - editedListener: callbackMock, - valuechangeListener: callbackMock, - }); - - expect(service.setFormTitle.callCount).to.be.equal(1); - expect(service.setFormTitle.args[0][1]).to.be.equal('translated sentence New Area'); - }); - }); - }); - }); - - describe('multimedia', () => { - let overrideNavigationButtonsStub; - let pauseStubs; - let form; - let $form; - let $nextBtn; - let $prevBtn; - let originalJQueryFind; - - before(() => { - $nextBtn = $(''); - $prevBtn = $(''); - originalJQueryFind = $.fn.find; - overrideNavigationButtonsStub = sinon - .stub(EnketoService.prototype, 'overrideNavigationButtons') - .callThrough(); - - form = { - calc: { update: sinon.stub() }, - output: { update: sinon.stub() }, - resetView: sinon.stub(), - pages: { - _next: sinon.stub(), - _getCurrentIndex: sinon.stub() - } - }; - }); - - beforeEach(() => { - $form = $(`
`); - $form - .append($nextBtn) - .append($prevBtn); - pauseStubs = {}; - sinon - .stub($.fn, 'find') - .callsFake(selector => { - const result = originalJQueryFind.call($form, selector); + const savedDocs = dbBulkDocs.args[0][0]; - result.each((idx, element) => { - if (element.pause) { - pauseStubs[element.id] = sinon.stub(element, 'pause'); + expect(savedDocs.length).to.equal(1); + expect(savedDocs[0].contact).to.deep.equal({ + _id: 'abc', + parent: { + _id: 'def' } }); - - return result; + expect(setLastChangedDoc.callCount).to.equal(1); + expect(setLastChangedDoc.args[0]).to.deep.equal([savedDocs[0]]); }); }); - - after(() => $.fn.find = originalJQueryFind); - - xit('should pause the multimedia when going to the previous page', fakeAsync(() => { - $form.prepend(''); - overrideNavigationButtonsStub.call(service, form, $form); - - $prevBtn.trigger('click.pagemode'); - flush(); - - 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; - })); - - xit('should pause the multimedia when going to the next page', fakeAsync(() => { - form.pages._next.resolves(true); - $form.prepend(''); - overrideNavigationButtonsStub.call(service, form, $form); - - $nextBtn.trigger('click.pagemode'); - flush(); - - 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; - })); - - 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(''); - overrideNavigationButtonsStub.call(service, form, $form); - - $nextBtn.trigger('click.pagemode'); - flush(); - - expect(pauseStubs.video).to.be.undefined; - expect(pauseStubs.audio).to.be.undefined; - })); - - xit('should not call pause function when there isnt video and audio in the form wrapper', fakeAsync(() => { - overrideNavigationButtonsStub.call(service, form, $form); - - $prevBtn.trigger('click.pagemode'); - $nextBtn.trigger('click.pagemode'); - flush(); - - expect(pauseStubs.video).to.be.undefined; - expect(pauseStubs.audio).to.be.undefined; - })); }); });