diff --git a/src/lib/validation/form/no-required-notes.js b/src/lib/validation/form/no-required-notes.js
new file mode 100644
index 000000000..56f515409
--- /dev/null
+++ b/src/lib/validation/form/no-required-notes.js
@@ -0,0 +1,23 @@
+const { getBindNodes } = require('../../forms-utils');
+
+module.exports = {
+ requiresInstance: false,
+ skipFurtherValidation: false,
+ execute: async({ xformPath, xmlDoc }) => {
+ const errors = [];
+
+ const requiredNotes = getBindNodes(xmlDoc)
+ .filter(bind => bind.getAttribute('readonly') === 'true()') // Notes will not be conditionally readonly
+ .filter(bind => {
+ const required = bind.getAttribute('required');
+ return required && required !== 'false()';
+ })
+ .filter(bind => bind.getAttribute('type') === 'string')
+ .filter(bind => !bind.getAttribute('calculate'))
+ .map(bind => bind.getAttribute('nodeset'));
+ if(requiredNotes.length) {
+ errors.push(`Form at ${xformPath} contains the following note fields with 'required' expressions: [${requiredNotes.join(', ')}]`);
+ }
+ return { errors, warnings: [] };
+ }
+};
diff --git a/test/lib/validate-forms.spec.js b/test/lib/validate-forms.spec.js
index 959249e3d..a2a727a43 100644
--- a/test/lib/validate-forms.spec.js
+++ b/test/lib/validate-forms.spec.js
@@ -49,6 +49,11 @@ describe('validate-forms', () => {
expect(checkXPathsExist.requiresInstance).to.equal(false);
expect(checkXPathsExist.skipFurtherValidation).to.equal(false);
+ const noRequiredNotes = validations.shift();
+ expect(noRequiredNotes.name).to.equal('no-required-notes.js');
+ expect(noRequiredNotes.requiresInstance).to.equal(false);
+ expect(noRequiredNotes.skipFurtherValidation).to.equal(false);
+
expect(validations).to.be.empty;
});
diff --git a/test/lib/validation/form/no-required-notes.spec.js b/test/lib/validation/form/no-required-notes.spec.js
new file mode 100644
index 000000000..5a412952e
--- /dev/null
+++ b/test/lib/validation/form/no-required-notes.spec.js
@@ -0,0 +1,141 @@
+const { expect } = require('chai');
+const { DOMParser } = require('@xmldom/xmldom');
+const noRequiredNotes = require('../../../../src/lib/validation/form/no-required-notes');
+
+const domParser = new DOMParser();
+
+const getXml = (bindData = '') => `
+
+
+
+ Test
+
+
+
+ Harambe
+
+
+
+
+
+ Summary
+
+
+
+
+
+ ${bindData}
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const createBindData = fields =>
+ fields
+ .map(({ name, type, calculate, readonly, required }) => {
+ const calc = calculate ? `calculate="${calculate}"` : '';
+ const read = readonly ? `readonly="${readonly}"` : '';
+ const req = required ? `required="${required}"` : '';
+ return ``;
+ })
+ .join('');
+
+const getXmlDoc = (fields, instance) => domParser.parseFromString(getXml(createBindData(fields), instance));
+const xformPath = '/my/form/path/form.xml';
+
+const assertEmpty = (output) => {
+ expect(output.warnings).is.empty;
+ expect(output.errors, output.errors).is.empty;
+};
+
+const getExpectedErrorMsg = requiredNotes => `Form at ${xformPath} contains the following note fields with 'required' expressions: [${requiredNotes.join(', ')}]`;
+
+describe('no-required-notes', () => {
+ it('resolves OK for form with no notes', () => {
+ return noRequiredNotes.execute({ xformPath, xmlDoc: getXmlDoc([]) })
+ .then(output => assertEmpty(output));
+ });
+
+ it('resolves OK for note that is not required', () => {
+ const fields = [{
+ name: '/data/name',
+ type: 'string',
+ readonly: 'true()',
+ required: 'false()'
+ },{
+ name: '/data/address/street-nbr',
+ type: 'string',
+ readonly: 'true()'
+ }];
+ return noRequiredNotes.execute({ xformPath, xmlDoc: getXmlDoc(fields) })
+ .then(output => assertEmpty(output));
+ });
+
+ it('resolves OK for calculate that is required', () => {
+ const fields = [{
+ name: '/data/name',
+ type: 'string',
+ calculate: 'concat("Hello", "World")',
+ readonly: 'true()',
+ required: 'true()'
+ }];
+ return noRequiredNotes.execute({ xformPath, xmlDoc: getXmlDoc(fields) })
+ .then(output => assertEmpty(output));
+ });
+
+ it('resolves OK for string question that is not readonly', () => {
+ const fields = [{
+ name: '/data/name',
+ type: 'string',
+ readonly: 'false()',
+ required: 'true()'
+ },{
+ name: '/data/address/street-nbr',
+ type: 'string',
+ readonly: 'false()'
+ }];
+ return noRequiredNotes.execute({ xformPath, xmlDoc: getXmlDoc(fields) })
+ .then(output => assertEmpty(output));
+ });
+
+ it('resolves OK for non-string question', () => {
+ const fields = [{
+ name: '/data/age',
+ type: 'int',
+ readonly: 'true()',
+ required: 'true()'
+ }];
+ return noRequiredNotes.execute({ xformPath, xmlDoc: getXmlDoc(fields) })
+ .then(output => assertEmpty(output));
+ });
+
+ it('returns errors for required notes', () => {
+ const fields = [{
+ name: '/data/name',
+ type: 'string',
+ readonly: 'true()',
+ required: 'true()'
+ },{
+ name: '/data/address/street-nbr',
+ type: 'string',
+ readonly: 'true()',
+ calculate: '',
+ required: '/data/age > 5'
+ }];
+ return noRequiredNotes.execute({ xformPath, xmlDoc: getXmlDoc(fields) })
+ .then(output => {
+ expect(output.warnings).is.empty;
+ expect(output.errors).to.deep.equal([getExpectedErrorMsg(fields.map(f => f.name))]);
+ });
+ });
+});