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 @@
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/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/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/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/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 @@
+