From e9de6e6b7697669b4dd1f8c3dcd4fde6fe75f9ce Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Mon, 14 Mar 2022 06:35:07 +1300 Subject: [PATCH 1/4] Add delay between logging out and back in again in e2e tests Attempt to fix create-meta-db.wdio-spec.js "Create user meta db" test which has been failing randomly. --- tests/page-objects/common/common.wdio.page.js | 1 + 1 file changed, 1 insertion(+) 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 () => { From e2aedd41aabdce2ffd5b0d4fd809751b3d286446 Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Wed, 16 Mar 2022 15:02:14 +1300 Subject: [PATCH 2/4] Fix PWA compliance medic/cht-core#7362 --- admin/src/js/controllers/images-branding.js | 19 ++- admin/src/templates/images_branding.html | 21 +++- api/server.js | 5 + api/src/controllers/login.js | 34 +---- api/src/services/branding.js | 52 ++++++++ api/src/services/config-watcher.js | 10 +- api/src/services/manifest.js | 35 ++++++ api/src/templates/login/index.html | 2 + api/src/templates/login/token-login.html | 2 + api/src/templates/manifest.json | 19 +++ api/tests/mocha/controllers/login.spec.js | 116 ++++-------------- api/tests/mocha/services/branding.spec.js | 57 +++++++++ .../mocha/services/config-watcher.spec.js | 5 + api/tests/mocha/services/manifest.spec.js | 78 ++++++++++++ ddocs/medic/_attachments/manifest.json | 11 -- .../translations/messages-en.properties | 10 +- tests/e2e/manifest.wdio-spec.js | 111 +++++++++++++++++ tests/e2e/service-worker.wdio-spec.js | 13 +- tests/utils.js | 15 ++- webapp/src/img/icon.png | Bin 0 -> 6887 bytes webapp/src/ts/index.html | 1 + 21 files changed, 456 insertions(+), 160 deletions(-) create mode 100644 api/src/services/branding.js create mode 100644 api/src/services/manifest.js create mode 100644 api/src/templates/manifest.json create mode 100644 api/tests/mocha/services/branding.spec.js create mode 100644 api/tests/mocha/services/manifest.spec.js delete mode 100644 ddocs/medic/_attachments/manifest.json create mode 100644 tests/e2e/manifest.wdio-spec.js create mode 100644 webapp/src/img/icon.png 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/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/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/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/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/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/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 0000000000000000000000000000000000000000..167d64d13d50cb64d346e10a62ef3c54f1e30064 GIT binary patch literal 6887 zcmb_h*E<{z)7{k;Yh{UESFh1qSh9MFPDJ^U=)HGhErQiM5nT|X_bz%*5Jd02Mvv~z z_dk3WGv{jNnTvVmoaao0rn({op9&uU06>(L_{VX!z5u6A#j3K41H-*IA@`Q!FK?%T&sB9=)<{-TR_ar8Y|f;-ta z$D`@#g4QI#Fx+_hISevG1Oui;-qxnsD24z+09VfdfiuW?J``$+vcM^;K@r5i*-iqF z0EP(kErFD<(oyS-?!fy?avtN3Xu74AJe3sA2BLi!hX8_5psw2Pc)2y)dmy&2{cdh| zqMWPS`iES--E?MI%HH${Al-?pwaxc>!I_AH49BF4{|57&Kdm6E8cL<4?sY%|87HQN zebi?T=ds5J+_i;_@}%;u-)D}Ymz5)cmjlPQhJO#2q6V9oPw)|%0JxZt z?PdsR$04e*-RhXb+x`Y#Z$^H{Y9PYQQ>Q<+zp4xK$guIn#}U9>cck8+ zek<~MYPrFPAHAPXd0Fa@4AUR^LOxBcMhr`j@|LJW=M6V|CBrHTBd^1$5GFqvlu8$a zBnyz1&iRi8WTesrXE5SN4;bS>dL%84NZcuTvv{&g0S+FI_!Bie>c^|Nbp`e9YFW}8P1r4~9cIik`cpIAvQ5#lJSnaa2y_HQM$dLm0x zf!}5oynI9avQQ`NAO1eX6#|HT3?b`lI*oKvcRuCD`;94@lrSTMU#c(DD*x*%9(o)c z8k$Ta!J=!E$sE)P+*~N&5YV&pXBXU(*!QG_DH^oMNz#|EptkI14#IgvB5i^B?u1yH zf7Lb2k=<}MTN}fA0YuT4K$)xMve2g2=uQf36T7%Acvc>U*D$d|O%;U{BJmRdskp%G}jydVR)b!V&)%kK&8?4V22dRYeE8!w< z_D(NcdKCA-6J^TgAbT4VPXWaC+{m}ap+@D`?}En~j?7M=-2H(4@dilhPYpR!Q-;OG zMP_PxW=soG;%(B$y=zz196|EXw$AlHk9J6gr|lT%;8A4lVNLb=x3R=TySw%1Cn2pw z=nx=%0M+Nb(%}x;_^Q^Ea?se9<=rKQaB||Tvz;KYeuR&R7%aV^(c@8)r+)V?>Y(wh zG$qX6;5Wj_#DuOp-`sU`_LPwa{`xv^Pk^_62$vn z3EVl>8^*A3>6vb%^6FQ9iMr!wEmlPV3i{-~EKF)-7=LdE4+7>Uy6cRqACq3_cUaL1 zwejed-Q$m73@-IxQjU=2m1rnLIYgosFuVR_eZbX>L72CIz^B2SlpWk4IXxb$YW%8cU!l}J@}Q3DYhrUkWx0LL>o zH|VQChDYyJs1S6Ph&EK_KSMpcC!?B{NWWasyG!ZS%?Q7jBDyoI`4%V#GlkXr&?vN8 zdfapCM?f>2<}0VjhBk#VvpyX{*iO%Kff8~s)0mLjK+c3|QK%+NMYlpD8JR5q+V$c0 zi{fObc~Yw-y0Pa(b+k|FjwhCGf1E|!p;4>>Tz-`;dDsv1S4)Syjk;G@;ENW+4;HF zo1B#a0aP~f%yl#qPNbG0l)AZXtL*-7p>UyN=VFfs%G~;A43BVct?x+{D-@(ct76#E zFGu=IIrf8+Ran~EzlDVLE5uO2__;~cs7Fo_tRe{>?^=Dio|n!z_Et#V#lI`Iip0NN zQdeK3na1fc!i={8zR<*{>sh8jbI?bpRG4nUC$<_Pj*5qKe}z*khC9ng7lcGbT@vem zl&cX~+H!#nPa=n-02AFCuUg-xQ**^B)tFh^#sefniOg# z8;(y}lGWDL4`c53RV>9rDV<7e!jqGLoIRs&E8i)4{Q7Y3b|$nCrU|CPA^g$d!8Ib^ zrP|Y|Al0{V25mV+8r?&d+2?XbxpU#AicD}dH~T1$OfmFtaw&ve-y6v|yL zhofZwQl;^Lo+eDt`pN2N&Cf=ylYRW6?FJBT8){7!$Mn~yiPVPDo_}qD?Ck6apAW=W z^l*$I9rO@S{Z6e%t>^jIoubCPU65;Tc6fHnvWb35DnU*dM2&pCK$PAWHORkwGDpV! zc)~<-M#Y{yFT_QNsg&6O#wUWYkbDx~4rn|74P#?4HK~t-BfdXflCxsrFmZ838N}+O zWUVeBB{f}9EuQ-ydrzP~el7hepg17}c4+9yvzX3j1A)LBTF$~q>|oNPV)6$nTA=2U zVSN=hzuwtEI^?cckt%oT?ir(yRKlDrNJ)OcNyY1=0b}G#BxqeWWW4>g^?5&(?fb(Z zh8!Nxz|C0q9C#;v@(_*^8)x0meS9(#x0#~4ZkJT5;U6yyL=b){^>`vC}(eE>_HwYYE3|Pj*QJ)qnvO-VGK%|k($xMgwZa+|QALu}L+I5=MAE0{bRlqu=0WjcxMs!dlC<-UicwZ-)f<6#21v_&>2FQVwISX;IM0HA zVWHO($ycK)AGvtQ#7jT|Sm=(|X@)voV4nsZ?}C0%`h5srbN$gtz6CH+MYZc^J4nC9 zsK>UxLiAVPX^Af9Z}8U67K6(#AAipjOCW{euWm?gf2u}7Y7z0(7gp_6adr(>;H^L%3 z02If_D1{dqid}eBnRV1h07Ez)UdeJWHHj1Sw8g3o2c!+ zGMS*$p5=_tC{cA8pf@@@g~Si0!pO4ynnv&g*7Dz^D~M#D|NDpvOfB=k`ab||GM=uU z#YJtvo>X^vB(-~{``Ov$iab&|L+?cb4Q1r*<^KK?a%*3$O~Ojx>?VAkq&1BHXNx4q zIWj`;2H`+%QhpSqTfFgxx|F<-baD>vNb}Ud?4K)BPet8$*!eAPhT+xQHvCRL29}3c zH_#YrQN&=hZLxOWa|V9ACZ9`A>eN9>QlU_vA|1uvkE2##&x^ySZNA{`KJ9NI`IqDm z%Gzugy>%OWR=v1wq7KEcxncX7DKv<>!A6TMzYk|yY2!Bdxtk?v&`2|nx@BvLl~gnY zPlkz}-zqHgn8RHr!Iw9R7PLJr^guq|y7T%IHD64*8Lx3tkTA7~{r7esq}9NaSCm~r ziNE~{nUY4NY|Q?2R!QWX=WG1jvO@=EZvE)?aKfk?EbI;}MDmqPkdv`EgPq>{5|^<* z$Xm)<#`XA&G%(9ol;Ibv@7oNRx2F3|6Q4-=pM}Q-@G_v}(4~I5(%QOJBcpB1dzeeZ zqjEM57)YsyjN%+J*#5k{XpA-A#h)ecBwIAEph?*B-JcFjbXHDmaPWUFDlAj;=le(V zb^UYhI|#hof>RsJ86h}p^jXiK_p(<>-fL~y*_9pM_fD$tpYPh3ot)0FqEz5SU5uAP zE^oi_eHcXC4>(hdEIJ)Bprw}Eut(mO%XcUk+Z8rnRMS|mF=I@%?tmT8o}mGiHQZZM z4AE3~J4tLrbUBe3>FETY*8SyZglIIUmoSqQVtPA~l^Mw{DD4BD{~Cy?8=aE)5Qqc4 z7ksOifos1CM{?H|8s*&y_E*TKzv+3BO1}RIZg|)7s8Z2pVB~uPKucoeip~ExSC7i^-xMUC@cH${S|Kr{^HoMf~Exl^7`^) zM;l>oqPLE|ZV&|DHrI4TH40r|^g)i##HXSa!YrZivBw|gW|meygy>H#Uw0BOA5O+n z8qYOIQb2>B$wzJfD68p>F-reLMG^^4tEj@#oYD_#*BrWJDdnuq(Zex8V^Pr&E>YvsU)(Zuz`G+O-e^jA;4O(lX_ol$oh z!H5GP8gK!zs4RBdZTC5DrU0F=J-T$D!B0K&5h5td7=I1}ZJH@Va@pve!|OgN>0Wxo zsOcKFijm}&FMOA~&u8(t{x+NL&Z0|D(71dU<@)EQODBi*r`|Q%t>bYU%1ErE&nf;O zdGuSpj_Qr(s|+C@S$gUBnqLz^cE0}R(lR6;jOwb6KoW@0vcOxj0iNC$Dj={XX&t{5 z2FSY*)E|4%Gv4Kl!stD`9EaY{H5@4!Ra5P^$vDE}1MCE`VN?wVQx`J2E--QLeH(iD zL+_tx_C@iigdUSSrgvwQYGj{bwNZQ#B#I<2^}T^7}uw0Yh_i z(yIQRc4`cU!j6%!Ck~(d015xVhNVpj64-4d#7JtOo0KbW0&Vb}J-Jq4dS4&O z=8kNMM~F<`M~$UhrI;whd6628nTEK&nBP2iW~($<{JZY^z2_op@F?s!Mm zlK-pL5Bhj{gdp_(7%w~G{{Ck6^m~9uR*V-;f8Go1Ao=iolO=Jlr>l{-6{clR+>6bD zGd!LtSjEUCL)=U;pk#}u@ZKicIJqW7^D97ZCE;Ne*!{)Kb@$pebyV5=!SnCWEGQ}b zxv>2#FVFTUy)_YCTJZVX3JQzl#t=`4+%LnDG;c`TaxGgw0evi|*Js=Lzg#M>y{Qld zDpuQ$SRcbS8fycqP0+CX!Uw5#6NRcLiwx>?n%O_dd%W|1jJuuXzpk5o)NJ5g_jO%i zQk_DK@R;-cWp}8}R_SXxttD+ugWi9OA$Xcdh30m0g zpw8DoS%eD(9emIa=omK zqLl!uAi{efXDEhgZPCVb+B>03f0cGc9WND%4&MXv)%tA5)6LZe%)zhHn7u! z#;hgPm*(qdb(fY8)X1m@hl3M==nnI{ilxK#28?XljdxMfm+bh| z*me-1;48&N74~#gmlq)6q7ig4aDn%$W2IZgDv~sf1W~8b=GDVmrvIIiYW@$5Ep12Z zYXf;AlkE6$^>5Zd33)LE=7RNs9E}Le3vcaNG74Exo1^`^kE(wasaH~25BBzXRq$Z~ ztdN(suV3|kmAFD5RkYjJ86<@%MG(p-DM*pd-!FzneWK1lzk2G(HMZu&7$lQ==GZLc zxQKhK!U8Y#sp=cs$qv9V&e=QEUMnW0`t^~V83f(SJU?MzB%X9x9rVs!vI9~TlXfP> zy$ahrX)fM6vb>l?N_tY|xkfZ01#3qO@KUpxr{5?U5!JWZ*T8=+FPm_8t|TW5u-1jX z?qF0#4JQ7rt>JsYOl6If=piWG?#|znka$y{|1Y)r8Y+4SuMoFrjg-jzTD(540X8(CEC-UH1Pco=9HZ z{e7vLB}wjm9088u9A;;Ap%7kt#uWaL5o%F3Jd27}%jly;h9r9yHIJkMpa3em4mmS_|viG3`)z@Nym4CaATD?zyB$$tCQfdWjnoJX&2m3sERCGtw5QWHU2&Z7y)|M`CRkn9a)NVq=j#cSU%BtyiCiRtTw zby|x{hF(`ebt|{Um5I4ordWXaaqaGZd5T6qgH<3p%jRRtiaV>+zbi4Dy(`IygaSC> za5%RkbsGCl4IsgD>|GNY)x6f^_dq7-WiaC=0fu3-z2EH=%)v z7K$)q^;2ZjY2iuHj%XGv%JZ&FP8g#1TgtLj0fxbTl-Oz9|rF#XA26;`twZw(W%f?>%gT914a}VS6 zDyjVil+$bR|09Lb!{nNX{l%bh!Krx~QmGF0gvGad`Vp77o^!^?8gl^i`&-SF6*VpVH$D0UFz7Rq9v7b|zheTk2kdp5{V|M59rp~XM_CY7zdkNr_$V%=ojT_rT9bnQmI`o|DN41r!e(G+3X@i}`>0@*yQF>;7qVgW#mqxAQ=KHkN0a||`!Irj5R;-Vrw<(-YL|(F!g2C9Z+wT_3;exDf)FtVqaWkL80h_y_2bwfbH!SF zD)#3Amj0Xp&kF4M3~m0aF%uTi40iW=(m$O`zk#K+Ija&>PSdh5`YsFGz27dt+r9Y( zCZrLkjoM|xgr=v|Sjn+{eLoiDOz<-%0!NfSHxVL#6BEhEO#Ry#RfP!jQVZF*=Nafe zA0#jhvnn)l-InWH9e9VFew_R4$!HwcQyq1RDg#C#ASE6P*BENUH)=^dX#AG*B_}GfL3W1 zJb((Ar`449fGQ`m_fbpNis?7}oRaRQoamz|AE@rp3OWY4zNBA0ZJt3EKWz#sNNm7oxkav<`4x+O+4 znC#A@#MFa-x`nQ!*!tViQOr0X(8IqdiZZrw>hp?UfCZ{fgqxdN?yQ?-w%VT;9Q7ig zNEI%l#^Yt4gM%jc9CH;XX%zr&YQmy~h2Rv7+T`)$iQPZAo>qEzOL{8dXBT1?31?rG z2M|%hsbi>x-A;=dV{QcyEQd;6Ho&|DvCBJyQyb_4^o=0u@l@6NBi3!tLZ9<&HAFn0 zgfds4%=cW6Rr^=k#$a_$t{!>5;Tw&Mqb0EU3jML$TkUz`)EbL_c33rEC=#mV@v+4; z1gF?|WZ_U=!Cy3+WOD?doJabfK8O-V?gZy3=D}nR)pIeWwLtyXbXt(xLJ&`4*cIv- z4|H4WPYT`_Kxo@wlmC{+84RV?!2kCDS^OAi9+Yrpb2pXr?{fuEmRFanka-{cKhmNB ATL1t6 literal 0 HcmV?d00001 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 @@ +