diff --git a/api/server.js b/api/server.js
index f7af7f7a780..2d1c467eecc 100644
--- a/api/server.js
+++ b/api/server.js
@@ -25,6 +25,7 @@ process
const serverUtils = require('./src/server-utils');
const uploadDefaultDocs = require('./src/upload-default-docs');
const generateServiceWorker = require('./src/generate-service-worker');
+ const manifest = require('./src/services/manifest');
const apiPort = process.env.API_PORT || 5988;
try {
@@ -61,6 +62,10 @@ process
await migrations.run();
logger.info('Database migrations completed successfully');
+ logger.info('Generating manifest');
+ await manifest.generate();
+ logger.info('Manifest generated successfully');
+
logger.info('Generating service worker');
await generateServiceWorker.run();
logger.info('Service worker generated successfully');
diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js
index 088d3e3a7e6..75f487d3f22 100644
--- a/api/src/controllers/login.js
+++ b/api/src/controllers/login.js
@@ -13,6 +13,7 @@ const logger = require('../logger');
const db = require('../db');
const localeUtils = require('locale');
const cookie = require('../services/cookie');
+const brandingService = require('../services/branding');
const templates = {
login: {
@@ -192,36 +193,8 @@ const setCookies = (req, res, sessionRes) => {
});
};
-const getInlineImage = (data, contentType) => `data:${contentType};base64,${data}`;
-
-const getDefaultBranding = () => {
- const logoPath = path.join(__dirname, '..', 'resources', 'logo', 'medic-logo-light-full.svg');
- return promisify(fs.readFile)(logoPath, {}).then(logo => {
- const data = Buffer.from(logo).toString('base64');
- return {
- name: 'Medic',
- logo: getInlineImage(data, 'image/svg+xml')
- };
- });
-};
-
-const getBranding = () => {
- return db.medic.get('branding', {attachments: true})
- .then(doc => {
- const image = doc._attachments[doc.resources.logo];
- return {
- name: doc.title,
- logo: getInlineImage(image.data, image.content_type)
- };
- })
- .catch(err => {
- logger.warn('Could not find branding doc on CouchDB: %o', err);
- return getDefaultBranding();
- });
-};
-
const renderTokenLogin = (req, res) => {
- return getBranding()
+ return brandingService.get()
.then(branding => render('tokenLogin', req, branding, { tokenUrl: req.url }))
.then(body => res.send(body));
};
@@ -283,7 +256,8 @@ const loginByToken = (req, res) => {
};
const renderLogin = (req) => {
- return getBranding().then(branding => render('login', req, branding));
+ return brandingService.get()
+ .then(branding => render('login', req, branding));
};
module.exports = {
diff --git a/api/src/services/branding.js b/api/src/services/branding.js
new file mode 100644
index 00000000000..27accc2a020
--- /dev/null
+++ b/api/src/services/branding.js
@@ -0,0 +1,52 @@
+const fs = require('fs');
+const { promisify } = require('util');
+const path = require('path');
+
+const db = require('../db');
+const logger = require('../logger');
+
+const DEFAULT_LOGO_PATH = path.join(__dirname, '..', 'resources', 'logo', 'medic-logo-light-full.svg');
+
+const getInlineImage = (data, contentType) => `data:${contentType};base64,${data}`;
+
+const getBrandingDoc = async () => {
+ try {
+ return await db.medic.get('branding', { attachments: true });
+ } catch(e) {
+ logger.warn('Could not find branding doc on CouchDB: %o', e);
+ return;
+ }
+};
+
+const getName = (doc) => (doc && doc.title) || 'CHT';
+
+const getLogo = async (doc) => {
+ let data;
+ let contentType;
+ const name = doc && doc.resources && doc.resources.logo;
+ if (name) {
+ const image = doc._attachments[name];
+ data = image.data;
+ contentType = image.content_type;
+ } else {
+ const logo = await promisify(fs.readFile)(DEFAULT_LOGO_PATH, {});
+ data = Buffer.from(logo).toString('base64');
+ contentType = 'image/svg+xml';
+ }
+ return getInlineImage(data, contentType);
+};
+
+const getIcon = (doc) => (doc && doc.resources && doc.resources.icon) || 'icon.png';
+
+const getBranding = async () => {
+ const doc = await getBrandingDoc();
+ const logo = await getLogo(doc);
+ return {
+ name: getName(doc),
+ logo: logo,
+ icon: getIcon(doc),
+ doc: doc
+ };
+};
+
+module.exports.get = getBranding;
diff --git a/api/src/services/config-watcher.js b/api/src/services/config-watcher.js
index d62c5d06137..4a6c951a43a 100644
--- a/api/src/services/config-watcher.js
+++ b/api/src/services/config-watcher.js
@@ -8,6 +8,7 @@ const ddocExtraction = require('../ddoc-extraction');
const resourceExtraction = require('../resource-extraction');
const generateXform = require('./generate-xform');
const generateServiceWorker = require('../generate-service-worker');
+const manifest = require('./manifest');
const config = require('../config');
const MEDIC_DDOC_ID = '_design/medic';
@@ -119,7 +120,14 @@ const handleFormChange = (change) => {
};
const handleBrandingChanges = () => {
- return updateServiceWorker();
+ return updateManifest()
+ .then(() => updateServiceWorker());
+};
+
+const updateManifest = () => {
+ return manifest.generate().catch(err => {
+ logger.error('Failed to generate manifest: %o', err);
+ });
};
const updateServiceWorker = () => {
diff --git a/api/src/services/manifest.js b/api/src/services/manifest.js
new file mode 100644
index 00000000000..803362cc127
--- /dev/null
+++ b/api/src/services/manifest.js
@@ -0,0 +1,35 @@
+const { promisify } = require('util');
+const fs = require('fs');
+const path = require('path');
+
+const _ = require('lodash');
+
+const environment = require('../environment');
+const brandingService = require('./branding');
+
+const EXTRACTED_RESOURCES_PATH = environment.getExtractedResourcesPath();
+const MANIFEST_OUTPUT_PATH = path.join(EXTRACTED_RESOURCES_PATH, 'manifest.json');
+const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'manifest.json');
+
+const writeJson = async (branding) => {
+ const file = await promisify(fs.readFile)(TEMPLATE_PATH, { encoding: 'utf-8' });
+ const template = _.template(file);
+ const json = template({ branding });
+ return await promisify(fs.writeFile)(MANIFEST_OUTPUT_PATH, json);
+};
+
+const writeIcon = async (doc) => {
+ const name = doc && doc.resources && doc.resources.icon;
+ const attachment = name && doc._attachments[name];
+ if (attachment) {
+ const contents = Buffer.from(attachment.data, 'base64');
+ const outputPath = path.join(EXTRACTED_RESOURCES_PATH, 'img', name);
+ await promisify(fs.writeFile)(outputPath, contents);
+ }
+};
+
+module.exports.generate = async () => {
+ const branding = await brandingService.get();
+ await writeJson(branding);
+ await writeIcon(branding.doc);
+};
diff --git a/api/src/services/report/smsparser.js b/api/src/services/report/smsparser.js
index 20d49c82011..81f745d6fce 100644
--- a/api/src/services/report/smsparser.js
+++ b/api/src/services/report/smsparser.js
@@ -6,6 +6,8 @@ const mpParser = require('./mp-parser');
const javarosaParser = require('./javarosa-parser');
const textformsParser = require('./textforms-parser');
const logger = require('../../logger');
+const moment = require('moment');
+const bs = require('bikram-sambat');
const MUVUKU_REGEX = /^\s*([A-Za-z]?\d)!.+!.+/;
@@ -117,6 +119,25 @@ const parseNum = raw => {
return Number(std);
};
+const bsToEpoch = (bsYear, bsMonth, bsDay) => {
+ try {
+ const gregDate = bs.toGreg_text(bsYear, bsMonth, bsDay);
+ return moment(gregDate).valueOf();
+ } catch (exception) {
+ logger.error('The provided date could not be converted: %o.', exception);
+ return null;//should be caught by validation in registration
+ }
+};
+
+const getFieldByType = (def, type) => {
+ if (!def || !def.fields) {
+ return;
+ }
+ return Object
+ .keys(def.fields)
+ .find(k => def.fields[k] && def.fields[k].type === type);
+};
+
const lower = str => (str && str.toLowerCase ? str.toLowerCase() : str);
exports.parseField = (field, raw) => {
@@ -159,7 +180,15 @@ exports.parseField = (field, raw) => {
}
// YYYY-MM-DD assume muvuku format for now
// store in milliseconds since Epoch
- return new Date(raw).valueOf();
+ return moment(raw).valueOf();
+ case 'bsDate': {
+ if (!raw) {
+ return null;
+ }
+ const separator = raw[raw.search(/[^0-9]/)];//non-numeric character
+ const dateParts = raw.split(separator);
+ return bsToEpoch(...dateParts);
+ }
case 'boolean': {
if (raw === undefined) {
return;
@@ -194,7 +223,7 @@ exports.parse = (def, doc) => {
let msgData;
const formData = {};
let addOmittedFields = false;
-
+ const aggregateBSDateField = getFieldByType(def, 'bsAggreDate');
if (!def || !doc || !doc.message || !def.fields) {
return {};
}
@@ -237,6 +266,32 @@ exports.parse = (def, doc) => {
}
}
+ if(aggregateBSDateField) {
+ let bsYear;
+ let bsMonth = 1;
+ let bsDay = 1;
+ for (const k of Object.keys(def.fields)) {
+ switch (def.fields[k].type) {
+ case 'bsYear':
+ bsYear = msgData[k];
+ break;
+ case 'bsMonth':
+ bsMonth = msgData[k];
+ break;
+ case 'bsDay':
+ bsDay = msgData[k];
+ break;
+ }
+ }
+
+ if(!bsYear) {
+ logger.error('Can not aggregate bsAggreDate without bsYear');
+ return;
+ }
+
+ formData[aggregateBSDateField] = bsToEpoch(bsYear, bsMonth, bsDay);
+ }
+
// pass along some system generated fields
if (msgData._extra_fields === true) {
formData._extra_fields = true;
diff --git a/api/src/templates/login/index.html b/api/src/templates/login/index.html
index d4291e2e983..d85e75cb733 100644
--- a/api/src/templates/login/index.html
+++ b/api/src/templates/login/index.html
@@ -2,9 +2,11 @@
diff --git a/api/src/templates/login/token-login.html b/api/src/templates/login/token-login.html
index ff0d121e166..bbbb55bfe5f 100644
--- a/api/src/templates/login/token-login.html
+++ b/api/src/templates/login/token-login.html
@@ -2,9 +2,11 @@
+
{{ branding.name }}
+
diff --git a/api/src/templates/manifest.json b/api/src/templates/manifest.json
new file mode 100644
index 00000000000..a9fbcde6104
--- /dev/null
+++ b/api/src/templates/manifest.json
@@ -0,0 +1,19 @@
+{
+ "start_url": "./",
+ "name": "{{ branding.name }}",
+ "display": "standalone",
+ "background_color": "#323232",
+ "theme_color": "#323232",
+ "icons": [
+ {
+ "src": "/img/{{ branding.icon }}",
+ "sizes": "any",
+ "purpose": "any"
+ },
+ {
+ "src": "/favicon.ico",
+ "sizes": "32x32",
+ "type": "image"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/api/tests/form-definitions.js b/api/tests/form-definitions.js
index 6d4e2a73376..955c49bb83b 100644
--- a/api/tests/form-definitions.js
+++ b/api/tests/form-definitions.js
@@ -687,5 +687,109 @@ exports.forms = {
}
},
facility_reference: 'cref_rc'
+ },
+ YYYT: {
+ meta: {
+ code: 'YYYT',
+ label: 'Test bsDate'
+ },
+ fields: {
+ patient_id: {
+ labels: {
+ long: 'Patient ID',
+ tiny: 'ID'
+ },
+ type: 'string',
+ required: true
+ },
+ lmp_date: {
+ labels: {
+ short: 'LMP Date in BS',
+ tiny: 'LMP_BS'
+ },
+ type: 'bsDate',
+ required: true
+ }
+ }
+ },
+ YYYS: {
+ meta: {
+ code: 'YYYS',
+ label: 'Test date parts in BS calendar'
+ },
+ fields: {
+ patient_id: {
+ labels: {
+ long: 'Patient ID',
+ tiny: 'ID'
+ },
+ type: 'string',
+ required: true
+ },
+ lmp_year: {
+ labels: {
+ short: 'Year',
+ tiny: 'Y'
+ },
+ type: 'bsYear',
+ required: true
+ },
+ lmp_month: {
+ labels: {
+ short: 'Month',
+ tiny: 'M'
+ },
+ type: 'bsMonth',
+ required: true
+ },
+ lmp_day: {
+ labels: {
+ short: 'Day',
+ tiny: 'D'
+ },
+ type: 'bsDay',
+ required: true
+ },
+ lmp_date: {
+ labels: {
+ short: 'LMP Date',
+ tiny: 'LMP'
+ },
+ type: 'bsAggreDate',
+ required: true
+ }
+ }
+ },
+ YYYR: {
+ meta: {
+ code: 'YYYR',
+ label: 'Test date parts in BS calendar without bsMonth, bsDay'
+ },
+ fields: {
+ patient_id: {
+ labels: {
+ long: 'Patient ID',
+ tiny: 'ID'
+ },
+ type: 'string',
+ required: true
+ },
+ lmp_year: {
+ labels: {
+ short: 'Year',
+ tiny: 'Y'
+ },
+ type: 'bsYear',
+ required: true
+ },
+ lmp_date: {
+ labels: {
+ short: 'LMP Date',
+ tiny: 'LMP'
+ },
+ type: 'bsAggreDate',
+ required: true
+ }
+ }
}
};
diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js
index 00b2faa475d..b2100e71cb4 100644
--- a/api/tests/mocha/controllers/login.spec.js
+++ b/api/tests/mocha/controllers/login.spec.js
@@ -6,6 +6,7 @@ const auth = require('../../../src/auth');
const cookie = require('../../../src/services/cookie');
const users = require('../../../src/services/users');
const tokenLogin = require('../../../src/services/token-login');
+const branding = require('../../../src/services/branding');
const db = require('../../../src/db').medic;
const sinon = require('sinon');
const config = require('../../../src/config');
@@ -19,6 +20,12 @@ let controller;
let req;
let res;
+const DEFAULT_BRANDING = {
+ logo: 'xyz',
+ name: 'CHT',
+ icon: 'icon.png',
+};
+
describe('login controller', () => {
beforeEach(() => {
@@ -133,25 +140,14 @@ describe('login controller', () => {
it('send login page', () => {
const query = sinon.stub(db, 'query').resolves({ rows: [] });
const linkResources = '; rel=preload; as=style, ; rel=preload; as=script';
- const getDoc = sinon.stub(db, 'get').resolves({
- _id: 'branding',
- resources: {
- logo: 'xyz'
- },
- _attachments: {
- xyz: {
- content_type: 'zes',
- data: 'xsd'
- }
- }
- });
+ const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
const send = sinon.stub(res, 'send');
const setHeader = sinon.stub(res, 'setHeader');
const readFile = sinon.stub(fs, 'readFile')
.callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ translations }}');
sinon.stub(config, 'getTranslationValues').returns({ en: { login: 'English' } });
return controller.get(req, res).then(() => {
- chai.expect(getDoc.callCount).to.equal(1);
+ chai.expect(brandingGet.callCount).to.equal(1);
chai.expect(send.callCount).to.equal(1);
chai.expect(send.args[0][0])
.to.equal('LOGIN PAGE GOES HERE. %7B%22en%22%3A%7B%22login%22%3A%22English%22%7D%7D');
@@ -165,14 +161,14 @@ describe('login controller', () => {
it('when branding doc missing send login page', () => {
const linkResources = '; rel=preload; as=style, ; rel=preload; as=script';
- const getDoc = sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'});
+ const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
sinon.stub(db, 'query').resolves({ rows: [] });
const send = sinon.stub(res, 'send');
const setHeader = sinon.stub(res, 'setHeader');
sinon.stub(fs, 'readFile').callsArgWith(2, null, 'LOGIN PAGE GOES HERE.');
sinon.stub(config, 'getTranslationValues').returns({});
return controller.get(req, res).then(() => {
- chai.expect(getDoc.callCount).to.equal(1);
+ chai.expect(brandingGet.callCount).to.equal(1);
chai.expect(send.callCount).to.equal(1);
chai.expect(send.args[0][0]).to.equal('LOGIN PAGE GOES HERE.');
chai.expect(setHeader.callCount).to.equal(1);
@@ -189,18 +185,7 @@ describe('login controller', () => {
const readFile = sinon.stub(fs, 'readFile').callsArgWith(2, null, 'file content');
sinon.stub(config, 'translate').returns('TRANSLATED VALUE.');
const template = sinon.stub(_, 'template').returns(sinon.stub());
- sinon.stub(db, 'get').resolves({
- _id: 'branding',
- resources: {
- logo: 'xyz'
- },
- _attachments: {
- xyz: {
- content_type: 'zes',
- data: 'xsd'
- }
- }
- });
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
return controller.get(req, res) // first request
.then(() => {
chai.expect(readFile.callCount).to.equal(1);
@@ -218,7 +203,7 @@ describe('login controller', () => {
const linkResources = '; rel=preload; as=style, ; rel=preload; as=script';
const setHeader = sinon.stub(res, 'setHeader');
sinon.stub(db, 'query').resolves({ rows: [ { doc: { code: 'en', name: 'English' } } ] });
- sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'});
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
const send = sinon.stub(res, 'send');
sinon.stub(fs, 'readFile').callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ locales.length }}');
sinon.stub(config, 'translate').returns('TRANSLATED VALUE.');
@@ -236,7 +221,7 @@ describe('login controller', () => {
sinon.stub(db, 'query').resolves({ rows: [
{ doc: { code: 'fr', name: 'French' } }
]});
- sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'});
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
const send = sinon.stub(res, 'send');
sinon.stub(fs, 'readFile').callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ defaultLocale }}');
sinon.stub(config, 'get').withArgs('locale').returns('de');
@@ -249,7 +234,7 @@ describe('login controller', () => {
it('uses application default locale if none of the accept-language headers match', () => {
req.headers = { 'accept-language': 'en' };
sinon.stub(db, 'query').resolves({ rows: [] });
- sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'});
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
const send = sinon.stub(res, 'send');
sinon.stub(fs, 'readFile').callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ defaultLocale }}');
sinon.stub(config, 'get').withArgs('locale').returns('de');
@@ -265,7 +250,7 @@ describe('login controller', () => {
{ doc: { code: 'en', name: 'English' } },
{ doc: { code: 'fr', name: 'French' } }
]});
- sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'});
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
const send = sinon.stub(res, 'send');
sinon.stub(fs, 'readFile').callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ defaultLocale }}');
@@ -278,24 +263,13 @@ describe('login controller', () => {
describe('get login/token', () => {
it('should render the token login page', () => {
sinon.stub(db, 'query').resolves({ rows: [] });
- sinon.stub(db, 'get').resolves({
- _id: 'branding',
- resources: {
- logo: 'xyz'
- },
- _attachments: {
- xyz: {
- content_type: 'zes',
- data: 'xsd'
- }
- }
- });
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
sinon.stub(res, 'send');
sinon.stub(fs, 'readFile').callsArgWith(2, null, 'TOKEN PAGE GOES HERE. {{ translations }}');
sinon.stub(config, 'getTranslationValues').returns({ en: { login: 'English' } });
req.params = { token: 'my_token', hash: 'my_hash' };
return controller.tokenGet(req, res).then(() => {
- chai.expect(db.get.callCount).to.equal(1);
+ chai.expect(branding.get.callCount).to.equal(1);
chai.expect(res.send.callCount).to.equal(1);
chai.expect(res.send.args[0][0])
.to.equal('TOKEN PAGE GOES HERE. %7B%22en%22%3A%7B%22login%22%3A%22English%22%7D%7D');
@@ -750,18 +724,9 @@ describe('login controller', () => {
describe('renderLogin', () => {
it('should get branding and render the login page', () => {
sinon.stub(db, 'query').resolves({ rows: [] });
- sinon.stub(db, 'get').resolves({
- _id: 'branding',
- title: 'something',
- resources: {
- logo: 'xyz'
- },
- _attachments: {
- xyz: {
- content_type: 'zes',
- data: 'xsd'
- }
- }
+ sinon.stub(branding, 'get').resolves({
+ name: 'something',
+ logo: 'data:zes;base64,xsd'
});
sinon.stub(fs, 'readFile')
.callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ translations }} {{ branding.logo }} {{ branding.name }}');
@@ -771,8 +736,7 @@ describe('login controller', () => {
chai.expect(loginPage).to.equal(
'LOGIN PAGE GOES HERE. %7B%22en%22%3A%7B%22login%22%3A%22English%22%7D%7D data:zes;base64,xsd something'
);
- chai.expect(db.get.callCount).to.equal(1);
- chai.expect(db.get.args[0]).to.deep.equal(['branding', { attachments: true }]);
+ chai.expect(branding.get.callCount).to.equal(1);
chai.expect(fs.readFile.callCount).to.equal(1);
chai.expect(db.query.callCount).to.equal(1);
chai.expect(db.query.args[0]).to.deep.equal([
@@ -782,48 +746,16 @@ describe('login controller', () => {
});
});
- it('should render login page when branding doc missing', () => {
- sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'});
- sinon.stub(db, 'query').resolves({ rows: [] });
- sinon.stub(fs, 'readFile');
- fs.readFile
- .withArgs(sinon.match(/medic-logo-light-full\.svg/))
- .callsArgWith(2, null, 'image');
- fs.readFile
- .withArgs(sinon.match(/templates\/login\/index\.html/))
- .callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ branding.logo }} {{ branding.name }}');
-
- sinon.stub(config, 'getTranslationValues').returns({});
-
- return controller.renderLogin(req).then((loginPage) => {
- chai.expect(db.get.callCount).to.equal(1);
- chai.expect(loginPage).to.equal('LOGIN PAGE GOES HERE. data:image/svg+xml;base64,aW1hZ2U= Medic');
- chai.expect(fs.readFile.callCount).to.equal(2);
- });
- });
-
it('should nullcheck the request param', () => {
sinon.stub(db, 'query').resolves({ rows: [] });
- sinon.stub(db, 'get').resolves({
- _id: 'branding',
- title: 'something',
- resources: {
- logo: 'xyz'
- },
- _attachments: {
- xyz: {
- content_type: 'zes',
- data: 'xsd'
- }
- }
- });
+ sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING);
sinon.stub(fs, 'readFile')
.callsArgWith(2, null, 'LOGIN PAGE GOES HERE. {{ translations }} {{ branding.logo }} {{ branding.name }}');
sinon.stub(config, 'getTranslationValues').returns({ en: { login: 'English' } });
return controller.renderLogin().then((loginPage) => {
chai.expect(loginPage).to.equal(
- 'LOGIN PAGE GOES HERE. %7B%22en%22%3A%7B%22login%22%3A%22English%22%7D%7D data:zes;base64,xsd something'
+ 'LOGIN PAGE GOES HERE. %7B%22en%22%3A%7B%22login%22%3A%22English%22%7D%7D xyz CHT'
);
chai.expect(db.query.callCount).to.equal(1);
chai.expect(db.query.args[0]).to.deep.equal([
diff --git a/api/tests/mocha/services/branding.spec.js b/api/tests/mocha/services/branding.spec.js
new file mode 100644
index 00000000000..f54cd95899f
--- /dev/null
+++ b/api/tests/mocha/services/branding.spec.js
@@ -0,0 +1,57 @@
+const chai = require('chai');
+const sinon = require('sinon');
+
+const fs = require('fs');
+const path = require('path');
+
+const db = require('../../../src/db').medic;
+
+const service = require('../../../src/services/branding');
+const baseDir = path.join(__dirname, '..', '..', '..', 'src');
+
+describe('branding service', () => {
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('returns default when missing doc', async () => {
+ const get = sinon.stub(db, 'get').rejects({});
+ const readFile = sinon.stub(fs, 'readFile').callsArgWith(2, null, 'zyx');
+ const result = await service.get();
+ chai.expect(get.callCount).to.equal(1);
+ chai.expect(get.args[0][0]).to.equal('branding');
+ chai.expect(get.args[0][1].attachments).to.equal(true);
+ chai.expect(readFile.callCount).to.equal(1);
+ chai.expect(readFile.args[0][0]).to.equal(baseDir + '/resources/logo/medic-logo-light-full.svg');
+ chai.expect(result.name).to.equal('CHT');
+ chai.expect(result.logo).to.equal('data:image/svg+xml;base64,enl4');
+ chai.expect(result.icon).to.equal('icon.png');
+ });
+
+ it('returns configured branding', async () => {
+ const doc = {
+ title: 'some name',
+ resources: {
+ logo: 'somelogo.png',
+ icon: 'leastfavicon.ico'
+ },
+ _attachments: {
+ 'somelogo.png': {
+ data: 'base64data',
+ content_type: 'image/png'
+ }
+ }
+ };
+ const get = sinon.stub(db, 'get').resolves(doc);
+ const readFile = sinon.stub(fs, 'readFile').callsArgWith(2, null, 'zyx');
+ const result = await service.get();
+ chai.expect(get.callCount).to.equal(1);
+ chai.expect(readFile.callCount).to.equal(0);
+ chai.expect(result.name).to.equal('some name');
+ chai.expect(result.logo).to.equal('data:image/png;base64,base64data');
+ chai.expect(result.icon).to.equal('leastfavicon.ico');
+ chai.expect(result.doc).to.equal(doc);
+ });
+
+});
diff --git a/api/tests/mocha/services/config-watcher.spec.js b/api/tests/mocha/services/config-watcher.spec.js
index bf7b7357c74..af5a310467a 100644
--- a/api/tests/mocha/services/config-watcher.spec.js
+++ b/api/tests/mocha/services/config-watcher.spec.js
@@ -13,6 +13,7 @@ const generateServiceWorker = require('../../../src/generate-service-worker');
const generateXform = require('../../../src/services/generate-xform');
const config = require('../../../src/config');
const bootstrap = require('../../../src/services/config-watcher');
+const manifest = require('../../../src/services/manifest');
let on;
const emitChange = (change) => {
@@ -35,6 +36,7 @@ describe('Configuration', () => {
sinon.stub(settingsService, 'get');
sinon.stub(settingsService, 'update');
sinon.stub(generateServiceWorker, 'run');
+ sinon.stub(manifest, 'generate');
sinon.stub(environment, 'getExtractedResourcesPath')
.returns(path.resolve(__dirname, './../../../../build/ddocs/medic/_attachments'));
sinon.spy(config, 'set');
@@ -265,6 +267,7 @@ describe('Configuration', () => {
describe('branding changes', () => {
it('generates service worker when branding doc is updated', () => {
+ manifest.generate.resolves();
generateServiceWorker.run.resolves();
return emitChange({ id: 'branding' }).then(() => {
@@ -277,7 +280,9 @@ describe('Configuration', () => {
});
it('should terminate process on service worker errors', () => {
+ manifest.generate.resolves();
generateServiceWorker.run.rejects();
+
sinon.stub(process, 'exit');
return emitChange({ id: 'branding' }).then(() => {
diff --git a/api/tests/mocha/services/manifest.spec.js b/api/tests/mocha/services/manifest.spec.js
new file mode 100644
index 00000000000..491e3e3daad
--- /dev/null
+++ b/api/tests/mocha/services/manifest.spec.js
@@ -0,0 +1,78 @@
+const chai = require('chai');
+const sinon = require('sinon');
+
+const fs = require('fs');
+const path = require('path');
+
+const brandingService = require('../../../src/services/branding');
+
+const service = require('../../../src/services/manifest');
+const baseDir = path.join(__dirname, '..', '..', '..');
+
+const JSON_TEMPLATE = JSON.stringify({
+ name: '{{ branding.name }}',
+ icon: '{{ branding.icon }}'
+});
+
+describe('manifest service', () => {
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('gracefully generates manifest from default branding doc', async () => {
+
+ const branding = {
+ name: 'CHT',
+ icon: 'logo.png',
+ doc: {}
+ };
+
+ const get = sinon.stub(brandingService, 'get').resolves(branding);
+ const readFile = sinon.stub(fs, 'readFile').callsArgWith(2, null, JSON_TEMPLATE);
+ const writeFile = sinon.stub(fs, 'writeFile').callsArgWith(2, null, null);
+
+ await service.generate();
+ chai.expect(get.callCount).to.equal(1);
+ chai.expect(readFile.callCount).to.equal(1);
+ chai.expect(readFile.args[0][0]).to.equal(baseDir + '/src/templates/manifest.json');
+ chai.expect(writeFile.callCount).to.equal(1);
+ chai.expect(writeFile.args[0][0]).to.equal(baseDir + '/extracted-resources/manifest.json');
+ chai.expect(writeFile.args[0][1]).to.equal('{"name":"CHT","icon":"logo.png"}');
+ });
+
+ it('uses configured branding doc and extracts logo', async () => {
+
+ const branding = {
+ name: 'CHT',
+ icon: 'logo.png',
+ doc: {
+ resources: {
+ icon: 'logo.png',
+ },
+ _attachments: {
+ 'logo.png': {
+ data: 'xyz'
+ }
+ }
+ }
+ };
+
+ const get = sinon.stub(brandingService, 'get').resolves(branding);
+ const readFile = sinon.stub(fs, 'readFile').callsArgWith(2, null, JSON_TEMPLATE);
+ const writeFile = sinon.stub(fs, 'writeFile').callsArgWith(2, null, null);
+
+ sinon.stub(Buffer, 'from').returns('base64xyz');
+
+ await service.generate();
+ chai.expect(get.callCount).to.equal(1);
+ chai.expect(readFile.callCount).to.equal(1);
+ chai.expect(readFile.args[0][0]).to.equal(baseDir + '/src/templates/manifest.json');
+ chai.expect(writeFile.callCount).to.equal(2);
+ chai.expect(writeFile.args[0][0]).to.equal(baseDir + '/extracted-resources/manifest.json');
+ chai.expect(writeFile.args[0][1]).to.equal('{"name":"CHT","icon":"logo.png"}');
+ chai.expect(writeFile.args[1][0]).to.equal(baseDir + '/extracted-resources/img/logo.png');
+ chai.expect(writeFile.args[1][1]).to.equal('base64xyz');
+ });
+
+});
diff --git a/api/tests/mocha/services/report/smsparser.spec.js b/api/tests/mocha/services/report/smsparser.spec.js
index a1691616e0c..2e7c5039ea7 100644
--- a/api/tests/mocha/services/report/smsparser.spec.js
+++ b/api/tests/mocha/services/report/smsparser.spec.js
@@ -3,6 +3,7 @@ const chai = require('chai');
const definitions = require('../../../form-definitions');
const config = require('../../../../src/config');
const smsparser = require('../../../../src/services/report/smsparser');
+const moment = require('moment');
describe('sms parser', () => {
@@ -504,7 +505,7 @@ describe('sms parser', () => {
}
};
const data = smsparser.parse(def, doc);
- chai.expect(data).to.deep.equal({testdate: 1331510400000});
+ chai.expect(data).to.deep.equal({ testdate: moment('2012-03-12').valueOf() });
});
it('parse date field: textforms', () => {
@@ -531,7 +532,11 @@ describe('sms parser', () => {
const doc = { message: '1!YYYZ!##2012-03-12' };
const def = definitions.forms.YYYZ;
const data = smsparser.parse(def, doc);
- chai.expect(data).to.deep.equal({one:null, two:null, birthdate: 1331510400000});
+ chai.expect(data).to.deep.equal({
+ one: null,
+ two: null,
+ birthdate: moment('2012-03-12').valueOf()
+ });
});
it('parse date field yyyz 2: textforms', () => {
@@ -541,6 +546,118 @@ describe('sms parser', () => {
chai.expect(data).to.deep.equal({birthdate: 1331510400000});
});
+ it('parse bsDate field: muvuku', () => {
+ const doc = { message: '1!0000!2068-11-29' };//
+ const def = {
+ meta: {
+ code: '0000'
+ },
+ fields: {
+ testdate: {
+ type: 'bsDate',
+ labels: {
+ short: 'testdate',
+ tiny: 'TDATE'
+ }
+ }
+ }
+ };
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({testdate: moment('2012-03-12').valueOf()});
+ });
+
+ it('parse bsDate field: compact textforms', () => {
+ const doc = { message: '0000 2068-11-29' };
+ const def = {
+ meta: {
+ code: '0000'
+ },
+ fields: {
+ testdate: {
+ type: 'bsDate',
+ labels: {
+ short: 'testdate',
+ tiny: 'TDATE'
+ }
+ }
+ }
+ };
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({testdate: moment('2012-03-12').valueOf()});
+ });
+
+ it('parse bsDate field yyyt: muvuku', () => {
+ const doc = { message: '1!YYYT!12345#2068-11-29' };
+ const def = definitions.forms.YYYT;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({
+ patient_id: '12345',
+ lmp_date: moment('2012-03-12').valueOf()
+ });
+ });
+
+ it('parse bsDate field yyyt 2: textforms', () => {
+ const doc = { message: '12345 2068-11-29' };
+ const def = definitions.forms.YYYT;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({
+ patient_id: '12345',
+ lmp_date: moment('2012-03-12').valueOf()
+ });
+ });
+
+ it('invalid bsDate field yyyt 2: textforms', () => {
+ const doc = { message: '12345 2068-11-32' };
+ const def = definitions.forms.YYYT;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({patient_id: '12345', lmp_date: null});
+ });
+
+ it('parse BS date parts yyys 2: textforms', () => {
+ const doc = { message: '#ID 12345 #Y 2068 #M 11 #D 29' };
+ const def = definitions.forms.YYYS;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({
+ patient_id: 12345,
+ lmp_year: 2068, lmp_month: 11, lmp_day: 29,
+ lmp_date: moment('2012-03-12').valueOf()
+ });
+ });
+
+ it('parse BS date parts yyys 2: compact textforms', () => {
+ const doc = { message: 'YYYS 12345 2068 11 29' };
+ const def = definitions.forms.YYYS;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({
+ patient_id: '12345',
+ lmp_year: '2068', lmp_month: '11', lmp_day: '29',
+ lmp_date: moment('2012-03-12').valueOf()
+ });
+ });
+
+
+ it('BS date parts with invalid bsYear yyys: compact textforms', () => {
+ const doc = { message: 'YYYS 12345 123 11 29' };
+ const def = definitions.forms.YYYS;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({
+ patient_id: '12345',
+ lmp_year: '123', lmp_month: '11', lmp_day: '29',
+ lmp_date: null
+ });
+ });
+
+ it('BS date parts without bsMonth & bsDay yyyr: compact textforms', () => {
+ const doc = { message: 'YYYR 12345 2068' };
+ const def = definitions.forms.YYYR;
+ const data = smsparser.parse(def, doc);
+ chai.expect(data).to.deep.equal({
+ patient_id: '12345',
+ lmp_year: '2068',
+ lmp_date: moment('2011-04-14').valueOf() //2068-01-01 BS
+ });
+ });
+
it('parse boolean field: true', () => {
const doc = { message: '1!0000!1' };
const def = {
@@ -1341,7 +1458,7 @@ describe('sms parser', () => {
chai.expect(data).to.deep.equal({name: 'jane'});
});
- it('stop input valuesfrom getting translated', () => {
+ it('stop input values from getting translated', () => {
const def = {
meta: {
code: 'c_imm'
diff --git a/ddocs/medic/_attachments/manifest.json b/ddocs/medic/_attachments/manifest.json
deleted file mode 100644
index be90bec4c6a..00000000000
--- a/ddocs/medic/_attachments/manifest.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "start_url": "./",
- "display": "standalone",
- "background_color": "#323232",
- "theme_color": "#323232",
- "icons": [{
- "src": "/favicon.ico",
- "sizes": "32x32",
- "type": "image"
- }]
-}
\ No newline at end of file
diff --git a/ddocs/medic/_attachments/translations/messages-en.properties b/ddocs/medic/_attachments/translations/messages-en.properties
index 9e4cd1e260f..82313b86368 100644
--- a/ddocs/medic/_attachments/translations/messages-en.properties
+++ b/ddocs/medic/_attachments/translations/messages-en.properties
@@ -380,10 +380,14 @@ associated.contact.help = When this user creates reports they will be assigned t
autoreply = autoreply
birth_date = Birth date
branding = Branding
-branding.favicon.field = Icon
+branding.favicon.field = Small icon
+branding.favicon.field.help = This icon shows in the browser tab. Should be 32x32 pixel, .ico file.
branding.logo.field = Logo
+branding.logo.field.help = Shown on the login page and large screen devices.
branding.title.field = Title
-branding.title.field.help = This text will be displayed in the tab heading on browsers only.
+branding.title.field.help = This text will be displayed in the browser tab and as the PWA title.
+branding.icon.field = Large icon
+branding.icon.field.help = Will show for PWA installations. Should be at least 144 pixels square.
bulkdelete.complete = Bulk deletion has completed. Click complete to refresh the page.
bulkdelete.complete.action = Complete
bulkdelete.complete.title = Bulk delete complete
@@ -943,7 +947,7 @@ permission.description.can_view_outgoing_messages = Allowed to view the Outgoing
permission.description.can_view_reports = Allowed to view Reports.
permission.description.can_view_reports_tab = Display the Reports tab in the app. If not set it a menu item will be shown in the app menu instead.
permission.description.can_view_tasks = Allowed to view Tasks.
-permission.description.can_view_tasks_group = Allowed to view household tasks page.
+permission.description.can_view_tasks_group = Allowed to view household tasks page.
permission.description.can_view_tasks_tab = Display the Tasks tab in the app. If not set it a menu item will be shown in the app menu instead.
permission.description.can_view_unallocated_data_records = Allowed to see reports that have no assigned contact.
permission.description.can_view_users = Allowed to get a list of all configured users.
diff --git a/shared-libs/transitions/src/transitions/registration.js b/shared-libs/transitions/src/transitions/registration.js
index 263f6d7e4aa..19aa989da52 100644
--- a/shared-libs/transitions/src/transitions/registration.js
+++ b/shared-libs/transitions/src/transitions/registration.js
@@ -139,21 +139,43 @@ const getWeeksSinceLMP = doc => {
}
};
+/*
+* Given a doc, try to get the exact LMP date
+*/
+const getLMPDate = doc => {
+ const props = ['lmp_date', 'date_lmp'];
+ for (const prop of props) {
+ const lmp = doc.fields && doc.fields[prop] && parseInt(doc.fields[prop]);
+ if (!isNaN(lmp)) {//milliseconds since epoch
+ return lmp;
+ }
+ }
+};
+
const setExpectedBirthDate = doc => {
- const lmp = getWeeksSinceLMP(doc);
- const start = moment(doc.reported_date).startOf('day');
- if (lmp === 0) {
- // means baby was already born, chw just wants a registration.
+ let start;
+ const lmpDate = getLMPDate(doc);
+ if (lmpDate) {
+ start = moment(lmpDate);
+ } else {
+ const lmp = getWeeksSinceLMP(doc);
+ if (lmp) {
+ start = moment(doc.reported_date).startOf('day');
+ start.subtract(lmp, 'weeks');
+ }
+ }
+
+ if (!start) {// means baby was already born, chw just wants a registration.
doc.lmp_date = null;
doc.expected_date = null;
- } else {
- start.subtract(lmp, 'weeks');
- doc.lmp_date = start.toISOString();
- doc.expected_date = start
- .clone()
- .add(40, 'weeks')
- .toISOString();
+ return;
}
+
+ doc.lmp_date = start.toISOString();
+ doc.expected_date = start
+ .clone()
+ .add(40, 'weeks')
+ .toISOString();
};
const setBirthDate = doc => {
diff --git a/shared-libs/transitions/test/unit/pregnancy_registration.js b/shared-libs/transitions/test/unit/pregnancy_registration.js
index 9ef756fe315..2e99b7b9c77 100644
--- a/shared-libs/transitions/test/unit/pregnancy_registration.js
+++ b/shared-libs/transitions/test/unit/pregnancy_registration.js
@@ -17,7 +17,7 @@ const getMessage = doc => {
return doc.tasks[0].messages[0].message;
};
-describe('pregnancy registration', () => {
+describe('pregnancy registration with weeks since LMP', () => {
afterEach(() => sinon.restore());
beforeEach(() => {
@@ -108,7 +108,7 @@ describe('pregnancy registration', () => {
});
it('filter fails with no clinic phone and private form', () => {
- const doc = { form: 'p', type: 'data_record'};
+ const doc = { form: 'p', type: 'data_record' };
sinon.stub(utils, 'getForm').returns({ public_form: false });
assert(!transition.filter(doc));
});
@@ -401,3 +401,210 @@ describe('pregnancy registration', () => {
});
});
+
+describe('pregnancy registration with exact LMP date', () => {
+ const today = moment('2000-01-01');
+ const eightWeeksAgo = today.clone().subtract(8, 'weeks').startOf('day');
+ afterEach(() => sinon.restore());
+
+ beforeEach(() => {
+ transition.setExpectedBirthDate = transition.__get__('setExpectedBirthDate');
+ sinon.stub(config, 'get').returns([{
+ form: 'l',
+ type: 'pregnancy',
+ events: [
+ {
+ name: 'on_create',
+ trigger: 'add_patient',
+ params: '',
+ bool_expr: ''
+ },
+ {
+ name: 'on_create',
+ trigger: 'add_expected_date',
+ params: '',
+ bool_expr: 'typeof doc.getid === "undefined"'
+ }
+ ],
+ validations: {
+ join_responses: true,
+ list: [
+ {
+ property: 'lmp_date',
+ rule: 'isAfter("-40 weeks")',
+ message: [{
+ content: 'Date should be later than 40 weeks ago.',
+ locale: 'en'
+ }]
+ },
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("8 weeks")',
+ message: [{
+ content: 'Date should be older than 8 weeks ago.',
+ locale: 'en'
+ }]
+ },
+ {
+ property: 'patient_name',
+ rule: 'lenMin(1) && lenMax(100)',
+ message: [{
+ content: 'Invalid patient name.',
+ locale: 'en'
+ }]
+ }
+ ]
+ }
+ }, {
+ // Pregnancy for existing patient
+ form: 'ep',
+ type: 'pregnancy',
+ events: [
+ {
+ name: 'on_create',
+ trigger: 'add_expected_date',
+ params: '',
+ bool_expr: 'typeof doc.getid === "undefined"'
+ }
+ ],
+ validations: {
+ join_responses: true,
+ list: [
+ {
+ property: 'patient_id',
+ rule: 'len(5)',
+ message: [{
+ content: 'Invalid patient Id.',
+ locale: 'en'
+ }]
+ }
+ ]
+ }
+ }]);
+ });
+
+ it('setExpectedBirthDate sets lmp_date and expected_date correctly for 8 weeks ago', () => {
+ const doc = {
+ fields:
+ {
+ lmp_date: eightWeeksAgo.valueOf()
+ },
+ reported_date: today.valueOf(),
+ type: 'data_record'
+ };
+
+ transition.setExpectedBirthDate(doc);
+ assert(doc.lmp_date);
+ assert.equal(doc.lmp_date, eightWeeksAgo.clone().toISOString());
+ assert.equal(doc.expected_date, eightWeeksAgo.clone().add(40, 'weeks').toISOString());
+ });
+
+ it('valid adds lmp_date and patient_id', () => {
+ sinon.stub(utils, 'getContactUuid').resolves('uuid');
+ sinon.stub(transitionUtils, 'getUniqueId').resolves(12345);
+
+ const doc = {
+ form: 'l',
+ type: 'data_record',
+ reported_date: today.valueOf(),
+ fields: {
+ lmp_date: eightWeeksAgo.valueOf(),
+ patient_name: 'abc'
+ }
+ };
+
+ return transition.onMatch({ doc: doc }).then(function (changed) {
+ assert.equal(changed, true);
+ assert.equal(doc.lmp_date, eightWeeksAgo.toISOString());
+ assert(doc.patient_id);
+ assert.equal(doc.tasks, undefined);
+ });
+ });
+
+ it('valid name invalid LMP date', () => {
+ const doc = {
+ form: 'l',
+ from: '+1234',
+ type: 'data_record',
+ fields: {
+ patient_name: 'hi',
+ lmp_date: 'x'
+ },
+ reported_date: today.valueOf()
+ };
+
+ return transition.onMatch({ doc: doc }).then(function (changed) {
+ assert.equal(changed, true);
+ assert.equal(doc.patient_id, undefined);
+ assert.equal(doc.lmp_date, null);
+ assert.equal(getMessage(doc),
+ 'Date should be later than 40 weeks ago. ' +
+ ' Date should be older than 8 weeks ago.');
+ });
+ });
+
+ it('invalid name invalid LMP Date logic', () => {
+ const doc = {
+ form: 'l',
+ from: '+123',
+ type: 'data_record',
+ fields: {
+ patient_name: '',
+ lmp_date: null
+ },
+ reported_date: today.valueOf()
+ };
+
+ return transition.onMatch({ doc: doc }).then(function (changed) {
+ assert.equal(changed, true);
+ assert.equal(doc.patient_id, undefined);
+ assert.equal(doc.lmp_date, null);
+ assert.equal(getMessage(doc),
+ 'Invalid patient name. ' +
+ ' Date should be later than 40 weeks ago. ' +
+ ' Date should be older than 8 weeks ago.');
+ });
+ });
+
+ it('LMP date less than 8 weeks ago should fail', () => {
+ sinon.stub(utils, 'getContactUuid').resolves('uuid');
+ sinon.stub(transitionUtils, 'getUniqueId').resolves(12345);
+
+ const doc = {
+ form: 'l',
+ type: 'data_record',
+ fields: {
+ patient_name: 'abc',
+ lmp_date: eightWeeksAgo.clone().add({day: 1}).valueOf()
+ },
+ reported_date: today.valueOf()
+ };
+
+ return transition.onMatch({ doc: doc }).then(function (changed) {
+ assert.equal(changed, true);
+ assert.equal(doc.patient_id, undefined);
+ assert.equal(getMessage(doc), 'Date should be older than 8 weeks ago.');
+ });
+ });
+
+ it('LMP date more than 40 weeks ago should fail', () => {
+ sinon.stub(utils, 'getContactUuid').resolves('uuid');
+ sinon.stub(transitionUtils, 'getUniqueId').resolves(12345);
+
+ const doc = {
+ form: 'l',
+ type: 'data_record',
+ fields: {
+ lmp_date: today.clone().subtract({weeks: 40, day: 1}).valueOf(),
+ patient_name: 'abc'
+ },
+ reported_date: today.valueOf()
+ };
+ return transition.onMatch({ doc: doc }).then(function (changed) {
+ assert.equal(changed, true);
+ assert.equal(doc.patient_id, undefined);
+ assert.equal(getMessage(doc), 'Date should be later than 40 weeks ago.');
+ });
+ });
+
+});
diff --git a/shared-libs/validation/src/validation.js b/shared-libs/validation/src/validation.js
index 5eb5c503122..248b10a9751 100644
--- a/shared-libs/validation/src/validation.js
+++ b/shared-libs/validation/src/validation.js
@@ -111,6 +111,38 @@ const getRules = (validations) => {
return rules;
};
+const compareDate = (doc, validation, checkAfter = false) => {
+ const fields = [...validation.funcArgs];
+ try {
+ const duration = _parseDuration(fields.pop());
+ if (!duration.isValid()) {
+ logger.error('date constraint validation: the duration is invalid');
+ return Promise.resolve(false);
+ }
+ const testDate = moment(doc[validation.field]);
+ const controlDate = checkAfter ?
+ moment(doc.reported_date).add(duration) :
+ moment(doc.reported_date).subtract(duration);
+ if (!testDate.isValid() || !controlDate.isValid()) {
+ logger.error('date constraint validation: the date is invalid');
+ return Promise.resolve(false);
+ }
+
+ if (checkAfter && testDate.isSameOrAfter(controlDate, 'days')) {
+ return Promise.resolve(true);
+ }
+ if (!checkAfter && testDate.isSameOrBefore(controlDate, 'days')) {
+ return Promise.resolve(true);
+ }
+
+ logger.error('date constraint validation failed');
+ return Promise.resolve(false);
+ } catch (err) {
+ logger.error('date constraint validation: the date or duration is invalid: %o', err);
+ return Promise.resolve(false);
+ }
+};
+
module.exports = {
init: (options) => {
db = options.db;
@@ -178,6 +210,10 @@ module.exports = {
logger.error('isISOWeek validation failed: the number of week is greater than the maximum');
return Promise.resolve(false);
},
+
+ isAfter: (doc, validation) => compareDate(doc, validation, true),
+
+ isBefore: (doc, validation) => compareDate(doc, validation, false),
},
/**
* Validation settings may consist of Pupil.js rules and custom rules.
diff --git a/shared-libs/validation/test/validations.js b/shared-libs/validation/test/validations.js
index 335f00328e6..de8cb5806c5 100644
--- a/shared-libs/validation/test/validations.js
+++ b/shared-libs/validation/test/validations.js
@@ -522,4 +522,203 @@ describe('validations', () => {
]);
});
});
+
+ it('pass isBefore validation on doc when test date is 1 day before control date', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("4 weeks")',
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 4, days: 1}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.equal(errors.length, 0);
+ });
+ });
+
+ it('pass isBefore validation on doc when lmp_date is a field', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("4 weeks")',
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ fields: {
+ lmp_date: moment().subtract({weeks: 4, days: 1}).valueOf()
+ },
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.equal(errors.length, 0);
+ });
+ });
+
+ it('pass isBefore validation on doc when the test and control dates are same', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("4 weeks")',
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 4}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.equal(errors.length, 0);
+ });
+ });
+
+ it('fail isBefore validation when test date is 1 day later than control date', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("4 weeks")',
+ message: [
+ {
+ content: 'Invalid date.',
+ locale: 'en',
+ },
+ ],
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 3, days: 6}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.deepEqual(errors, [
+ {
+ code: 'invalid_lmp_date_isBefore',
+ message: 'Invalid date.',
+ },
+ ]);
+ });
+ });
+
+ it('fail isBefore validation when test date is not a valid date', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("4 weeks")',
+ message: [
+ {
+ content: 'Invalid date.',
+ locale: 'en',
+ },
+ ],
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: 'x',
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.deepEqual(errors, [
+ {
+ code: 'invalid_lmp_date_isBefore',
+ message: 'Invalid date.',
+ },
+ ]);
+ });
+ });
+
+ it('fail isBefore validation when duration is not a number', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isBefore("x")',
+ message: [
+ {
+ content: 'Invalid date.',
+ locale: 'en',
+ },
+ ],
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 3, days: 6}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.deepEqual(errors, [
+ {
+ code: 'invalid_lmp_date_isBefore',
+ message: 'Invalid date.',
+ },
+ ]);
+ });
+ });
+
+ it('pass isAfter validation on doc when test date is 1 day after control date', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isAfter("-40 weeks")',
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 39, days: 6}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.equal(errors.length, 0);
+ });
+ });
+
+ it('pass isAfter validation on doc when the test and control dates are same', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isAfter("-40 weeks")',
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 40}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.equal(errors.length, 0);
+ });
+ });
+
+ it('fail isAfter validation when test date is 1 day before control date', () => {
+ const validations = [
+ {
+ property: 'lmp_date',
+ rule: 'isAfter("-40 weeks")',
+ message: [
+ {
+ content: 'Invalid date.',
+ locale: 'en',
+ },
+ ],
+ },
+ ];
+ const doc = {
+ _id: 'same',
+ lmp_date: moment().subtract({weeks: 40, days: 1}).valueOf(),
+ reported_date: moment().valueOf()
+ };
+ return validation.validate(doc, validations).then(errors => {
+ assert.deepEqual(errors, [
+ {
+ code: 'invalid_lmp_date_isAfter',
+ message: 'Invalid date.',
+ },
+ ]);
+ });
+ });
});
diff --git a/tests/e2e/forms/repeat-form.wdio-spec.js b/tests/e2e/forms/repeat-form.wdio-spec.js
index d4bfb2ed028..31b447d947b 100644
--- a/tests/e2e/forms/repeat-form.wdio-spec.js
+++ b/tests/e2e/forms/repeat-form.wdio-spec.js
@@ -9,6 +9,7 @@ const reportsPage = require('../../page-objects/reports/reports.wdio.page');
const countFormDocument = readFormDocument('repeat-translation-count');
const buttonFormDocument = readFormDocument('repeat-translation-button');
+const selectFormDocument = readFormDocument('repeat-translation-select');
const userContactDoc = {
_id: constants.USER_CONTACT_ID,
name: 'Jack',
@@ -25,7 +26,7 @@ const userContactDoc = {
describe('RepeatForm', () => {
before(async () => {
- await utils.seedTestData(userContactDoc, [countFormDocument, buttonFormDocument]);
+ await utils.seedTestData(userContactDoc, [countFormDocument, buttonFormDocument, selectFormDocument]);
});
afterEach(async () => {
@@ -133,6 +134,38 @@ describe('RepeatForm', () => {
}
});
+ describe('Repeat form with select', () => {
+ it('should display the initial form and its repeated content in the default language', async () => {
+ const swUserName = 'Jina la mtumizi';
+ await loginPage.changeLanguage('sw', swUserName);
+ await login();
+ await openRepeatForm(selectFormDocument.internalId);
+
+ const { input: washingtonInput, label: washingtonLabel } = await getField('selected_state', 'washington');
+ expect(await washingtonLabel.getText()).to.equal('Washington');
+
+ await washingtonInput.click();
+ const { input: kingInput, label: kingLabel } = await getField('selected_county', 'king');
+ expect(await kingLabel.getText()).to.equal('King');
+
+ await kingInput.click();
+ const { label: seattleLabel } = await getField('selected_city', 'seattle');
+ const { label: redmondLabel } = await getField('selected_city', 'redmond');
+ expect(await seattleLabel.getText()).to.equal('Seattle');
+ expect(await redmondLabel.getText()).to.equal('Redmond');
+ });
+
+ async function getField(fieldName, fieldValue) {
+ const fieldInputPath = `#report-form input[name="/cascading_select/${fieldName}"][value="${fieldValue}"]`;
+ const fieldLabelPath = `${fieldInputPath} ~ .option-label.active`;
+
+ return {
+ input: await $(fieldInputPath),
+ label: await $(fieldLabelPath),
+ };
+ }
+ });
+
async function assertLabels({ selector, count, labelText }) {
const labels = await $$(selector);
expect(labels.length).to.equal(count);
diff --git a/tests/e2e/manifest.wdio-spec.js b/tests/e2e/manifest.wdio-spec.js
new file mode 100644
index 00000000000..e74a2fc1d7c
--- /dev/null
+++ b/tests/e2e/manifest.wdio-spec.js
@@ -0,0 +1,111 @@
+const { expect } = require('chai');
+const { promisify } = require('util');
+const { readFile } = require('fs');
+const path = require('path');
+
+const utils = require('../utils');
+
+const SW_SUCCESSFUL_REGEX = /Service worker generated successfully/;
+
+const DEFAULT_MANIFEST = {
+ start_url: './',
+ name: 'CHT',
+ display: 'standalone',
+ background_color: '#323232',
+ theme_color: '#323232',
+ icons: [
+ { src: '/img/icon.png', sizes: 'any', purpose: 'any' },
+ { src: '/favicon.ico', sizes:'32x32', type: 'image' }
+ ]
+};
+
+const addAttachment = async (doc, label, path, name, type) => {
+ doc.resources[label] = name;
+ const content = await promisify(readFile)(path);
+ doc._attachments[name] = {
+ data: new Buffer.from(content).toString('base64'),
+ content_type: type
+ };
+};
+
+const updateBranding = async (doc) => {
+ const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFUL_REGEX);
+ if (!doc) {
+ try {
+ await utils.deleteDoc('branding');
+ } catch(err) {
+ if (err.statusCode === 404) {
+ return; // already not there - success!
+ }
+ throw err;
+ }
+ } else {
+ await utils.saveDoc(doc);
+ }
+ await waitForLogs.promise;
+};
+
+const assertIconsExist = async (manifest) => {
+ for (const icon of manifest.icons) {
+ console.log('Asserting that icon src exists: ' + icon.src);
+ await utils.request(icon.src); // will throw if 404
+ }
+};
+
+const getBrandingDoc = async () => {
+ try {
+ await utils.getDoc('branding');
+ } catch (e) {
+ if (e.statusCode === 404) {
+ return { _id: 'branding' };
+ }
+ throw e;
+ }
+};
+
+describe('manifest.json', () => {
+
+ it('works without branding doc', async () => {
+ await updateBranding();
+ const response = await utils.request('/manifest.json');
+ expect(response).to.deep.equal(DEFAULT_MANIFEST);
+ await assertIconsExist(response);
+ });
+
+ it('works with custom branding doc', async () => {
+ const branding = await getBrandingDoc();
+ branding.title = 'PWA4LIFE';
+ branding.resources = {};
+ branding._attachments = {};
+ const logoName = 'icon-chw-selected.svg';
+ const faviconName = 'favicon.ico';
+ const logoPath = path.join(__dirname, '../../webapp/src/img', logoName);
+ const faviconPath = path.join(__dirname, '../../api/src/resources/ico', faviconName);
+ await addAttachment(branding, 'logo', logoPath, logoName, 'image/svg+xml');
+ await addAttachment(branding, 'favicon', faviconPath, faviconName, 'image/x-icon');
+ await updateBranding(branding);
+ const response = await utils.request('/manifest.json');
+ const expected = {
+ start_url: './',
+ name: 'PWA4LIFE',
+ display: 'standalone',
+ background_color: '#323232',
+ theme_color: '#323232',
+ icons: [
+ {
+ src: '/img/icon.png',
+ sizes: 'any',
+ purpose: 'any'
+ },
+ {
+ src: '/favicon.ico',
+ sizes: '32x32',
+ type: 'image'
+ }
+ ]
+ };
+ expect(response).to.deep.equal(expected);
+ await assertIconsExist(response);
+ });
+
+});
diff --git a/tests/e2e/sentinel/transitions/registration.spec.js b/tests/e2e/sentinel/transitions/registration.spec.js
index 88cc465446f..1d4c49f3b93 100644
--- a/tests/e2e/sentinel/transitions/registration.spec.js
+++ b/tests/e2e/sentinel/transitions/registration.spec.js
@@ -1080,7 +1080,23 @@ describe('registration', () => {
}
};
- const docs = [withWeeksSinceLMP, withLMP];
+ const withLMPDate = {
+ _id: uuid(),
+ type: 'data_record',
+ form: 'FORM',
+ from: '+444999',
+ fields: {
+ patient_id: 'patient',
+ lmp_date: moment().utc('false').subtract(4, 'weeks').startOf('day').valueOf()
+ },
+ reported_date: moment().valueOf(),
+ contact: {
+ _id: 'person',
+ parent: { _id: 'clinic', parent: { _id: 'health_center', parent: { _id: 'district_hospital' } } }
+ }
+ };
+
+ const docs = [withWeeksSinceLMP, withLMP, withLMPDate];
const docIds = getIds(docs);
return utils
@@ -1106,6 +1122,12 @@ describe('registration', () => {
.to.equal(moment().utc(false).startOf('day').subtract(4, 'weeks').toISOString());
chai.expect(updated[1].expected_date)
.to.equal(moment().utc(false).startOf('day').add(36, 'weeks').toISOString());
+
+ chai.expect(updated[2].lmp_date).to.be.ok;
+ chai.expect(updated[2].lmp_date)
+ .to.equal(moment().utc(false).startOf('day').subtract(4, 'weeks').toISOString());
+ chai.expect(updated[2].expected_date)
+ .to.equal(moment().utc(false).startOf('day').add(36, 'weeks').toISOString());
});
});
diff --git a/tests/e2e/service-worker.wdio-spec.js b/tests/e2e/service-worker.wdio-spec.js
index 9e7f5bd39a2..e47a46efac3 100644
--- a/tests/e2e/service-worker.wdio-spec.js
+++ b/tests/e2e/service-worker.wdio-spec.js
@@ -78,7 +78,7 @@ const login = async () => {
await commonPage.waitForPageLoaded();
};
-const SW_SUCCESSFULL_REGEX = /Service worker generated successfully/;
+const SW_SUCCESSFUL_REGEX = /Service worker generated successfully/;
describe('Service worker cache', () => {
before(async () => {
@@ -93,13 +93,14 @@ describe('Service worker cache', () => {
const cacheDetails = await getCachedRequests();
expect(cacheDetails.name.startsWith('sw-precache-v3-cache-')).to.be.true;
- expect(cacheDetails.urls).to.deep.eq([
+ expect(cacheDetails.urls).to.have.members([
'/',
'/audio/alert.mp3',
'/fontawesome-webfont.woff2',
'/fonts/NotoSans-Bold.ttf',
'/fonts/NotoSans-Regular.ttf',
'/fonts/enketo-icons-v2.woff',
+ '/img/icon.png',
'/img/icon-chw-selected.svg',
'/img/icon-chw.svg',
'/img/icon-nurse-selected.svg',
@@ -123,7 +124,7 @@ describe('Service worker cache', () => {
});
it('branding updates trigger login page refresh', async () => {
- const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFULL_REGEX);
+ const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFUL_REGEX);
const branding = await utils.getDoc('branding');
branding.title = 'Not Medic';
await utils.saveDoc(branding);
@@ -138,7 +139,7 @@ describe('Service worker cache', () => {
});
it('login page translation updates trigger login page refresh', async () => {
- const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFULL_REGEX);
+ const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFUL_REGEX);
await utils.addTranslations('en', {
'User Name': 'NotUsername',
'login': 'NotLogin',
@@ -156,7 +157,7 @@ describe('Service worker cache', () => {
});
it('adding new languages triggers login page refresh', async () => {
- const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFULL_REGEX);
+ const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFUL_REGEX);
await utils.addTranslations('ro', {
'User Name': 'Utilizator',
'Password': 'Parola',
@@ -181,7 +182,7 @@ describe('Service worker cache', () => {
const cacheDetails = await getCachedRequests(true);
- const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFULL_REGEX);
+ const waitForLogs = utils.waitForLogs(utils.apiLogFile, SW_SUCCESSFUL_REGEX);
await utils.addTranslations('en', {
'ran': 'dom',
'some': 'thing',
diff --git a/tests/e2e/translations/nepali-dates-and-numbers.wdio-spec.js b/tests/e2e/translations/nepali-dates-and-numbers.wdio-spec.js
index a5ab125b9bb..7edc4b3513f 100644
--- a/tests/e2e/translations/nepali-dates-and-numbers.wdio-spec.js
+++ b/tests/e2e/translations/nepali-dates-and-numbers.wdio-spec.js
@@ -9,6 +9,7 @@ const commonPage = require('../../page-objects/common/common.wdio.page');
const reportsPage = require('../../page-objects/reports/reports.wdio.page');
const contactsPage = require('../../page-objects/contacts/contacts.wdio.page');
const chtConfUtils = require('../../cht-conf-utils');
+const gatewayApiUtils = require('../../gateway-api.utils');
const NEPALI_LOCALE_CODE = 'ne';
@@ -37,13 +38,74 @@ const setExistentReportDates = async (dates) => {
return utils.saveDocs(reports);
};
+const momentToBikParts = (mDate) => {
+ return bikramSambat.toBik(moment(mDate).format('YYYY-MM-DD'));
+};
+
+const momentToBikYMD = (mDate) => {
+ const bsDate = momentToBikParts(mDate);
+ return `${bsDate.year}-${bsDate.month}-${bsDate.day}`;
+};
+
+const formIdBS = 'B';
+const formIdBSParts = 'C';
+const nineWeeksAgo = moment().subtract({ weeks: 9 }).startOf('day');
+const tenWeeksAgo = moment().subtract({ weeks: 10 }).startOf('day');
+
+const forms = {
+ B: {
+ meta: {
+ code: formIdBS,
+ label: 'LMP with BS Date',
+ },
+ fields: {
+ name: {
+ type: 'string',
+ labels: { short: 'Name' }
+ },
+ lmp_date: {
+ type: 'bsDate',
+ labels: { short: 'LMP Date' }
+ }
+ }
+ },
+ C: {
+ meta: {
+ code: formIdBSParts,
+ label: 'LMP with BS date parts'
+ },
+ fields: {
+ name: { type: 'string', labels: { short: 'Name' } },
+ lmp_year: { type: 'bsYear' },
+ lmp_month: { type: 'bsMonth' },
+ lmp_day: { type: 'bsDay' },
+ lmp_date: { type: 'bsAggreDate', labels: { short: 'LMP Date' } }
+ }
+ }
+};
+
+const registrations = [
+ {
+ form: formIdBS,
+ events: [{ name: 'on_create', trigger: 'add_expected_date' }]
+ },
+ {
+ form: formIdBSParts,
+ events: [{ name: 'on_create', trigger: 'add_expected_date' }]
+ }
+];
+
+const transitions = {
+ registration: true
+};
+
describe('Bikram Sambat date display', () => {
before(async () => {
await chtConfUtils.initializeConfigDir();
const contactSummaryFile = path.join(__dirname, 'bikram-sambat-config', 'contact-summary.templated.js');
const { contactSummary } = await chtConfUtils.compileNoolsConfig({ contactSummary: contactSummaryFile });
- await utils.updateSettings({ contact_summary: contactSummary }, true);
+ await utils.updateSettings({ contact_summary: contactSummary, forms, registrations, transitions }, true);
const formsPath = path.join(__dirname, 'bikram-sambat-config', 'forms');
await chtConfUtils.compileAndUploadAppForms(formsPath);
@@ -166,4 +228,48 @@ describe('Bikram Sambat date display', () => {
expect(await contactsPage.getContactSummaryField('field')).to.equal('text ०१२३४५६७८९');
expect(await contactsPage.getContactSummaryField('another')).to.equal('other text 0123456789');
});
+
+ it('SMS report shows bsDate type as date field correctly', async () => {
+ await setLanguage(NEPALI_LOCALE_CODE);
+ moment.locale(NEPALI_LOCALE_CODE);
+
+ await gatewayApiUtils.api.postMessage({
+ id: 'lmp-id-bs',
+ from: '+9779876543210',
+ content: `${formIdBS} Shrestha ${momentToBikYMD(tenWeeksAgo)}`
+ });
+
+ await commonPage.goToPeople();
+ await commonPage.goToReports();
+ const firstReport = await reportsPage.firstReport();
+ await firstReport.click();
+
+ const dateFormat = bikramSambat.toBik_text(tenWeeksAgo);
+ const relativeFormat = moment(tenWeeksAgo.toDate()).fromNow();
+ const lmpDateValue = await reportsPage.getReportDetailFieldValueByLabel('LMP Date');
+ expect(lmpDateValue).to.equal(`${dateFormat} (${relativeFormat})`);
+ });
+
+ it('SMS report shows bsAggreDate type as date field correctly', async () => {
+ await setLanguage(NEPALI_LOCALE_CODE);
+ moment.locale(NEPALI_LOCALE_CODE);
+ const lmpBSParts = momentToBikParts(nineWeeksAgo);
+
+ await gatewayApiUtils.api.postMessage({
+ id: 'lmp-id-bs-parts',
+ from: '+9779876543210',
+ content: `${formIdBSParts} Shrestha ` +
+ `${lmpBSParts.year} ${lmpBSParts.month} ${lmpBSParts.day}`
+ });
+
+ await commonPage.goToPeople();
+ await commonPage.goToReports();
+ const firstReport = await reportsPage.firstReport();
+ await firstReport.click();
+
+ const dateFormat = bikramSambat.toBik_text(nineWeeksAgo);
+ const relativeFormat = moment(nineWeeksAgo.toDate()).fromNow();
+ const lmpDateValue = await reportsPage.getReportDetailFieldValueByLabel('LMP Date');
+ expect(lmpDateValue).to.equal(`${dateFormat} (${relativeFormat})`);
+ });
});
diff --git a/tests/forms/repeat-translation-select.xml b/tests/forms/repeat-translation-select.xml
new file mode 100644
index 00000000000..88ff7b367fb
--- /dev/null
+++ b/tests/forms/repeat-translation-select.xml
@@ -0,0 +1,166 @@
+
+
+
+ Cascading select example
+
+
+
+
+ Brownsville
+
+
+ Harlingen
+
+
+ Seattle
+
+
+ Redmond
+
+
+ Tacoma
+
+
+ Puyallup
+
+
+ King
+
+
+ Pierce
+
+
+ Brewster
+
+
+ Cameron
+
+
+ Texas
+
+
+ Washington
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ static_instance-states-0
+ texas
+
+ -
+ static_instance-states-1
+ washington
+
+
+
+
+
+ -
+ static_instance-counties-0
+ washington
+ king
+
+ -
+ static_instance-counties-1
+ washington
+ pierce
+
+ -
+ static_instance-counties-2
+ texas
+ brewster
+
+ -
+ static_instance-counties-3
+ texas
+ cameron
+
+
+
+
+
+ -
+ static_instance-cities-0
+ brownsville
+ cameron
+ texas
+
+ -
+ static_instance-cities-1
+ harlingen
+ cameron
+ texas
+
+ -
+ static_instance-cities-2
+ seattle
+ king
+ washington
+
+ -
+ static_instance-cities-3
+ redmond
+ king
+ washington
+
+ -
+ static_instance-cities-4
+ tacoma
+ pierce
+ washington
+
+ -
+ static_instance-cities-5
+ puyallup
+ pierce
+ washington
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+ texas
+
+ -
+
+ washington
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/page-objects/common/common.wdio.page.js b/tests/page-objects/common/common.wdio.page.js
index 87cdfe69d8a..020db9373c1 100644
--- a/tests/page-objects/common/common.wdio.page.js
+++ b/tests/page-objects/common/common.wdio.page.js
@@ -64,6 +64,7 @@ const navigateToLogoutModal = async () => {
const logout = async () => {
await navigateToLogoutModal();
await (await modal.confirm()).click();
+ await browser.pause(100); // wait for login page js to execute
};
const getLogoutMessage = async () => {
diff --git a/tests/page-objects/reports/reports.wdio.page.js b/tests/page-objects/reports/reports.wdio.page.js
index 21b52fb9e3d..93c0076b99e 100644
--- a/tests/page-objects/reports/reports.wdio.page.js
+++ b/tests/page-objects/reports/reports.wdio.page.js
@@ -18,6 +18,9 @@ const reportRowSelector = `${reportListID} .content-row`;
const reportRow = () => $(reportRowSelector);
const reportRowsText = () => $$(`${reportRowSelector} .heading h4 span`);
+const reportDetailsFieldsSelector = `${reportBodyDetailsSelector} > ul > li`;
+const reportDetailsFields = () => $$(reportDetailsFieldsSelector);
+
const submitReportButton = () => $('.action-container .general-actions:not(.ng-hide) .fa-plus');
const deleteAllButton = () => $('.action-container .detail-actions .delete-all');
const dateFilter = () => $('#date-filter');
@@ -210,6 +213,17 @@ const getCurrentReportId = async () => {
return currentUrl.slice(reportBaseUrl.length);
};
+const getReportDetailFieldValueByLabel = async (label) => {
+ await reportBodyDetails().waitForDisplayed();
+ for (const field of await reportDetailsFields()) {
+ const fieldLabel = await (await field.$('label span')).getText();
+ if (fieldLabel === label) {
+ return await (await field.$('p span')).getText();
+ }
+ }
+};
+
+
module.exports = {
getCurrentReportId,
reportList,
@@ -248,4 +262,5 @@ module.exports = {
allReports,
reportsByUUID,
getAllReportsText,
+ getReportDetailFieldValueByLabel
};
diff --git a/tests/utils.js b/tests/utils.js
index 978084208f2..d11bbd9f4fa 100644
--- a/tests/utils.js
+++ b/tests/utils.js
@@ -739,12 +739,15 @@ module.exports = {
deleteDocs: ids => {
return module.exports.getDocs(ids).then(docs => {
- docs.forEach(doc => doc._deleted = true);
- return module.exports.requestOnTestDb({
- path: '/_bulk_docs',
- method: 'POST',
- body: { docs },
- });
+ docs = docs.filter(doc => !!doc);
+ if (docs.length) {
+ docs.forEach(doc => doc._deleted = true);
+ return module.exports.requestOnTestDb({
+ path: '/_bulk_docs',
+ method: 'POST',
+ body: { docs },
+ });
+ }
});
},
diff --git a/webapp/src/img/icon.png b/webapp/src/img/icon.png
new file mode 100644
index 00000000000..167d64d13d5
Binary files /dev/null and b/webapp/src/img/icon.png differ
diff --git a/webapp/src/ts/actions/analytics.ts b/webapp/src/ts/actions/analytics.ts
index e7761ae7c17..fc763c71a5f 100644
--- a/webapp/src/ts/actions/analytics.ts
+++ b/webapp/src/ts/actions/analytics.ts
@@ -1,6 +1,6 @@
import { Store } from '@ngrx/store';
-import { createSingleValueAction } from '@mm-actions/./actionUtils';
+import { createSingleValueAction } from '@mm-actions/actionUtils';
export const Actions = {
setAnalyticsModules: createSingleValueAction('SET_ANALYTICS_MODULES', 'analyticsModules'),
diff --git a/webapp/src/ts/actions/contacts.ts b/webapp/src/ts/actions/contacts.ts
index c49c9b2f923..36b715e26e4 100644
--- a/webapp/src/ts/actions/contacts.ts
+++ b/webapp/src/ts/actions/contacts.ts
@@ -1,5 +1,6 @@
import { Store, createAction } from '@ngrx/store';
-import { createSingleValueAction, createMultiValueAction } from './actionUtils';
+
+import { createSingleValueAction, createMultiValueAction } from '@mm-actions/actionUtils';
export const Actions = {
updateContactsList: createSingleValueAction('UPDATE_CONTACTS_LIST', 'contacts'),
diff --git a/webapp/src/ts/actions/global.ts b/webapp/src/ts/actions/global.ts
index f6f00ba50ec..ea18047061d 100644
--- a/webapp/src/ts/actions/global.ts
+++ b/webapp/src/ts/actions/global.ts
@@ -1,6 +1,6 @@
import { createAction, Store } from '@ngrx/store';
-import { createSingleValueAction, createMultiValueAction } from './actionUtils';
+import { createSingleValueAction, createMultiValueAction } from '@mm-actions/actionUtils';
export const Actions = {
updateReplicationStatus: createSingleValueAction('UPDATE_REPLICATION_STATUS', 'replicationStatus'),
diff --git a/webapp/src/ts/actions/messages.ts b/webapp/src/ts/actions/messages.ts
index 0de49c5e9c8..91d1190ed9f 100644
--- a/webapp/src/ts/actions/messages.ts
+++ b/webapp/src/ts/actions/messages.ts
@@ -1,6 +1,6 @@
import { Store, createAction } from '@ngrx/store';
-import { createSingleValueAction } from './actionUtils';
+import { createSingleValueAction } from '@mm-actions/actionUtils';
import { GlobalActions } from '@mm-actions/global';
export const Actions = {
diff --git a/webapp/src/ts/actions/reports.ts b/webapp/src/ts/actions/reports.ts
index b0a7c677d96..84fbc9f0507 100644
--- a/webapp/src/ts/actions/reports.ts
+++ b/webapp/src/ts/actions/reports.ts
@@ -1,6 +1,6 @@
import { Store, createAction } from '@ngrx/store';
-import { createMultiValueAction, createSingleValueAction } from './actionUtils';
+import { createMultiValueAction, createSingleValueAction } from '@mm-actions/actionUtils';
import { GlobalActions } from '@mm-actions/global';
export const Actions = {
diff --git a/webapp/src/ts/actions/services.ts b/webapp/src/ts/actions/services.ts
index 2a61d3f6853..72c3ede9613 100644
--- a/webapp/src/ts/actions/services.ts
+++ b/webapp/src/ts/actions/services.ts
@@ -1,5 +1,5 @@
import { Store } from '@ngrx/store';
-import { createSingleValueAction } from './actionUtils';
+import { createSingleValueAction } from '@mm-actions/actionUtils';
export const Actions = {
setLastChangedDoc: createSingleValueAction('SET_LAST_CHANGED_DOC', 'lastChangedDoc'),
diff --git a/webapp/src/ts/actions/target-aggregates.ts b/webapp/src/ts/actions/target-aggregates.ts
index d739634fbb8..1bc13d8cd59 100644
--- a/webapp/src/ts/actions/target-aggregates.ts
+++ b/webapp/src/ts/actions/target-aggregates.ts
@@ -1,5 +1,5 @@
import { Store } from '@ngrx/store';
-import { createSingleValueAction } from './actionUtils';
+import { createSingleValueAction } from '@mm-actions/actionUtils';
export const Actions = {
setSelectedTargetAggregate: createSingleValueAction('SET_SELECTED_TARGET_AGGREGATE', 'selected'),
diff --git a/webapp/src/ts/actions/tasks.ts b/webapp/src/ts/actions/tasks.ts
index 177fe68c209..e686ff273ca 100644
--- a/webapp/src/ts/actions/tasks.ts
+++ b/webapp/src/ts/actions/tasks.ts
@@ -1,5 +1,5 @@
import { Store, createAction } from '@ngrx/store';
-import { createSingleValueAction } from './actionUtils';
+import { createSingleValueAction } from '@mm-actions/actionUtils';
export const Actions = {
setTasksList: createSingleValueAction('SET_TASKS_LIST', 'tasks'),
diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts
index 91fa3cfbb1e..e3a161a1a60 100644
--- a/webapp/src/ts/app.component.ts
+++ b/webapp/src/ts/app.component.ts
@@ -6,7 +6,7 @@ import { setTheme as setBootstrapTheme } from 'ngx-bootstrap/utils';
import { combineLatest } from 'rxjs';
import { DBSyncService, SyncStatus } from '@mm-services/db-sync.service';
-import { Selectors } from './selectors';
+import { Selectors } from '@mm-selectors/index';
import { GlobalActions } from '@mm-actions/global';
import { SessionService } from '@mm-services/session.service';
import { AuthService } from '@mm-services/auth.service';
@@ -17,7 +17,7 @@ import { LocationService } from '@mm-services/location.service';
import { ModalService } from '@mm-modals/mm-modal/mm-modal';
import { ReloadingComponent } from '@mm-modals/reloading/reloading.component';
import { FeedbackService } from '@mm-services/feedback.service';
-import { environment } from './environments/environment';
+import { environment } from '@mm-environments/environment';
import { FormatDateService } from '@mm-services/format-date.service';
import { XmlFormsService } from '@mm-services/xml-forms.service';
import { JsonFormsService } from '@mm-services/json-forms.service';
diff --git a/webapp/src/ts/components/components.module.ts b/webapp/src/ts/components/components.module.ts
index a97f4f92246..06e1890ab11 100644
--- a/webapp/src/ts/components/components.module.ts
+++ b/webapp/src/ts/components/components.module.ts
@@ -5,13 +5,15 @@ import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
-import { HeaderComponent } from './header/header.component';
-import { PipesModule } from '../pipes/pipes.module';
-import { DirectivesModule } from '../directives/directives.module';
-import { SnackbarComponent } from './snackbar/snackbar.component';
-import { ContentRowListItemComponent } from './content-row-list-item/content-row-list-item.component';
-import { ReportVerifyValidIconComponent, ReportVerifyInvalidIconComponent } from './status-icons/status-icons.template';
-
+import { HeaderComponent } from '@mm-components/header/header.component';
+import { PipesModule } from '@mm-pipes/pipes.module';
+import { DirectivesModule } from '@mm-directives/directives.module';
+import { SnackbarComponent } from '@mm-components/snackbar/snackbar.component';
+import { ContentRowListItemComponent } from '@mm-components/content-row-list-item/content-row-list-item.component';
+import {
+ ReportVerifyValidIconComponent,
+ ReportVerifyInvalidIconComponent
+} from '@mm-components/status-icons/status-icons.template';
import {
MultiDropdownFilterComponent
} from '@mm-components/filters/multi-dropdown-filter/multi-dropdown-filter.component';
diff --git a/webapp/src/ts/directives/directives.module.ts b/webapp/src/ts/directives/directives.module.ts
index c97bf7c610e..bc18575ce39 100644
--- a/webapp/src/ts/directives/directives.module.ts
+++ b/webapp/src/ts/directives/directives.module.ts
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
-import { AuthDirective } from './auth.directive';
+import { AuthDirective } from '@mm-directives/auth.directive';
@NgModule({
declarations: [
diff --git a/webapp/src/ts/index.html b/webapp/src/ts/index.html
index 40dc40cad46..8bf448774bb 100644
--- a/webapp/src/ts/index.html
+++ b/webapp/src/ts/index.html
@@ -4,6 +4,7 @@
+