diff --git a/admin/src/js/controllers/images-branding.js b/admin/src/js/controllers/images-branding.js index bfe2ac90376..b12884ec974 100644 --- a/admin/src/js/controllers/images-branding.js +++ b/admin/src/js/controllers/images-branding.js @@ -14,14 +14,9 @@ angular.module('controllers').controller('ImagesBrandingCtrl', const DOC_ID = 'branding'; const MAX_FILE_SIZE = 100000; // 100KB - $('#logo-upload .choose').on('click', _ev => { - _ev.preventDefault(); - $('#logo-upload .uploader').click(); - }); - - $('#favicon-upload .choose').on('click', _ev => { - _ev.preventDefault(); - $('#favicon-upload .uploader').click(); + $('#images-branding .choose').on('click', ev => { + ev.preventDefault(); + $(ev.target).closest('.form-group').find('.uploader').click(); }); $scope.loading = true; @@ -31,6 +26,7 @@ angular.module('controllers').controller('ImagesBrandingCtrl', .then(doc => { $scope.doc = doc; $scope.favicon = doc._attachments[doc.resources.favicon]; + $scope.icon = doc._attachments[doc.resources.icon]; }) .catch(err => { $log.error('Error fetching resources file', err); @@ -85,10 +81,12 @@ angular.module('controllers').controller('ImagesBrandingCtrl', const updateFavicon = () => updateImage(getFile('#favicon-upload'), 'favicon'); + const updateIcon = () => updateImage(getFile('#icon-upload'), 'icon'); + const removeObsoleteAttachments = () => { const current = $scope.doc._attachments; const updated = {}; - ['logo', 'favicon'].forEach(key => { + ['logo', 'favicon', 'icon'].forEach(key => { const name = $scope.doc.resources[key]; if (name) { updated[name] = current[name]; @@ -108,7 +106,8 @@ angular.module('controllers').controller('ImagesBrandingCtrl', if (!validateTitle() || !updateLogo() || - !updateFavicon()) { + !updateFavicon() || + !updateIcon()) { return; } diff --git a/admin/src/templates/images_branding.html b/admin/src/templates/images_branding.html index badfa08935b..8245d89e975 100644 --- a/admin/src/templates/images_branding.html +++ b/admin/src/templates/images_branding.html @@ -24,9 +24,10 @@ + branding.logo.field.help
- +
@@ -40,6 +41,24 @@
+ branding.favicon.field.help + +
+ +
+ +
+
+ +
+
+ +
+
+ branding.icon.field.help
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 @@ + {{ branding.name }} +
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 @@ +