diff --git a/.gitignore b/.gitignore
index 264c07f05c9..c4f4faac412 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ config/*/forms/contact/*.xlsx
/**/*/.snapshots/local.json
tests/integration/results/
allure*
+tests/config-temp
tests/utils/config-temp
.eslintcache
doc-conflicts/
@@ -61,4 +62,4 @@ user-password-change.csv
/tests/e2e/visual/images/*.png
.envrc
release-notes.md
-/scripts/build/helm/values.yaml
\ No newline at end of file
+/scripts/build/helm/values.yaml
diff --git a/eslint.config.js b/eslint.config.js
index 4f376c4a77b..c39ba3ca1d9 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -51,7 +51,6 @@ module.exports = defineConfig([
'shared-libs/cht-datasource/docs/**/*',
'tests/scalability/report*/**/*',
'tests/scalability/jmeter/**/*',
- 'webapp/src/ts/providers/xpath-element-path.provider.ts',
'webapp/dist/**/*',
'.github/**/compiled/index.js'
]),
diff --git a/tests/e2e/default/contacts/contact-attachments.wdio-spec.js b/tests/e2e/default/contacts/contact-attachments.wdio-spec.js
index 0cb104e711d..e90842df843 100644
--- a/tests/e2e/default/contacts/contact-attachments.wdio-spec.js
+++ b/tests/e2e/default/contacts/contact-attachments.wdio-spec.js
@@ -14,7 +14,6 @@ const { CONTACT_TYPES } = require('@medic/constants');
describe('Contact form attachments', () => {
const photoPngPath = path.join(__dirname, '../enketo/images/photo-for-upload-form.png');
const layersPngPath = path.join(__dirname, '../../../../webapp/src/img/layers.png');
-
const places = placeFactory.generateHierarchy();
const healthCenter = places.get(CONTACT_TYPES.HEALTH_CENTER);
@@ -44,42 +43,15 @@ describe('Contact form attachments', () => {
'contact.type.person_with_attachments.edit': 'Edit Person With Attachments'
};
- const createFormXml = fs.readFileSync(
- path.join(__dirname, 'forms/person-with-attachments-create.xml'),
- 'utf8'
+ const createFormDoc = commonPage.createFormDoc(
+ path.join(__dirname, 'forms/person-with-attachments-create'),
+ 'contact:person_with_attachments:create'
);
-
- const editFormXml = fs.readFileSync(
- path.join(__dirname, 'forms/person-with-attachments-edit.xml'),
- 'utf8'
+ const editFormDoc = commonPage.createFormDoc(
+ path.join(__dirname, 'forms/person-with-attachments-edit'),
+ 'contact:person_with_attachments:edit'
);
- const createFormDoc = {
- _id: 'form:contact:person_with_attachments:create',
- internalId: 'contact:person_with_attachments:create',
- title: 'New Person With Attachments',
- type: 'form',
- _attachments: {
- xml: {
- content_type: 'application/octet-stream',
- data: Buffer.from(createFormXml).toString('base64'),
- }
- }
- };
-
- const editFormDoc = {
- _id: 'form:contact:person_with_attachments:edit',
- internalId: 'contact:person_with_attachments:edit',
- title: 'Edit Person With Attachments',
- type: 'form',
- _attachments: {
- xml: {
- content_type: 'application/octet-stream',
- data: Buffer.from(editFormXml).toString('base64'),
- }
- }
- };
-
const createContactWithAttachment = (contactName, imagePath = photoPngPath) => {
const imageBuffer = fs.readFileSync(imagePath);
const imageBase64 = imageBuffer.toString('base64');
@@ -128,37 +100,6 @@ describe('Contact form attachments', () => {
await commonPage.waitForPageLoaded();
});
- it('should create contact with image attachment', async () => {
- const contactName = 'Test Person With Photo';
-
- await commonPage.goToPeople(healthCenter._id);
- await commonPage.clickFastActionFAB({ actionId: personWithAttachmentsType.id });
-
- await commonEnketoPage.setInputValue('Full name', contactName);
- await commonEnketoPage.addFileInputValue('Photo', photoPngPath);
-
- await genericForm.submitForm();
- await commonPage.waitForPageLoaded();
- await contactPage.waitForContactLoaded();
-
- const contactId = await contactPage.getCurrentContactId();
- expect(contactId).to.exist;
-
- const createdContact = await utils.getDoc(contactId);
-
- expect(createdContact).to.exist;
- expect(createdContact.name).to.equal(contactName);
- expect(createdContact._attachments).to.exist;
-
- const attachmentNames = Object.keys(createdContact._attachments);
- expect(attachmentNames).to.have.lengthOf(1);
- expect(attachmentNames[0]).to.match(/^user-file-photo-for-upload-form.*\.png$/);
-
- const attachment = createdContact._attachments[attachmentNames[0]];
- expect(attachment.content_type).to.equal('image/png');
- expect(attachment.length, 'Attachment should have a valid size').to.be.greaterThan(0);
- });
-
it('should create contact with multiple attachments (image + document)', async () => {
const contactName = 'Test Person With Multiple Files';
@@ -183,7 +124,7 @@ describe('Contact form attachments', () => {
expect(createdContact._attachments).to.exist;
const attachmentNames = Object.keys(createdContact._attachments);
- expect(attachmentNames).to.have.lengthOf(2);
+ expect(attachmentNames).to.have.lengthOf(3);
const photoAttachment = attachmentNames.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
const documentAttachment = attachmentNames.find(name => name.match(/^user-file-layers.*\.png$/));
@@ -198,6 +139,9 @@ describe('Contact form attachments', () => {
expect(createdContact._attachments[documentAttachment].content_type).to.equal('image/png');
expect(createdContact._attachments[documentAttachment].length, 'Document should have a valid size')
.to.be.greaterThan(0);
+
+ expect(createdContact._attachments['user-file/badge'].content_type).to.equal('image/png');
+ expect(createdContact._attachments['user-file/badge'].length).to.be.greaterThan(0);
});
it('should preserve attachments when editing contact', async () => {
@@ -221,7 +165,9 @@ describe('Contact form attachments', () => {
const contactBefore = await utils.getDoc(contactId);
expect(contactBefore._attachments).to.exist;
const originalAttachments = Object.keys(contactBefore._attachments);
- expect(originalAttachments).to.have.lengthOf(1);
+ expect(originalAttachments).to.have.lengthOf(2);
+ expect(originalAttachments[0]).to.match(/^user-file-photo-for-upload-form.*\.png$/);
+ expect(originalAttachments[1]).to.equal('user-file/badge');
await commonPage.accessEditOption();
@@ -237,10 +183,11 @@ describe('Contact form attachments', () => {
expect(contactAfter._attachments).to.exist;
const attachmentsAfter = Object.keys(contactAfter._attachments);
- expect(attachmentsAfter).to.have.lengthOf(1);
- expect(attachmentsAfter[0]).to.equal(originalAttachments[0]);
+ expect(attachmentsAfter).to.deep.equal(originalAttachments);
expect(contactAfter._attachments[attachmentsAfter[0]].length, 'Preserved attachment should have a valid size')
.to.be.greaterThan(0);
+ expect(contactAfter._attachments[attachmentsAfter[1]].length, 'Preserved attachment should have a valid size')
+ .to.be.greaterThan(0);
});
it('should remove attachment when editing contact', async () => {
diff --git a/tests/e2e/default/contacts/forms/health_center-with-attachments-create.xlsx b/tests/e2e/default/contacts/forms/health_center-with-attachments-create.xlsx
new file mode 100644
index 00000000000..e684834e208
Binary files /dev/null and b/tests/e2e/default/contacts/forms/health_center-with-attachments-create.xlsx differ
diff --git a/tests/e2e/default/contacts/forms/health_center-with-attachments-create.xml b/tests/e2e/default/contacts/forms/health_center-with-attachments-create.xml
new file mode 100644
index 00000000000..dbf4209243c
--- /dev/null
+++ b/tests/e2e/default/contacts/forms/health_center-with-attachments-create.xml
@@ -0,0 +1,174 @@
+
+
+
+ New Health Center
+
+
+
+
+ Parent District Hospital
+
+
+ Parent Name
+
+
+ Parent Badge
+
+
+ Parent Photo
+
+
+ Primary Contact for Heath Center
+
+
+ Contact Name
+
+
+ Contact Badge
+
+
+ Contact Photo
+
+
+ Heath Center
+
+
+ Health Center Name
+
+
+ Health Center Badge
+
+
+ Health Center Photo
+
+
+ Add Clinics
+
+
+ Clinic
+
+
+ Child Name
+
+
+ Child Badge
+
+
+ Child Photo
+
+
+
+
+
+
+
+
+
+
+
+
+ PARENT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/e2e/default/contacts/forms/health_center-with-attachments-edit.xlsx b/tests/e2e/default/contacts/forms/health_center-with-attachments-edit.xlsx
new file mode 100644
index 00000000000..de52ed12212
Binary files /dev/null and b/tests/e2e/default/contacts/forms/health_center-with-attachments-edit.xlsx differ
diff --git a/tests/e2e/default/contacts/forms/health_center-with-attachments-edit.xml b/tests/e2e/default/contacts/forms/health_center-with-attachments-edit.xml
new file mode 100644
index 00000000000..71ac824f1dd
--- /dev/null
+++ b/tests/e2e/default/contacts/forms/health_center-with-attachments-edit.xml
@@ -0,0 +1,90 @@
+
+
+
+ Edit Health Center
+
+
+
+
+ Heath Center
+
+
+ Health Center Name
+
+
+ Health Center Photo
+
+
+ Add Clinics
+
+
+ Clinic
+
+
+ Child Name
+
+
+ Child Photo
+
+
+ Child Badge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/e2e/default/contacts/forms/person-with-attachments-create.xml b/tests/e2e/default/contacts/forms/person-with-attachments-create.xml
index 557cd27200b..21f85efe6fa 100644
--- a/tests/e2e/default/contacts/forms/person-with-attachments-create.xml
+++ b/tests/e2e/default/contacts/forms/person-with-attachments-create.xml
@@ -14,6 +14,9 @@
Document
+
+ Badge
+
@@ -24,6 +27,7 @@
+ iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC
@@ -35,6 +39,7 @@
+
@@ -49,6 +54,9 @@
+
+
+
diff --git a/tests/e2e/default/contacts/forms/person-with-attachments-edit.xml b/tests/e2e/default/contacts/forms/person-with-attachments-edit.xml
index 65422a55a76..92ddb21d62e 100644
--- a/tests/e2e/default/contacts/forms/person-with-attachments-edit.xml
+++ b/tests/e2e/default/contacts/forms/person-with-attachments-edit.xml
@@ -14,6 +14,9 @@
Document
+
+ Badge
+
@@ -24,6 +27,7 @@
+
@@ -35,6 +39,7 @@
+
@@ -49,6 +54,9 @@
+
+
+
diff --git a/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js b/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js
new file mode 100644
index 00000000000..ce1a9f5c12d
--- /dev/null
+++ b/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js
@@ -0,0 +1,197 @@
+const path = require('path');
+const utils = require('@utils');
+const placeFactory = require('@factories/cht/contacts/place');
+const loginPage = require('@page-objects/default/login/login.wdio.page');
+const commonPage = require('@page-objects/default/common/common.wdio.page');
+const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page');
+const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');
+const contactPage = require('@page-objects/default/contacts/contacts.wdio.page');
+const { CONTACT_TYPES } = require('@medic/constants');
+
+describe('Sub-contact attachment routing', () => {
+ // Manually entering binary data to simulate input from external source like 3rd-party android app.
+ const BINARY_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADve' +
+ 'WkH6oAAAAAElFTkSuQmCC';
+ const familyPhotoPath = path.join(__dirname, '../enketo/images/photo-for-upload-form.png');
+
+ const healthCenterType = {
+ id: 'health_center_with_attachments',
+ parents: [CONTACT_TYPES.HEALTH_CENTER, CONTACT_TYPES.CLINIC, 'district_hospital'],
+ create_form: 'form:contact:health_center_with_attachments:create',
+ edit_form: 'form:contact:health_center_with_attachments:edit',
+ person: false
+ };
+ const createFormDoc = commonPage.createFormDoc(
+ path.join(__dirname, 'forms/health_center-with-attachments-create'),
+ 'contact:health_center_with_attachments:create'
+ );
+ const editFormDoc = commonPage.createFormDoc(
+ path.join(__dirname, 'forms/health_center-with-attachments-edit'),
+ 'contact:health_center_with_attachments:edit'
+ );
+
+ const districtHospital = placeFactory.place().build({
+ name: 'District Hospital',
+ type: CONTACT_TYPES.DISTRICT_HOSPITAL
+ });
+
+ before(async () => {
+ const settings = await utils.getSettings();
+ settings.contact_types.push(healthCenterType);
+ await utils.updateSettings({ contact_types: settings.contact_types }, { ignoreReload: true });
+ await utils.saveDocs([districtHospital, createFormDoc, editFormDoc]);
+ await loginPage.cookieLogin();
+ });
+
+ after(async () => {
+ await utils.deleteDocs([createFormDoc._id, editFormDoc._id]);
+ await utils.revertDb([/^form:/], true);
+ });
+
+ it('creates place with parent, contact, and children all having attachments', async () => {
+ await commonPage.goToPeople(districtHospital._id);
+ await commonPage.clickFastActionFAB({ actionId: healthCenterType.id });
+
+ await commonEnketoPage.setInputValue('Parent Name', 'parent');
+ await commonEnketoPage.setInputValue('Parent Badge', BINARY_IMAGE_DATA);
+ await commonEnketoPage.addFileInputValue('Parent Photo', familyPhotoPath);
+
+ await commonEnketoPage.setInputValue('Contact Name', 'contact');
+ await commonEnketoPage.setInputValue('Contact Badge', BINARY_IMAGE_DATA);
+ await commonEnketoPage.addFileInputValue('Contact Photo', familyPhotoPath);
+
+ await commonEnketoPage.setInputValue('Health Center Name', 'contact');
+ await commonEnketoPage.setInputValue('Health Center Badge', BINARY_IMAGE_DATA);
+ await commonEnketoPage.addFileInputValue('Health Center Photo', familyPhotoPath);
+
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.setInputValue('Child Name', 'child0');
+ await commonEnketoPage.setInputValue('Child Badge', BINARY_IMAGE_DATA);
+ await commonEnketoPage.addFileInputValue('Child Photo', familyPhotoPath);
+
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.setInputValue('Child Name', 'child1', { repeatIndex: 1 });
+ await commonEnketoPage.setInputValue('Child Badge', BINARY_IMAGE_DATA, { repeatIndex: 1 });
+ await commonEnketoPage.addFileInputValue('Child Photo', familyPhotoPath, { repeatIndex: 1 });
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+ await contactPage.waitForContactLoaded();
+
+ const childIds = await contactPage.getAllRHSPlaceIds();
+ expect(childIds).to.have.lengthOf(2);
+ const healthCenter = await utils.getDoc(await contactPage.getCurrentContactId());
+ const [primaryContact, parent, child0, child1] = await utils.getDocs([
+ healthCenter.contact._id,
+ healthCenter.parent._id,
+ ...childIds
+ ]);
+
+ expect(healthCenter).to.deep.include({
+ name: 'contact',
+ badge: '',
+ parent: { _id: parent._id },
+ contact: { _id: primaryContact._id, parent: { _id: healthCenter._id, parent: { _id: parent._id } } }
+ });
+ expect(healthCenter.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(healthCenter._attachments)).to.deep.equal([
+ `user-file-${healthCenter.photo}`,
+ 'user-file/badge'
+ ]);
+ expect(primaryContact).to.deep.include({
+ _id: healthCenter.contact._id,
+ parent: { _id: healthCenter._id, parent: { _id: parent._id } },
+ name: 'contact',
+ badge: '',
+ });
+ expect(primaryContact.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(primaryContact._attachments)).to.deep.equal([
+ `user-file-${primaryContact.photo}`,
+ 'user-file/badge'
+ ]);
+ expect(parent).to.deep.include({
+ _id: healthCenter.parent._id,
+ name: 'parent',
+ badge: '',
+ });
+ expect(parent.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(parent._attachments)).to.deep.equal([
+ `user-file-${parent.photo}`,
+ 'user-file/badge'
+ ]);
+ expect(child0).to.deep.include({
+ name: 'child0',
+ badge: '',
+ parent: { _id: healthCenter._id, parent: { _id: parent._id } },
+ });
+ expect(child0.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(child0._attachments)).to.deep.equal([
+ `user-file-${child0.photo}`,
+ 'user-file/badge'
+ ]);
+ expect(child1).to.deep.include({
+ name: 'child1',
+ badge: '',
+ parent: { _id: healthCenter._id, parent: { _id: parent._id } },
+ });
+ expect(child1.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(child1._attachments)).to.deep.equal([
+ `user-file-${child1.photo}`,
+ 'user-file/badge'
+ ]);
+ });
+
+ it('maintains attachments as expected when editing a contact to add children', async () => {
+ await commonPage.accessEditOption();
+ await commonPage.waitForPageLoaded();
+
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.setInputValue('Child Name', 'child2');
+ await commonEnketoPage.setInputValue('Child Badge', BINARY_IMAGE_DATA);
+ await commonEnketoPage.addFileInputValue('Child Photo', familyPhotoPath);
+
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.setInputValue('Child Name', 'child3', { repeatIndex: 1 });
+ await commonEnketoPage.setInputValue('Child Badge', BINARY_IMAGE_DATA, { repeatIndex: 1 });
+ await commonEnketoPage.addFileInputValue('Child Photo', familyPhotoPath, { repeatIndex: 1 });
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+ await contactPage.waitForContactLoaded();
+
+ const childIds = await contactPage.getAllRHSPlaceIds();
+ expect(childIds).to.have.lengthOf(4);
+ const [healthCenter, child2, child3] = await utils.getDocs([
+ await contactPage.getCurrentContactId(),
+ childIds[2],
+ childIds[3]
+ ]);
+
+ expect(healthCenter.badge).to.equal('');
+ expect(healthCenter.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(healthCenter._attachments)).to.deep.equal([
+ `user-file-${healthCenter.photo}`,
+ 'user-file/badge'
+ ]);
+ expect(child2).to.deep.include({
+ name: 'child2',
+ badge: '',
+ parent: { _id: healthCenter._id, parent: healthCenter.parent },
+ });
+ expect(child2.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(child2._attachments)).to.deep.equal([
+ `user-file-${child2.photo}`,
+ 'user-file/badge'
+ ]);
+ expect(child3).to.deep.include({
+ name: 'child3',
+ badge: '',
+ parent: { _id: healthCenter._id, parent: healthCenter.parent },
+ });
+ expect(child3.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(child3._attachments)).to.deep.equal([
+ `user-file-${child3.photo}`,
+ 'user-file/badge'
+ ]);
+ });
+});
diff --git a/tests/e2e/default/enketo/db-docs-with-attachments.wdio-spec.js b/tests/e2e/default/enketo/db-docs-with-attachments.wdio-spec.js
new file mode 100644
index 00000000000..6218a489ef5
--- /dev/null
+++ b/tests/e2e/default/enketo/db-docs-with-attachments.wdio-spec.js
@@ -0,0 +1,155 @@
+const enketoWidgetsPage = require('@page-objects/default/enketo/enketo-widgets.wdio.page');
+const commonPage = require('@page-objects/default/common/common.wdio.page');
+const reportsPage = require('@page-objects/default/reports/reports.wdio.page');
+const utils = require('@utils');
+const path = require('path');
+const loginPage = require('@page-objects/default/login/login.wdio.page');
+const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');
+const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page');
+
+describe('db-docs with attachments', () => {
+ // Manually entering binary data to simulate input from external source like 3rd-party android app.
+ const BINARY_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADve' +
+ 'WkH6oAAAAAElFTkSuQmCC';
+ const FORM_ID = 'db-docs-with-attachments';
+ const photoPath0 = path.join(__dirname, '/images/photo-for-upload-form.png');
+ const photoPath1 = path.join(__dirname, '/images/photo-for-upload-form1.png');
+ const photoPath2 = path.join(__dirname, '/images/photo-for-upload-form2.png');
+
+ before(async () => {
+ await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/${FORM_ID}`));
+ await loginPage.cookieLogin();
+ await commonPage.goToReports();
+ });
+
+ it('writes report with attachments at various levels including in db-docs', async () => {
+ await commonPage.openFastActionReport(FORM_ID, false);
+
+ await commonEnketoPage.setInputValue('Report Text', 'report');
+ await commonEnketoPage.addFileInputValue('Report Photo', photoPath0);
+ await (await enketoWidgetsPage.imagePreview('Report Photo')).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Report Badge', BINARY_IMAGE_DATA);
+
+ await commonEnketoPage.addRepeatSection('Repeated Attachments');
+ await commonEnketoPage.setInputValue('Repeated Text', 'repeated0');
+ await commonEnketoPage.addFileInputValue('Repeated Photo', photoPath1);
+ await (await enketoWidgetsPage.imagePreview('Repeated Photo')).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Repeated Badge', BINARY_IMAGE_DATA);
+
+ await commonEnketoPage.addRepeatSection('Repeated Attachments');
+ await commonEnketoPage.setInputValue('Repeated Text', 'repeated1', { repeatIndex: 1 });
+ await commonEnketoPage.addFileInputValue('Repeated Photo', photoPath2, { repeatIndex: 1 });
+ await (await enketoWidgetsPage.imagePreview('Repeated Photo', { repeatIndex: 1 })).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Repeated Badge', BINARY_IMAGE_DATA, { repeatIndex: 1 });
+
+ await commonEnketoPage.setInputValue('Child Text', 'child');
+ await commonEnketoPage.addFileInputValue('Child Photo', photoPath0);
+ await (await enketoWidgetsPage.imagePreview('Child Photo')).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Child Badge', BINARY_IMAGE_DATA);
+
+ await commonEnketoPage.setInputValue('Grandchild Text', 'grandchild');
+ await commonEnketoPage.addFileInputValue('Grandchild Photo', photoPath0);
+ await (await enketoWidgetsPage.imagePreview('Grandchild Photo')).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Grandchild Badge', BINARY_IMAGE_DATA);
+
+ await commonEnketoPage.addRepeatSection('Repeated db-doc');
+ await commonEnketoPage.setInputValue('Repeated db-doc Text', 'repeated db-doc0');
+ await commonEnketoPage.addFileInputValue('Repeated db-doc Photo', photoPath0);
+ await (await enketoWidgetsPage.imagePreview('Repeated db-doc Photo')).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Repeated db-doc Badge', BINARY_IMAGE_DATA);
+
+ await commonEnketoPage.addRepeatSection('Repeated db-doc');
+ await commonEnketoPage.setInputValue('Repeated db-doc Text', 'repeated db-doc1', { repeatIndex: 1 });
+ await commonEnketoPage.addFileInputValue('Repeated db-doc Photo', photoPath0, { repeatIndex: 1 });
+ await (await enketoWidgetsPage.imagePreview('Repeated db-doc Photo', { repeatIndex: 1 })).waitForDisplayed();
+ await commonEnketoPage.setInputValue('Repeated db-doc Badge', BINARY_IMAGE_DATA, { repeatIndex: 1 });
+
+ await genericForm.submitForm();
+
+ const reportId = await reportsPage.getCurrentReportId();
+ const report = await utils.getDoc(reportId);
+ expect(report.fields).to.deep.include({ text: 'report', badge: '' });
+ expect(report.fields.photo).to.match(/^photo-for-upload-form-/);
+ expect(report.fields.repeated_attachments).to.have.length(2);
+ expect(report.fields.repeated_attachments[0]).to.deep.include({ text: 'repeated0', badge: '' });
+ expect(report.fields.repeated_attachments[0].photo).to.match(/^photo-for-upload-form1-/);
+ expect(report.fields.repeated_attachments[1]).to.deep.include({ text: 'repeated1', badge: '' });
+ expect(report.fields.repeated_attachments[1].photo).to.match(/^photo-for-upload-form2-/);
+ expect(Object.keys(report._attachments)).to.deep.equal([
+ `user-file-${report.fields.photo}`,
+ `user-file-${report.fields.repeated_attachments[0].photo}`,
+ `user-file-${report.fields.repeated_attachments[1].photo}`,
+ 'user-file/fields/badge',
+ 'user-file/fields/repeated_attachments[1]/badge',
+ 'user-file/fields/repeated_attachments[2]/badge',
+ ]);
+
+ const {
+ child_doc_id,
+ child_doc: { grandchild_doc_id },
+ repeat: [rep0, rep1, ...additional]
+ } = report.fields;
+ expect(additional).to.be.empty;
+ const [childDoc, grandchildDoc, repeatDoc0, repeatDoc1] = await utils.getDocs([
+ child_doc_id,
+ grandchild_doc_id,
+ rep0.repeated_doc_id,
+ rep1.repeated_doc_id
+ ]);
+
+ expect(childDoc.fields).to.deep.include({ text: 'child', badge: '' });
+ expect(childDoc.fields.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(childDoc._attachments)).to.deep.equal([
+ `user-file-${childDoc.fields.photo}`,
+ 'user-file/fields/badge'
+ ]);
+
+ expect(grandchildDoc.fields).to.deep.include({ text: 'grandchild', badge: '' });
+ expect(grandchildDoc.fields.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(grandchildDoc._attachments)).to.deep.equal([
+ `user-file-${grandchildDoc.fields.photo}`,
+ 'user-file/fields/badge'
+ ]);
+
+ expect(repeatDoc0.fields).to.deep.include({ text: 'repeated db-doc0', badge: '' });
+ expect(repeatDoc0.fields.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(repeatDoc0._attachments)).to.deep.equal([
+ `user-file-${repeatDoc0.fields.photo}`,
+ 'user-file/fields/badge'
+ ]);
+
+ expect(repeatDoc1.fields).to.deep.include({ text: 'repeated db-doc1', badge: '' });
+ expect(repeatDoc1.fields.photo).to.match(/^photo-for-upload-form-/);
+ expect(Object.keys(repeatDoc1._attachments)).to.deep.equal([
+ `user-file-${repeatDoc1.fields.photo}`,
+ 'user-file/fields/badge'
+ ]);
+
+ await reportsPage.rightPanelSelectors
+ .reportImage('report.db-docs-with-attachments.photo')
+ .waitForDisplayed();
+ // TODO binary attachments in repeat not rendered
+ await reportsPage.rightPanelSelectors
+ .reportImage('report.db-docs-with-attachments.repeated_attachments.0.photo')
+ .waitForDisplayed();
+ await reportsPage.rightPanelSelectors
+ .reportImage('report.db-docs-with-attachments.repeated_attachments.0.photo')
+ .waitForDisplayed();
+
+ await reportsPage.goToReportById(childDoc._id);
+ await reportsPage.rightPanelSelectors.reportImage('report.child_doc.photo').waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.child_doc.badge').waitForDisplayed();
+
+ await reportsPage.goToReportById(grandchildDoc._id);
+ await reportsPage.rightPanelSelectors.reportImage('report.grandchild_doc.photo').waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.grandchild_doc.badge').waitForDisplayed();
+
+ await reportsPage.goToReportById(repeatDoc0._id);
+ await reportsPage.rightPanelSelectors.reportImage('report.repeated_doc.photo').waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.repeated_doc.badge').waitForDisplayed();
+
+ await reportsPage.goToReportById(repeatDoc1._id);
+ await reportsPage.rightPanelSelectors.reportImage('report.repeated_doc.photo').waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.repeated_doc.badge').waitForDisplayed();
+ });
+});
diff --git a/tests/e2e/default/enketo/forms/db-docs-with-attachments.xlsx b/tests/e2e/default/enketo/forms/db-docs-with-attachments.xlsx
new file mode 100644
index 00000000000..2e8b4594112
Binary files /dev/null and b/tests/e2e/default/enketo/forms/db-docs-with-attachments.xlsx differ
diff --git a/tests/e2e/default/enketo/forms/db-docs-with-attachments.xml b/tests/e2e/default/enketo/forms/db-docs-with-attachments.xml
new file mode 100644
index 00000000000..c301c8fed8e
--- /dev/null
+++ b/tests/e2e/default/enketo/forms/db-docs-with-attachments.xml
@@ -0,0 +1,252 @@
+
+
+
+ Db-docs with attachments
+
+
+
+
+ Report Text
+
+
+ Report Photo
+
+
+ Report Badge
+
+
+ Repeated Attachments
+
+
+ Repeated Text
+
+
+ Repeated Photo
+
+
+ Repeated Badge
+
+
+ Child db-doc
+
+
+ Child Text
+
+
+ Child Photo
+
+
+ Child Badge
+
+
+ Grandchild db-doc
+
+
+ Grandchild Text
+
+
+ Grandchild Photo
+
+
+ Grandchild Badge
+
+
+ Repeated db-doc
+
+
+ Repeated db-doc Text
+
+
+ Repeated db-doc Photo
+
+
+ Repeated db-doc Badge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_id/>
+
+
+
+
+
+
+
+
+
+
+
+ <_id/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_id/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/e2e/default/enketo/images/photo-for-upload-form1.png b/tests/e2e/default/enketo/images/photo-for-upload-form1.png
new file mode 100644
index 00000000000..27fe0017d36
Binary files /dev/null and b/tests/e2e/default/enketo/images/photo-for-upload-form1.png differ
diff --git a/tests/e2e/default/enketo/images/photo-for-upload-form2.png b/tests/e2e/default/enketo/images/photo-for-upload-form2.png
new file mode 100644
index 00000000000..a2172e15304
Binary files /dev/null and b/tests/e2e/default/enketo/images/photo-for-upload-form2.png differ
diff --git a/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js b/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js
index 8d2163817db..7ff27056c35 100644
--- a/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js
+++ b/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js
@@ -200,13 +200,8 @@ describe('Contact Delivery Form', () => {
}
// Verify alive babies UUIDs are unique
- const aliveBabyUUIds = [];
- for (let i = 0; i < noOfAliveBabies; i++) {
- aliveBabyUUIds.push((await reportsPage
- .getDetailReportRowContent(
- `report.delivery.babys_condition.baby_repeat.${i}.baby_details.child_doc`
- )).rowValues[0]);
- }
+ const aliveBabyUUIds = initialReport.fields.babys_condition.baby_repeat
+ .map(({ baby_details }) => baby_details.child_doc);
expect(deadBabyUUIds.length).to.equal(noOfDeadBabies);
expect(aliveBabyUUIds.length).to.deep.equal(noOfAliveBabies);
@@ -279,13 +274,8 @@ describe('Contact Delivery Form', () => {
}
// Verify alive babies UUIDs are unique
- const updatedAliveBabyUUIds = [];
- for (let i = 0; i < noOfAliveBabies; i++) {
- updatedAliveBabyUUIds.push((await reportsPage
- .getDetailReportRowContent(
- `report.delivery.babys_condition.baby_repeat.${i}.baby_details.child_doc`
- )).rowValues[0]);
- }
+ const updatedAliveBabyUUIds = updatedReport.fields.babys_condition.baby_repeat
+ .map(({ baby_details }) => baby_details.child_doc);
expect(updatedDeadBabyUUIds.length).to.equal(noOfDeadBabies);
expect(updatedAliveBabyUUIds.length).to.deep.equal(noOfAliveBabies);
diff --git a/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js b/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js
index c1918892546..87521993448 100644
--- a/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js
+++ b/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js
@@ -5,6 +5,7 @@ const utils = require('@utils');
const path = require('path');
const loginPage = require('@page-objects/default/login/login.wdio.page');
const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');
+const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page');
describe('Submit Photo Upload form', () => {
@@ -17,10 +18,10 @@ describe('Submit Photo Upload form', () => {
beforeEach(async () => {
await commonPage.goToReports();
await commonPage.openFastActionReport('photo-upload', false);
- await enketoWidgetsPage.selectImage('photo-upload', path.join(__dirname, '/images/photo-for-upload-form.png'));
- await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed();
+ await commonEnketoPage.addFileInputValue('Image widget', path.join(__dirname, '/images/photo-for-upload-form.png'));
+ await (await enketoWidgetsPage.imagePreview('Image widget')).waitForDisplayed();
await genericForm.submitForm();
- await enketoWidgetsPage.reportImagePreview().waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.photo-upload.my_photo').waitForDisplayed();
});
it('submit and edit (no changes)', async () => {
@@ -32,10 +33,10 @@ describe('Submit Photo Upload form', () => {
await reportsPage.openReport(reportId);
await commonPage.accessEditOption();
- await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed();
+ await (await enketoWidgetsPage.imagePreview('Image widget')).waitForDisplayed();
await genericForm.submitForm();
- await enketoWidgetsPage.reportImagePreview().waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.photo-upload.my_photo').waitForDisplayed();
const updatedReport = await utils.getDoc(reportId);
expect(updatedReport.fields).excludingEvery(['instanceID', 'meta']).to.deep.equal(initialReport.fields);
expect(updatedReport._attachments).excludingEvery('revpos').to.deep.equal(initialReport._attachments);
@@ -51,17 +52,20 @@ describe('Submit Photo Upload form', () => {
await reportsPage.openReport(reportId);
await commonPage.accessEditOption();
- await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed();
- await enketoWidgetsPage.selectImage('photo-upload', path.join(__dirname, '../../../../webapp/src/img/layers.png'));
- await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed();
+ await (await enketoWidgetsPage.imagePreview('Image widget')).waitForDisplayed();
+ await commonEnketoPage.addFileInputValue(
+ 'Image widget',
+ path.join(__dirname, '/images/photo-for-upload-form1.png')
+ );
+ await (await enketoWidgetsPage.imagePreview('Image widget')).waitForDisplayed();
await genericForm.submitForm();
- await enketoWidgetsPage.reportImagePreview().waitForDisplayed();
+ await reportsPage.rightPanelSelectors.reportImage('report.photo-upload.my_photo').waitForDisplayed();
const updatedReport = await utils.getDoc(reportId);
expect(updatedReport.fields).excludingEvery(['instanceID', 'meta']).not.to.deep.equal(initialReport.fields);
expect(updatedReport._attachments).excludingEvery('revpos').not.to.deep.equal(initialReport._attachments);
- expect(initialReport.fields.my_photo).to.match(/^photo-for-upload-form.*\.png$/);
- expect(updatedReport.fields.my_photo).to.match(/^layers.*\.png$/);
+ expect(initialReport.fields.my_photo).to.match(/^photo-for-upload-form-.*\.png$/);
+ expect(updatedReport.fields.my_photo).to.match(/^photo-for-upload-form1-.*\.png$/);
});
});
diff --git a/tests/page-objects/default/contacts/contacts.wdio.page.js b/tests/page-objects/default/contacts/contacts.wdio.page.js
index a469e3fa9fe..da3e6b75907 100644
--- a/tests/page-objects/default/contacts/contacts.wdio.page.js
+++ b/tests/page-objects/default/contacts/contacts.wdio.page.js
@@ -27,6 +27,9 @@ const rightPanelSelectors = {
emptySelection: () => $('contacts-content .empty-selection'),
childrenCards: () => $$('.right-pane .card.children'),
contactCardTitle: () => $('.inbox .content-pane .material .body .action-header'),
+ primaryContactName: () => $('i[title="Primary contact"]').nextElement(),
+ personsCardList: () => $$('.card.children.persons h4 span'),
+ placesCardRows: () => $$('.card.children.places li.content-row'),
};
const contactCardSelectors = {
@@ -38,11 +41,6 @@ const contactCardSelectors = {
contactMuted: () => $('.heading-content .muted'),
};
-const peopleCardSelectors = {
- primaryContactName: () => $('i[title="Primary contact"]').nextElement(),
- rhsPeopleListSelector: () => $$('.card.children.persons h4 span'),
-};
-
const RHS_TASK_LIST_CARD = '.card.tasks';
const TASK_FILTER_SELECTOR = `${RHS_TASK_LIST_CARD} .table-filter a`;
const RHS_TASK_LIST_SELECTOR = `${RHS_TASK_LIST_CARD} mm-content-row h4 span`;
@@ -299,7 +297,7 @@ const getContactSummaryField = async (fieldName) => {
};
const getPrimaryContactName = async () => {
- return await peopleCardSelectors.primaryContactName().getText();
+ return await rightPanelSelectors.primaryContactName().getText();
};
const getAllLHSContactsNames = async () => {
@@ -308,7 +306,12 @@ const getAllLHSContactsNames = async () => {
};
const getAllRHSPeopleNames = () => {
- return commonPage.getTextForElements(peopleCardSelectors.rhsPeopleListSelector);
+ return commonPage.getTextForElements(rightPanelSelectors.personsCardList);
+};
+
+const getAllRHSPlaceIds = async () => {
+ const placeRows = await rightPanelSelectors.placesCardRows();
+ return placeRows.map(row => row.getAttribute('data-record-id'));
};
const getAllRHSReportsNames = async () => {
@@ -480,6 +483,7 @@ module.exports = {
addPlace,
getPrimaryContactName,
getAllRHSPeopleNames,
+ getAllRHSPlaceIds,
waitForContactLoaded,
waitForContactUnloaded,
editPerson,
diff --git a/tests/page-objects/default/enketo/common-enketo.wdio.page.js b/tests/page-objects/default/enketo/common-enketo.wdio.page.js
index 66a4e1bce57..3fa59de6ae4 100644
--- a/tests/page-objects/default/enketo/common-enketo.wdio.page.js
+++ b/tests/page-objects/default/enketo/common-enketo.wdio.page.js
@@ -8,7 +8,12 @@ const getCurrentPageSection = async () => (await currentSection().isExisting())
const enabledFieldset = (section) => section.$$('fieldset.or-branch:not(.disabled)');
-const addRepeatSectionButton = () => $(`button.add-repeat-btn`);
+const addRepeatSectionButton = (title) => {
+ if (!title) {
+ return $(`button.add-repeat-btn`);
+ }
+ return $(`//section[h4//span[normalize-space(text())="${title}"]]//button[contains(@class, "add-repeat-btn")]`);
+};
const radioButtonElement = async (question, value) => {
return (await getCurrentPageSection())
@@ -42,24 +47,24 @@ const selectCheckBox = async (question, value) => {
.click();
};
-const setValue = async (typeSelector, question, value) => {
+const setValue = async (typeSelector, question, value, { repeatIndex = 0 } = {}) => {
await (await getCurrentPageSection())
- .$(`label*=${question}`)
+ .$$(`label*=${question}`)[repeatIndex]
.$(typeSelector).setValue(value);
};
-const setInputValue = async (question, value) => {
- await setValue('input', question, value);
+const setInputValue = async (question, value, options) => {
+ await setValue('input', question, value, options);
};
-const setDateValue = async (question, value) => {
- await setValue('input.ignore.input-small', question, value);
+const setDateValue = async (question, value, options) => {
+ await setValue('input.ignore.input-small', question, value, options);
//To close the date widget
await formTitle().click();
};
-const setTextareaValue = async (question, value) => {
- await setValue('textarea', question, value);
+const setTextareaValue = async (question, value, options) => {
+ await setValue('textarea', question, value, options);
};
const addFileInputValue = async (question, value, { repeatIndex = 0 } = {}) => {
@@ -77,19 +82,19 @@ const validateSummaryReport = async (textArray) => {
}
};
-const getValue = async (typeSelector, question) => {
+const getValue = async (typeSelector, question, { repeatIndex = 0 } = {}) => {
return await (await getCurrentPageSection())
- .$(`label*=${question}`)
+ .$$(`label*=${question}`)[repeatIndex]
.$(typeSelector)
.getValue();
};
-const getInputValue = async (question) => {
- return await getValue('input', question);
+const getInputValue = async (question, options) => {
+ return await getValue('input', question, options);
};
-const getTextareaValue = async (question) => {
- return await getValue('textarea', question);
+const getTextareaValue = async (question, options) => {
+ return await getValue('textarea', question, options);
};
const scrollToQuestion = async (label) => {
@@ -114,8 +119,8 @@ const isConstraintMessageDisplayed = async (question) => {
.isDisplayed();
};
-const addRepeatSection = async () => {
- await addRepeatSectionButton().click();
+const addRepeatSection = async (title) => {
+ await addRepeatSectionButton(title).click();
};
const drawShapeOnCanvas = async (question) => {
@@ -140,6 +145,7 @@ const isRadioButtonSelected = async (question, value) => {
};
module.exports = {
+ getCurrentPageSection,
isElementDisplayed,
selectRadioButton,
selectCheckBox,
diff --git a/tests/page-objects/default/enketo/enketo-widgets.wdio.page.js b/tests/page-objects/default/enketo/enketo-widgets.wdio.page.js
index caeda894e34..2e2717c69d9 100644
--- a/tests/page-objects/default/enketo/enketo-widgets.wdio.page.js
+++ b/tests/page-objects/default/enketo/enketo-widgets.wdio.page.js
@@ -1,4 +1,5 @@
const FORM = 'form[data-form-id="enketo_widgets_test"]';
+const { getCurrentPageSection } = require('@page-objects/default/enketo/common-enketo.wdio.page');
const selectMultipleDropdown = (formId = FORM) => {
return $(`${formId} select[name="/data/enketo_test_select/select_spinner"]`);
@@ -49,15 +50,12 @@ const clickTimer = async (formId) => {
await timer.click();
};
-const imagePreview = (formId) => $(`form[data-form-id="${formId}"] .file-picker .file-preview img`);
-
-const selectImage = async (formId, filePath) => {
- const input = await $(`form[data-form-id="${formId}"] input[type=file]`);
- await input.addValue(filePath);
+const imagePreview = async (question, { repeatIndex = 0 } = {}) => {
+ return (await getCurrentPageSection())
+ .$$(`label*=${question}`)[repeatIndex]
+ .$('.file-picker .file-preview img');
};
-const reportImagePreview = () => $('.report-image');
-
module.exports = {
selectMultipleDropdown,
selectOneDropdown,
@@ -71,6 +69,4 @@ module.exports = {
patientNameErrorLabel,
clickTimer,
imagePreview,
- selectImage,
- reportImagePreview,
};
diff --git a/tests/page-objects/default/reports/reports.wdio.page.js b/tests/page-objects/default/reports/reports.wdio.page.js
index 11c96916a4a..f2271247cf4 100644
--- a/tests/page-objects/default/reports/reports.wdio.page.js
+++ b/tests/page-objects/default/reports/reports.wdio.page.js
@@ -47,6 +47,7 @@ const rightPanelSelectors = {
automaticReplyState: () => $(`${AUTOMATIC_REPLY_SECTION} .state`),
automaticReplyRecipient: () => $(`${AUTOMATIC_REPLY_SECTION} .recipient`),
detailReportRowContent: (row, type) => $$(`${REPORT_BODY_DETAILS} li[test-id*='${row}'] span[test-id='${type}']`),
+ reportImage: (testId) => $(`${REPORT_BODY_DETAILS} li[test-id='${testId}'] report-image img.report-image`),
deleteAllButton: () => $('.desktop.multiselect-bar-container .bulk-delete'),
selectedReportsCount: () => $('.desktop.multiselect-bar-container .count-label'),
sentTask: () => $(`${REPORT_BODY_DETAILS} ul .task-list .task-state .state`),
diff --git a/webapp/src/ts/providers/xpath-element-path.provider.ts b/webapp/src/ts/providers/xpath-element-path.provider.ts
index 706e4731c1e..30d10f1012a 100644
--- a/webapp/src/ts/providers/xpath-element-path.provider.ts
+++ b/webapp/src/ts/providers/xpath-element-path.provider.ts
@@ -1,64 +1,29 @@
-/*
- * 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: string[] = [];
-
- // 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;
-};
-
-Xpath.getElementRawXPath = function(element){
- const path = Xpath.getElementTreeXPath(element);
- return path?.replace(/\[\d+\]/g, '');
+const getElementLineage = (element: Element): Element[] => element.parentElement
+ ? [...getElementLineage(element.parentElement), element]
+ : [element];
+
+const getElementPosition = (element: Element): number => Array
+ .from(element.parentElement?.children ?? [element])
+ .filter(sibling => sibling.nodeName === element.nodeName)
+ .indexOf(element) + 1;
+
+export const Xpath = {
+ /**
+ * Gets the XPath for an element with no positional predicates.
+ */
+ getElementXPath: (element: Element): string => getElementLineage(element)
+ .map(({ nodeName }) => `/${nodeName}`)
+ .join(''),
+
+ /**
+ * Gets the XPath for an element with a positional predicate on every node in the path that is a repeat instance.
+ * @param element the element to get the XPath for
+ * @param repeatPaths the raw XPaths (containing no positional predicates) of the form's repeat groups
+ */
+ getElementPositionalXPath: (element: Element, repeatPaths: string[] = []): string => getElementLineage(element)
+ .map(node => {
+ const position = repeatPaths.includes(Xpath.getElementXPath(node)) ? `[${getElementPosition(node)}]` : '';
+ return `/${node.nodeName}${position}`;
+ })
+ .join('')
};
diff --git a/webapp/src/ts/services/enketo-prepopulation-data.service.ts b/webapp/src/ts/services/enketo-prepopulation-data.service.ts
index a32e01cd4fb..489d77de764 100644
--- a/webapp/src/ts/services/enketo-prepopulation-data.service.ts
+++ b/webapp/src/ts/services/enketo-prepopulation-data.service.ts
@@ -74,6 +74,9 @@ export class EnketoPrepopulationDataService {
return found;
}
- return elem.children(name);
+ // Match by node name in JS rather than passing `name` to the jQuery selector: data keys can be `_attachments`
+ // names containing '/' (a binary's field path) or ' ' and '(' (an uploaded filename), which jQuery rejects as
+ // an invalid selector - and it throws while tokenizing, even when the set being filtered is empty.
+ return elem.children().filter((_idx, child) => child.nodeName === name);
}
}
diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts
index 0d99abbeccc..f2d3d8c386a 100644
--- a/webapp/src/ts/services/enketo.service.ts
+++ b/webapp/src/ts/services/enketo.service.ts
@@ -2,7 +2,6 @@ import { Injectable, NgZone } from '@angular/core';
import { v7 as uuid } from 'uuid';
import * as pojo2xml from 'pojo2xml';
import type JQuery from 'jquery';
-import * as FileManager from '../../js/enketo/file-manager.js';
import events from 'enketo-core/src/js/event';
import { Xpath } from '@mm-providers/xpath-element-path.provider';
@@ -18,8 +17,7 @@ import { FormConfig } from '@mm-services/form/form-config';
import {
EnketoContactFormData,
EnketoFormData,
- EnketoReportFormData,
- EnketoRootFormData
+ EnketoReportFormData
} from '@mm-services/form/form-data';
import { isHardcodedType } from '@medic/contact-types-utils';
import { REPORT_ATTACHMENT_NAME } from '@mm-services/get-report-content.service';
@@ -46,9 +44,6 @@ export class EnketoService {
this.getContactFromDatasource = chtDatasourceService.bind(Contact.v1.get);
}
- private readonly USER_BINARY_ATTACHMENT_PREFIX = 'user-file';
- private readonly USER_FILE_ATTACHMENT_PREFIX = `${this.USER_BINARY_ATTACHMENT_PREFIX}-`;
-
private readonly objUrls: string[] = [];
private readonly getContactFromDatasource: ReturnType;
private currentForm;
@@ -411,29 +406,20 @@ export class EnketoService {
contactDoc._id,
isHardcodedType(contactDoc.type) ? contactDoc.type : contactDoc.contact_type
);
-
- const formAttachments = this.processFormAttachments(config.doc.internalId, formData, contactDoc._attachments);
const reportedDate = Date.now();
const rootOutputDoc: Record = {
- ...contactDoc,
- ...formData.deserializeDoc(config),
- _id: contactDoc._id,
+ ...formData.getContactData().deserializeDoc(config, reportedDate, contactDoc),
type: contactDoc.type,
contact_type: contactDoc.contact_type,
- reported_date: contactDoc.reported_date || reportedDate,
- _attachments: formAttachments
};
- const siblings = await this.processContactSiblings(formData, config, rootOutputDoc, defaultData);
- siblings.forEach(({ fieldName, fieldValue }) => rootOutputDoc[fieldName] = fieldValue);
+ const siblings = this.initializeContactSiblings(formData, config, rootOutputDoc, reportedDate);
+ await this.setSiblingValuesOnRoot(siblings, rootOutputDoc, defaultData);
const outputSiblings = siblings
.filter(({ fieldName, doc }) => doc && rootOutputDoc[fieldName] === doc)
- .map(({ doc }) => ({ ...doc, reported_date: reportedDate }));
-
+ .map(({ doc }) => doc!);
const childDocs = formData
.getChildData()
- .map(doc => doc.deserializeDoc(config))
- .map(doc => ({ ...doc, reported_date: reportedDate, parent: rootOutputDoc }));
-
+ .map(data => ({ ...data.deserializeDoc(config, reportedDate), parent: rootOutputDoc }));
return {
docId: rootOutputDoc._id,
preparedDocs: [rootOutputDoc, ...outputSiblings, ...childDocs].map(doc => this.minifyContactLineage(doc))
@@ -456,25 +442,19 @@ export class EnketoService {
delete reportDoc[REPORT_ATTACHMENT_NAME];
delete reportDoc._attachments?.[REPORT_ATTACHMENT_NAME];
- this.populateDbDocRefElements(formData, [formData, ...subDocsData]);
- const attachments = this.processFormAttachments(config.doc.internalId, formData, reportDoc._attachments);
const hiddenFields = this.getHiddenFields([
...formData.hiddenElements,
...subDocsData.map(({ rootElement }) => rootElement)
]);
const reportedDate = Date.now();
+ // Unpack the db-docs before deserializing the report so the db-doc-refs are all populated first.
+ const dbDocObjects = subDocsData.map(docData => docData.deserializeDoc(config, reportedDate));
const rootOutputDoc: Record = {
- ...reportDoc,
+ ...formData.deserializeDoc(config, reportedDate, reportDoc),
hidden_fields: hiddenFields,
- fields: formData.deserialize(config),
- reported_date: reportDoc.reported_date || reportedDate,
- _attachments: attachments
};
- const dbDocObjects = subDocsData
- .map(docData => docData.deserializeDoc(config))
- .map(doc => ({ ...doc, reported_date: reportedDate }));
return [rootOutputDoc, ...dbDocObjects];
});
}
@@ -492,23 +472,40 @@ export class EnketoService {
return new DOMParser().parseFromString(formString, 'text/xml');
}
- private async processContactSiblings(
+ private initializeContactSiblings(
formData: EnketoContactFormData,
config: FormConfig,
rootOutputDoc: Record,
- defaultData: Record
+ reportedDate: number,
) {
- return Promise.all(EnketoContactFormData.SIBLING_FIELD_NAMES.map(async (fieldName) => {
- const sibling = formData
- .getSiblingData(fieldName)
- ?.deserializeDoc(config);
- const doc = this.initializeContactSibling(rootOutputDoc, sibling);
- const fieldValue = await this.getContactSiblingValue(doc, rootOutputDoc[fieldName], defaultData[fieldName]);
- return { fieldName, fieldValue, doc, };
+ return EnketoContactFormData.SIBLING_FIELD_NAMES.map((fieldName) => {
+ const siblingData = formData.getSiblingData(fieldName);
+ const doc = this.initializeContactSibling(config, rootOutputDoc, reportedDate, siblingData);
+ return { fieldName, doc, };
+ });
+ }
+
+ private async setSiblingValuesOnRoot(
+ siblings: { fieldName: typeof EnketoContactFormData.SIBLING_FIELD_NAMES[number], doc?: Record}[],
+ rootOutputDoc: Record,
+ defaultData: Record,
+ ) {
+ return Promise.all(siblings.map(async ({ fieldName, doc }) => {
+ rootOutputDoc[fieldName] = await this.getContactSiblingValue(
+ doc,
+ rootOutputDoc[fieldName],
+ defaultData[fieldName]
+ );
}));
}
- private initializeContactSibling(rootContactDoc: Record, rawSibling?: Record) {
+ private initializeContactSibling(
+ config: FormConfig,
+ rootContactDoc: Record,
+ reportedDate: number,
+ siblingData: EnketoFormData | null
+ ) {
+ const rawSibling = siblingData?.deserializeDoc(config, reportedDate);
if (!rawSibling) {
return;
}
@@ -546,7 +543,7 @@ export class EnketoService {
}
private getHiddenFields(elements: Element[]) {
- const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementRawXPath(element)));
+ const hiddenXpaths = new Set(elements.map((element) => Xpath.getElementXPath(element)));
const hasHiddenAncestor = (
segments: string[]
) => (_: string, i: number) => i > 0 && hiddenXpaths.has(segments.slice(0, i).join('/'));
@@ -572,82 +569,6 @@ export class EnketoService {
};
}
- private findReferencedDoc(refElement: Element, reference: string | null, allData: EnketoFormData[]) {
- const target = reference?.trim().replace(/^\.?\//, ''); // strip leading "./" or "/"
- if (!target) {
- return;
- }
- const matches = allData.filter(({ rootElement }) => {
- const path = Xpath.getElementRawXPath(rootElement).replace(/^\//, ''); // strip leading "/"
- return path === target || path.endsWith(`/${target}`);
- });
-
- // For the docs that match the path tail, find the one with the closest ancestor node to the refElement.
- for (let ancestor: Element | null = refElement; ancestor; ancestor = ancestor.parentElement) {
- const match = matches.find(({ rootElement }) => ancestor?.contains(rootElement));
- if (match) {
- return match;
- }
- }
- }
-
- private populateDbDocRefElements(formData: EnketoReportFormData, allData: EnketoFormData[]) {
- formData.dbDocRefElements.forEach(element => {
- const referencedDoc = this.findReferencedDoc(element, element.getAttribute('db-doc-ref'), allData);
- if (referencedDoc) {
- element.textContent = referencedDoc.id;
- }
- });
- }
-
- private buildBinaryAttachmentData(form: string, originalAttachments: Record, element: Element) {
- const xpath = Xpath.getElementTreeXPath(element);
- const formXpath = xpath.replace(/^\/[^/]+/, `/${form}`);
- const filename = `${this.USER_BINARY_ATTACHMENT_PREFIX}${formXpath}`;
- const data = element.textContent;
- element.textContent = '';
- return {
- filename,
- // Currently do not support loading binary attachment data into edit form. So, keep existing value.
- attachment: data ? { data, content_type: 'image/png' } : originalAttachments[filename]
- };
- }
-
- private processFormAttachments(
- form: string,
- rootData: EnketoRootFormData,
- originalAttachments: Record = {}
- ) {
- const hasCustomAttachmentName = (fileName: string) => !fileName.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)
- && !fileName.startsWith(`${this.USER_BINARY_ATTACHMENT_PREFIX}/`);
- const isExistingFileAttachment = (fileName: string) => fileName.startsWith(this.USER_FILE_ATTACHMENT_PREFIX)
- && rootData.findNodeWithTextContent(fileName.slice(this.USER_FILE_ATTACHMENT_PREFIX.length));
- const binaryAttachments = rootData.binaryTypeElements
- .map(element => this.buildBinaryAttachmentData(form, originalAttachments, element))
- .filter(({ attachment }) => attachment)
- .reduce((binaryAttachments, { filename, attachment }) => ({ ...binaryAttachments, [filename]: attachment }), {});
- const newFileAttachments = FileManager
- .getCurrentFiles()
- .map(file => ({
- name: `${this.USER_FILE_ATTACHMENT_PREFIX}${file.name}`,
- content_type: file.type,
- data: new Blob([ file ], { type: file.type })
- }))
- .reduce((attachments, { name, content_type, data }) => ({ ...attachments, [name]: { content_type, data } }), {});
- const existingAttachments = Object
- .entries(originalAttachments)
- // Keep custom attachments and existing file attachments still referenced by a field
- .filter(([key]) => hasCustomAttachmentName(key) || isExistingFileAttachment(key))
- .reduce((existingAttachments, [key, attachment]) => ({ ...existingAttachments, [key]: attachment }), {});
-
- const attachments = {
- ...existingAttachments,
- ...newFileAttachments,
- ...binaryAttachments
- };
- return Object.keys(attachments).length ? attachments : undefined;
- }
-
unload(form) {
if (form !== this.currentForm) {
return;
diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts
index b9ce1b5acde..37999f4f028 100644
--- a/webapp/src/ts/services/form.service.ts
+++ b/webapp/src/ts/services/form.service.ts
@@ -417,6 +417,7 @@ export class FormService {
const docs = await this.enketoService.saveContact(enketoForm, defaultData!);
const preparedDocs = await this.applyTransitions(docs);
+ await this.validateAttachments(preparedDocs.preparedDocs);
const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type);
diff --git a/webapp/src/ts/services/form/form-data.ts b/webapp/src/ts/services/form/form-data.ts
index 30493594d6b..326213a8ce6 100644
--- a/webapp/src/ts/services/form/form-data.ts
+++ b/webapp/src/ts/services/form/form-data.ts
@@ -1,29 +1,50 @@
import { FormConfig } from '@mm-services/form/form-config';
import { Xpath } from '@mm-providers/xpath-element-path.provider';
import { v7 as uuid } from 'uuid';
+import * as FileManager from '../../../js/enketo/file-manager';
+
+const USER_BINARY_ATTACHMENT_PREFIX = 'user-file';
+const USER_FILE_ATTACHMENT_PREFIX = `${USER_BINARY_ATTACHMENT_PREFIX}-`;
+
+const DB_DOC_SELECTOR = '[db-doc=true i]';
export class EnketoFormData {
+ public readonly binaryTypeElements: Element[];
+
constructor(
public readonly rootElement: Element,
public readonly id: string,
- ) { }
-
- public deserialize(formConfig: FormConfig): Record {
- return this.nodesToJs(
- this.getChildElements(this.rootElement),
- formConfig.repeatPaths,
- Xpath.getElementRawXPath(this.rootElement)
- );
+ ) {
+ this.binaryTypeElements = Array
+ .from(this.rootElement.querySelectorAll('[type=binary]'))
+ .filter(element => !this.isInSubDbDoc(element));
}
- public deserializeDoc(formConfig: FormConfig): Record {
+ public deserializeDoc(
+ formConfig: FormConfig,
+ reportedDate: number,
+ originalDoc?: Record
+ ): Record {
+ // Resolve the attachments first because moving a binary value into an attachment clears the field value.
+ const attachments = this.getDocAttachments(formConfig, originalDoc?._attachments);
return {
+ ...originalDoc,
...this.deserialize(formConfig),
_id: this.id,
- form_version: formConfig.doc.xmlVersion
+ form_version: formConfig.doc.xmlVersion,
+ reported_date: originalDoc?.reported_date || reportedDate,
+ _attachments: attachments
};
}
+ protected deserialize(formConfig: FormConfig): Record {
+ return this.nodesToJs(
+ this.getChildElements(this.rootElement),
+ formConfig.repeatPaths,
+ Xpath.getElementXPath(this.rootElement)
+ );
+ }
+
protected isElementNode(node: unknown): node is Element {
return node?.['nodeType'] === Node.ELEMENT_NODE;
}
@@ -49,11 +70,6 @@ export class EnketoFormData {
}, {});
}
- private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) {
- const elements = this.getChildElements(node);
- return elements.length ? this.nodesToJs(elements, repeatPaths, nodePath) : node.textContent;
- }
-
protected findChildNode(element: Element, tagName: string) {
return Array
.from(element.children)
@@ -63,28 +79,117 @@ export class EnketoFormData {
protected getDocId(element: Element) {
return this.findChildNode(element, '_id')?.textContent || uuid();
}
-}
-export abstract class EnketoRootFormData extends EnketoFormData {
- public readonly binaryTypeElements: Element[];
-
- protected constructor(
- rootElement: Element,
- id: string,
+ protected getDocAttachments(
+ { repeatPaths }: FormConfig,
+ originalAttachments: Record = {},
+ xpathPrefix = ''
) {
- super(rootElement, id);
- this.binaryTypeElements = Array.from(this.rootElement.querySelectorAll('[type=binary]'));
+ const isOrphanedFileAttachment = (fileName: string) => fileName.startsWith(USER_FILE_ATTACHMENT_PREFIX)
+ && !this.findNodeWithTextContent(fileName.slice(USER_FILE_ATTACHMENT_PREFIX.length));
+ const binaryAttachments = this.binaryTypeElements
+ .map(element => this.buildBinaryAttachmentData(element, xpathPrefix, repeatPaths))
+ .filter(({ attachment }) => attachment)
+ .reduce((binaryAttachments, { filename, attachment }) => ({ ...binaryAttachments, [filename]: attachment }), {});
+ const newFileAttachments = FileManager
+ .getCurrentFiles()
+ .filter(({ name }) => this.findNodeWithTextContent(name))
+ .map(file => ({
+ name: `${USER_FILE_ATTACHMENT_PREFIX}${file.name}`,
+ content_type: file.type,
+ data: new Blob([ file ], { type: file.type })
+ }))
+ .reduce((attachments, { name, content_type, data }) => ({ ...attachments, [name]: { content_type, data } }), {});
+ const existingAttachments = Object
+ .entries(originalAttachments)
+ // Keep custom/binary attachments and existing file attachments still referenced by a field
+ .filter(([key]) => !isOrphanedFileAttachment(key))
+ .reduce((existingAttachments, [key, attachment]) => ({ ...existingAttachments, [key]: attachment }), {});
+
+ const attachments = {
+ ...existingAttachments,
+ ...newFileAttachments,
+ ...binaryAttachments
+ };
+ return Object.keys(attachments).length ? attachments : undefined;
}
- public findNodeWithTextContent(textContent: string) {
+ private findNodeWithTextContent(textContent: string) {
// XPath query is not viable here because attachment filenames can contain chars that break the XPath (e.g. ")
return Array
.from(this.rootElement.querySelectorAll('*'))
- .find(node => node.textContent === textContent) ?? null;
+ .filter(node => node.textContent === textContent)
+ .find(element => !this.isInSubDbDoc(element)) ?? null;
+ }
+
+ private getJsValueForNode(node: Element, repeatPaths: string[], nodePath: string) {
+ const elements = this.getChildElements(node);
+ return elements.length ? this.nodesToJs(elements, repeatPaths, nodePath) : node.textContent;
+ }
+
+ private isInSubDbDoc(element: Element) {
+ const nearestDbDoc = element.closest(DB_DOC_SELECTOR);
+ return !!nearestDbDoc && nearestDbDoc !== this.rootElement && this.rootElement.contains(nearestDbDoc);
+ }
+
+ private buildBinaryAttachmentData(element: Element, xpathPrefix: string, repeatPaths: string[]) {
+ const rootXpath = Xpath.getElementPositionalXPath(this.rootElement, repeatPaths);
+ const xpath = Xpath.getElementPositionalXPath(element, repeatPaths);
+ const relativeXpath = xpath.slice(rootXpath.length);
+ const filename = `${USER_BINARY_ATTACHMENT_PREFIX}${xpathPrefix}${relativeXpath}`;
+ const data = element.textContent;
+ element.textContent = '';
+ return {
+ filename,
+ attachment: data ? { data, content_type: 'image/png' } : null
+ };
}
}
-export class EnketoContactFormData extends EnketoRootFormData {
+/**
+ * Custom logic for the root contact in a contact form.
+ */
+class EnketoRootContactData extends EnketoFormData {
+ public override deserializeDoc(
+ formConfig: FormConfig,
+ reportedDate: number,
+ originalDoc?: Record
+ ): Record {
+ // Need to double-check existing file attachments since contact edit forms might only have a subset of fields.
+ // The default deserialize logic could drop attachments associated with properties not included in edit form.
+ const originalFileAttachmentEntries = Object
+ .entries(originalDoc?._attachments || {})
+ .filter(([key]) => key.startsWith(USER_FILE_ATTACHMENT_PREFIX));
+ const doc = super.deserializeDoc(formConfig, reportedDate, originalDoc);
+ const existingFileAttachments = originalFileAttachmentEntries
+ .filter(([key]) => this.hasPropertyWithValue(key.slice(USER_FILE_ATTACHMENT_PREFIX.length), doc))
+ .reduce((existingAttachments, [key, attachment]) => ({ ...existingAttachments, [key]: attachment }), {});
+ const attachments = {
+ ...existingFileAttachments,
+ ...doc._attachments
+ };
+ return {
+ ...doc,
+ parent: this.liftIdValue(doc.parent),
+ contact: this.liftIdValue(doc.contact),
+ _attachments: Object.keys(attachments).length ? attachments : undefined
+ };
+ }
+
+ private liftIdValue(idValue: unknown) {
+ return typeof idValue === 'string' ? { _id: idValue } : idValue;
+ }
+
+ private hasPropertyWithValue(value: string, obj: Record): boolean {
+ return Object
+ .values(obj)
+ .some(propertyValue => propertyValue && typeof propertyValue === 'object'
+ ? this.hasPropertyWithValue(value, propertyValue)
+ : propertyValue === value);
+ }
+}
+
+export class EnketoContactFormData extends EnketoFormData {
public static readonly SIBLING_FIELD_NAMES = ['parent', 'contact'] as const;
private readonly childElements: Element[];
private readonly rootContactElement: Element;
@@ -104,14 +209,8 @@ export class EnketoContactFormData extends EnketoRootFormData {
this.rootContactElement = elementForType;
}
- public deserializeDoc(formConfig: FormConfig): Record {
- const rootDoc = new EnketoFormData(this.rootContactElement, this.id).deserializeDoc(formConfig);
- const liftIdValue = (idValue: unknown) => typeof idValue === 'string' ? { _id: idValue } : idValue;
- return {
- ...rootDoc,
- parent: liftIdValue(rootDoc.parent),
- contact: liftIdValue(rootDoc.contact)
- };
+ public getContactData() {
+ return new EnketoRootContactData(this.rootContactElement, this.id);
}
public getChildData() {
@@ -124,22 +223,67 @@ export class EnketoContactFormData extends EnketoRootFormData {
}
}
-export class EnketoReportFormData extends EnketoRootFormData {
+export class EnketoReportFormData extends EnketoFormData {
private readonly dbDocElements: Element[];
public readonly hiddenElements: Element[];
public readonly dbDocRefElements: Element[];
constructor(xmlDoc: XMLDocument, id: string) {
super(xmlDoc.documentElement, id);
- this.dbDocElements = Array.from(this.rootElement.querySelectorAll('[db-doc=true i]'));
+ this.dbDocElements = Array.from(this.rootElement.querySelectorAll(DB_DOC_SELECTOR));
this.hiddenElements = Array.from(this.rootElement.querySelectorAll('[tag=hidden i]'));
this.dbDocRefElements = Array.from(this.rootElement.querySelectorAll('[db-doc-ref]'));
}
+ public override deserializeDoc(
+ formConfig: FormConfig,
+ reportedDate: number,
+ originalDoc?: Record
+ ): Record {
+ // Resolve the attachments first because moving a binary value into an attachment clears the field value.
+ const attachments = this.getDocAttachments(formConfig, originalDoc?._attachments, '/fields');
+ return {
+ ...originalDoc,
+ _id: this.id,
+ form_version: formConfig.doc.xmlVersion,
+ reported_date: originalDoc?.reported_date || reportedDate,
+ fields: this.deserialize(formConfig),
+ _attachments: attachments
+ };
+ }
+
public getDbDocData() {
- return this.dbDocElements.map(dbDoc => new EnketoFormData(
+ const dbDocs = this.dbDocElements.map(dbDoc => new EnketoFormData(
dbDoc,
this.getDocId(dbDoc)
));
+ const allData = [this, ...dbDocs];
+ // Populate the db-doc-ref elements
+ this.dbDocRefElements.forEach(element => {
+ const referencedDoc = this.findReferencedDoc(element, element.getAttribute('db-doc-ref'), allData);
+ if (referencedDoc) {
+ element.textContent = referencedDoc.id;
+ }
+ });
+ return dbDocs;
+ }
+
+ private findReferencedDoc(refElement: Element, reference: string | null, allData: EnketoFormData[]) {
+ const target = reference?.trim().replace(/^\.?\//, ''); // strip leading "./" or "/"
+ if (!target) {
+ return;
+ }
+ const matches = allData.filter(({ rootElement }) => {
+ const path = Xpath.getElementXPath(rootElement).replace(/^\//, ''); // strip leading "/"
+ return path === target || path.endsWith(`/${target}`);
+ });
+
+ // For the docs that match the path tail, find the one with the closest ancestor node to the refElement.
+ for (let ancestor: Element | null = refElement; ancestor; ancestor = ancestor.parentElement) {
+ const match = matches.find(({ rootElement }) => ancestor?.contains(rootElement));
+ if (match) {
+ return match;
+ }
+ }
}
}
diff --git a/webapp/src/ts/services/format-data-record.service.ts b/webapp/src/ts/services/format-data-record.service.ts
index 19611e14951..21251577876 100644
--- a/webapp/src/ts/services/format-data-record.service.ts
+++ b/webapp/src/ts/services/format-data-record.service.ts
@@ -465,8 +465,19 @@ export class FormatDataRecordService {
if (isImagePath(filePath)) {
return filePath;
}
+ const labelParts = label.split('.').slice(1);
+ const binaryFilePath = labelParts
+ .slice(1)
+ .reduce(
+ // Properly encode positional indicator
+ (path, part) => /^\d+$/.test(part) ? `${path}[${Number(part) + 1}]` : `${path}/${part}`,
+ 'user-file/fields'
+ );
+ if (isImagePath(binaryFilePath)) {
+ return binaryFilePath;
+ }
// Fall back to the old style of naming image attachments
- const legacyFilePath = 'user-file/' + label.split('.').slice(1).join('/');
+ const legacyFilePath = 'user-file/' + labelParts.join('/');
if (isImagePath(legacyFilePath)) {
return legacyFilePath;
}
@@ -506,14 +517,13 @@ export class FormatDataRecordService {
const label = 'report.' + doc.form;
const fields = this.getFields(doc, [], doc.fields, label, 0);
this.includeNonFormFieldsXml(doc, fields);
- const hide = doc.hidden_fields || [];
- hide.push('inputs');
- return _.filter(fields, (field) => {
- return _.every(hide, (h) => {
- const hiddenLabel = label + '.' + h;
- return hiddenLabel !== field.label && field.label.indexOf(hiddenLabel + '.') !== 0;
- });
- });
+ const hiddenLabels = ['inputs', ...doc.hidden_fields || []].map(field => `${label}.${field}`);
+ const isHidden = (fieldLabel: string) => {
+ // Drop any position indicators for arrays (e.g. repeat.1.field > repeat.field)
+ const positionlessLabel = fieldLabel.replace(/\.\d+(?=\.|$)/g, '');
+ return hiddenLabels.some(hidden => positionlessLabel === hidden || positionlessLabel.startsWith(`${hidden}.`));
+ };
+ return fields.filter(field => !isHidden(field.label));
}
private formatXmlFields(doc) {
diff --git a/webapp/tests/karma/ts/providers/xpath-element-path.provider.spec.ts b/webapp/tests/karma/ts/providers/xpath-element-path.provider.spec.ts
new file mode 100644
index 00000000000..008b0d1752e
--- /dev/null
+++ b/webapp/tests/karma/ts/providers/xpath-element-path.provider.spec.ts
@@ -0,0 +1,135 @@
+import { expect } from 'chai';
+import { Xpath } from '@mm-providers/xpath-element-path.provider';
+
+const parseXml = (xml: string): XMLDocument => new DOMParser().parseFromString(xml, 'text/xml');
+
+const getElement = (xml: string, selector: string): Element => {
+ const element = parseXml(xml).querySelector(selector);
+ if (!element) {
+ throw new Error(`No element found for selector [${selector}]`);
+ }
+ return element;
+};
+
+describe('Xpath provider', () => {
+ describe('getElementXPath', () => {
+ it('returns the path of the document element', () => {
+ const doc = parseXml('Sally');
+ expect(Xpath.getElementXPath(doc.documentElement)).to.equal('/data');
+ });
+
+ it('returns the path of a nested element', () => {
+ const xml = '-47.15';
+ expect(Xpath.getElementXPath(getElement(xml, 'lat'))).to.equal('/data/address/geo/lat');
+ });
+
+ it('includes the namespace prefix', () => {
+ const xml = 'Sally';
+ expect(Xpath.getElementXPath(getElement(xml, 'name'))).to.equal('/data/my:group/my:name');
+ });
+
+ it('returns the same path for repeated elements', () => {
+ const xml = 'ab';
+ const [first, second] = Array.from(parseXml(xml).querySelectorAll('name'));
+ expect(Xpath.getElementXPath(first)).to.equal('/data/child/name');
+ expect(Xpath.getElementXPath(second)).to.equal('/data/child/name');
+ });
+ });
+
+ describe('getElementPositionalXPath', () => {
+ it('returns the path of the document element', () => {
+ const doc = parseXml('Sally');
+ expect(Xpath.getElementPositionalXPath(doc.documentElement, ['/data'])).to.equal('/data[1]');
+ });
+
+ it('adds no positions when there are no repeat paths', () => {
+ const xml = 'ab';
+ const [first, second] = Array.from(parseXml(xml).querySelectorAll('name'));
+
+ expect(Xpath.getElementPositionalXPath(first)).to.equal('/data/child/name');
+ expect(Xpath.getElementPositionalXPath(second)).to.equal('/data/child/name');
+ });
+
+ it('adds the position of each repeat instance', () => {
+ const xml = `
+
+ a
+ b
+ c
+ `;
+ const names = Array.from(parseXml(xml).querySelectorAll('name'));
+
+ const paths = names.map(name => Xpath.getElementPositionalXPath(name, ['/data/child']));
+
+ expect(paths).to.deep.equal([
+ '/data/child[1]/name',
+ '/data/child[2]/name',
+ '/data/child[3]/name',
+ ]);
+ });
+
+ it('adds the position of a repeat instance that is the only instance', () => {
+ const xml = 'a';
+
+ const path = Xpath.getElementPositionalXPath(getElement(xml, 'name'), ['/data/child']);
+
+ expect(path).to.equal('/data/child[1]/name');
+ });
+
+ it('adds the position of the repeat instance itself', () => {
+ const xml = 'ab';
+ const children = Array.from(parseXml(xml).querySelectorAll('child'));
+
+ const paths = children.map(child => Xpath.getElementPositionalXPath(child, ['/data/child']));
+
+ expect(paths).to.deep.equal(['/data/child[1]', '/data/child[2]']);
+ });
+
+ it('adds a position at every level of nested repeats', () => {
+ const xml = `
+
+
+ ugali
+ chapati
+
+
+ porridge
+
+ `;
+ const types = Array.from(parseXml(xml).querySelectorAll('type'));
+
+ const paths = types.map(type => Xpath
+ .getElementPositionalXPath(type, ['/data/child', '/data/child/foods']));
+
+ expect(paths).to.deep.equal([
+ '/data/child[1]/foods[1]/type',
+ '/data/child[1]/foods[2]/type',
+ '/data/child[2]/foods[1]/type',
+ ]);
+ });
+
+ it('adds no position for a repeated element that is not a repeat path', () => {
+ const xml = 'ab';
+ const [first, second] = Array.from(parseXml(xml).querySelectorAll('name'));
+ const repeatPaths = ['/data/other', '/data/child/name/deeper'];
+
+ expect(Xpath.getElementPositionalXPath(first, repeatPaths)).to.equal('/data/child/name');
+ expect(Xpath.getElementPositionalXPath(second, repeatPaths)).to.equal('/data/child/name');
+ });
+
+ it('counts only same-named siblings when positioning a repeat instance', () => {
+ const xml = `
+
+ x
+ a
+ y
+ b
+ `;
+ const names = Array.from(parseXml(xml).querySelectorAll('name'));
+
+ const paths = names.map(name => Xpath.getElementPositionalXPath(name, ['/data/child']));
+
+ expect(paths).to.deep.equal(['/data/child[1]/name', '/data/child[2]/name']);
+ });
+ });
+});
diff --git a/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts b/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts
index b9168050a84..c0cbb9cfeb9 100644
--- a/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts
+++ b/webapp/tests/karma/ts/services/enketo-prepopulation-data.service.spec.ts
@@ -494,6 +494,35 @@ describe('EnketoPrepopulationData service', () => {
);
});
+ it('does not throw on _attachments keys that are invalid jQuery selectors', () => {
+ // Inline-binary attachments are named `user-file/