From a9aea1eedbc3976729cca91dcba5a18edfa120bd Mon Sep 17 00:00:00 2001 From: Diana Barsan <35681649+dianabarsan@users.noreply.github.com> Date: Thu, 14 Nov 2024 08:50:32 +0300 Subject: [PATCH 01/30] fix(#9618): don't crash api on startup if form is broken (#9641) #9618 --- api/server.js | 12 ++++++++---- tests/integration/api/server.spec.js | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/api/server.js b/api/server.js index fb8d799d8ef..6364630fda2 100644 --- a/api/server.js +++ b/api/server.js @@ -82,7 +82,6 @@ process await migrations.run(); logger.info('Database migrations completed successfully'); - startupLog.start('forms'); logger.info('Generating manifest'); await manifest.generate(); logger.info('Manifest generated successfully'); @@ -90,15 +89,20 @@ process logger.info('Generating service worker'); await generateServiceWorker.run(true); logger.info('Service worker generated successfully'); + } catch (err) { + logger.error('Fatal error initialising API'); + logger.error('%o', err); + process.exit(1); + } + try { + startupLog.start('forms'); logger.info('Updating xforms…'); await generateXform.updateAll(); logger.info('xforms updated successfully'); - } catch (err) { - logger.error('Fatal error initialising API'); + logger.error('Error initialising API'); logger.error('%o', err); - process.exit(1); } startupLog.complete(); diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 16c529221c6..2b50dec116c 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -430,4 +430,28 @@ describe('server', () => { }); }); + + describe('api startup', () => { + it('should start up with broken forms', async () => { + const waitForLogs = await utils.waitForApiLogs(/Failed to update xform/); + + const formName = 'broken'; + const formDoc = { + _id: `form:${formName}`, + title: formName, + type: 'form', + _attachments: { + xml: { + content_type: 'application/octet-stream', + data: btoa('this is totally not an xml'), + }, + }, + }; + await utils.db.put(formDoc); // don't use utils.saveDoc because that actually waits for good forms + await waitForLogs.promise; + + await utils.stopApi(); + await utils.startApi(); + }); + }); }); From df74663324dbae4e31053a97929b4a1090c54b62 Mon Sep 17 00:00:00 2001 From: Rafa Date: Thu, 14 Nov 2024 13:36:36 +0100 Subject: [PATCH 02/30] chore(#9594): add remaining e2e test to tasks for offline user (#9640) Co-authored-by: Rafa --- .../tasks/config/tasks-breadcrumbs-config.js | 33 ++++++++++++++++++- tests/e2e/default/tasks/tasks.wdio-spec.js | 18 ++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js b/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js index 2c6687693e9..d6a4a79f934 100644 --- a/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js +++ b/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js @@ -38,7 +38,7 @@ module.exports = [ ], events: [ { - id: 'person-creation-follow-up', + id: 'person-creation', start: 3, end: 7, dueDate: function (event, contact) { @@ -47,4 +47,35 @@ module.exports = [ } ] }, + + { + name: 'person_create_follow_up', + icon: 'icon-person', + title: 'person_create_follow_up', + appliesTo: 'reports', + appliesToType: ['home_visit'], + appliesIf: function () { + return true; + }, + resolvedIf: function (contact) { + return isFormArraySubmittedInWindow(contact.reports, ['home_visit'], contact.contact.reported_date); + }, + actions: [ + { + type: 'report', + form: 'home_visit' + } + ], + events: [ + { + id: 'person-creation-follow-up', + start: 3, + end: 1, + dueDate: function (event, contact) { + return contact.contact.reported_date; + } + } + ] + }, + ]; diff --git a/tests/e2e/default/tasks/tasks.wdio-spec.js b/tests/e2e/default/tasks/tasks.wdio-spec.js index e2a1ed6ba26..f0e770a1de0 100644 --- a/tests/e2e/default/tasks/tasks.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks.wdio-spec.js @@ -92,6 +92,24 @@ describe('Tasks', () => { expect(list).to.have.length(2); }); + it('should add a task when CHW completes a task successfully, and that task creates another task', async () => { + await tasksPage.compileTasks('tasks-breadcrumbs-config.js', false); + + await commonPage.goToTasks(); + let list = await tasksPage.getTasks(); + expect(list).to.have.length(2); + let task = await tasksPage.getTaskByContactAndForm('Megan Spice', 'person_create'); + await task.click(); + await tasksPage.waitForTaskContentLoaded('Home Visit'); + const taskElement = await tasksPage.getOpenTaskElement(); + await genericForm.submitForm(); + await taskElement.waitForDisplayed(); + await commonPage.sync(true); + task = await tasksPage.getTaskByContactAndForm('Megan Spice', 'person_create_follow_up'); + list = await tasksPage.getTasks(); + expect(list).to.have.length(3); + }); + it('should load multiple pages of tasks on infinite scrolling', async () => { await tasksPage.compileTasks('tasks-multiple-config.js', true); From ac1147c4f45d316eb67316aa2a313990b2b62c5b Mon Sep 17 00:00:00 2001 From: Diana Barsan <35681649+dianabarsan@users.noreply.github.com> Date: Sat, 16 Nov 2024 17:35:33 +0300 Subject: [PATCH 03/30] fix(#9286): don't pass request timeout prop (#9634) Removes view indexer request timeout param. This didn't end up terminating the request at haproxy level. #9286 #9617 #8573 --- api/src/services/setup/view-indexer.js | 1 - api/tests/mocha/services/setup/view-indexer.spec.js | 13 +++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/api/src/services/setup/view-indexer.js b/api/src/services/setup/view-indexer.js index 7055f867f80..6dac3ff3476 100644 --- a/api/src/services/setup/view-indexer.js +++ b/api/src/services/setup/view-indexer.js @@ -66,7 +66,6 @@ const indexView = async (dbName, ddocId, viewName) => { uri: `${environment.serverUrl}/${dbName}/${ddocId}/_view/${viewName}`, json: true, qs: { limit: 1 }, - timeout: 2000, }); } catch (requestError) { if (!continueIndexing) { diff --git a/api/tests/mocha/services/setup/view-indexer.spec.js b/api/tests/mocha/services/setup/view-indexer.spec.js index c61787d0870..098a53601d1 100644 --- a/api/tests/mocha/services/setup/view-indexer.spec.js +++ b/api/tests/mocha/services/setup/view-indexer.spec.js @@ -6,7 +6,8 @@ const db = require('../../../../src/db'); const env = require('@medic/environment'); const request = require('@medic/couch-request'); const databases = require('../../../../src/services/setup/databases'); -const upgradeLogService = require('../../../../src/services/setup/upgrade-log'); +const upgradeLogService = require('../../../../src/service' + + 's/setup/upgrade-log'); let viewIndexer; @@ -60,38 +61,33 @@ describe('View indexer service', () => { uri: 'http://localhost/thedb/_design/:staged:one/_view/view1', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb/_design/:staged:one/_view/view2', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb/_design/:staged:one/_view/view3', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb/_design/:staged:three/_view/view4', json: true, qs: { limit: 1 }, - timeout: 2000, }], [{ uri: 'http://localhost/thedb-users-meta/_design/:staged:four/_view/view', json: true, qs: { limit: 1 }, - timeout: 2000, }], ]); }); }); describe('indexView', () => { - it('should query the view with a timeout', async () => { + it('should query the view', async () => { sinon.stub(request, 'get').resolves(); sinon.stub(env, 'serverUrl').value('http://localhost'); @@ -102,7 +98,6 @@ describe('View indexer service', () => { uri: 'http://localhost/medic/_design/:staged:medic/_view/contacts', json: true, qs: { limit: 1 }, - timeout: 2000, }]); }); @@ -119,7 +114,6 @@ describe('View indexer service', () => { uri: 'http://localhost/other/_design/mydesign/_view/viewname', json: true, qs: { limit: 1 }, - timeout: 2000, }; expect(request.get.args).to.deep.equal(Array.from({ length: 21 }).map(() => [params])); }); @@ -137,7 +131,6 @@ describe('View indexer service', () => { uri: 'http://localhost/other/_design/mydesign/_view/viewname', json: true, qs: { limit: 1 }, - timeout: 2000, }; expect(request.get.args).to.deep.equal(Array.from({ length: 21 }).map(() => [params])); }); From 58dcfb6d746fee5c5640e35ccf16e95b53f3057f Mon Sep 17 00:00:00 2001 From: Jennifer Q <66472237+latin-panda@users.noreply.github.com> Date: Mon, 18 Nov 2024 11:01:51 +0700 Subject: [PATCH 04/30] chore(#9648): update version to 4.15 (#9649) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 819b0b5e2aa..f5bf5d6881f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "medic", - "version": "4.14.0", + "version": "4.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medic", - "version": "4.14.0", + "version": "4.15.0", "hasInstallScript": true, "license": "AGPL-3.0-only", "workspaces": [ diff --git a/package.json b/package.json index 43e9019ccce..7472d6780fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "medic", - "version": "4.14.0", + "version": "4.15.0", "private": true, "license": "AGPL-3.0-only", "repository": { From 51da792b7bc0c6e9b212bc61a7ff0afe05fa440a Mon Sep 17 00:00:00 2001 From: Diana Barsan <35681649+dianabarsan@users.noreply.github.com> Date: Mon, 18 Nov 2024 09:50:16 +0300 Subject: [PATCH 05/30] fix(#9612): hide last submitted task from task list (#9650) Due to a recent debounce in setting contacts as dirty, task-list ended up waiting for two debounce delays when reloading tasks: one from rules engine and one from tasks component itself. It previously had only one debounce, so this issue was less visible, but still existed. As a fix: - emit the change notification early in rules engine, this way both debounces are in parallel - hide the last submitted task from the task list immediately after submission. if the task was not completed by the action, it will show up again when the task list refreshes. Optimizing an e2e test that took a long time due to getting info from "not rendered" angular elements. #9612 --- tests/page-objects/default/tasks/tasks.wdio.page.js | 1 + webapp/src/ts/reducers/tasks.ts | 1 + webapp/src/ts/services/rules-engine.service.ts | 2 +- webapp/tests/karma/ts/reducers/tasks.spec.ts | 1 + webapp/tests/karma/ts/services/rules-engine.service.spec.ts | 6 +++++- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/page-objects/default/tasks/tasks.wdio.page.js b/tests/page-objects/default/tasks/tasks.wdio.page.js index c9fc2a98177..3c9ffee7c35 100644 --- a/tests/page-objects/default/tasks/tasks.wdio.page.js +++ b/tests/page-objects/default/tasks/tasks.wdio.page.js @@ -12,6 +12,7 @@ const getTaskById = (emissionId) => $(`${TASK_LIST_SELECTOR} li[data-record-id=" const getTasks = () => $$(`${TASK_LIST_SELECTOR} li.content-row`); const getTaskInfo = async (taskElement) => { + await taskElement.scrollIntoView(); const contactName = await (await taskElement.$('h4 span')).getText(); const formTitle = await (await taskElement.$('.summary p')).getText(); let lineage = ''; diff --git a/webapp/src/ts/reducers/tasks.ts b/webapp/src/ts/reducers/tasks.ts index 8768ff14d90..6bad3eb217b 100644 --- a/webapp/src/ts/reducers/tasks.ts +++ b/webapp/src/ts/reducers/tasks.ts @@ -47,6 +47,7 @@ const _tasksReducer = createReducer( on(Actions.setLastSubmittedTask, (state, { payload: { task } }) => ({ ...state, + tasksList: state.tasksList.filter(t => task?._id !== t._id), taskGroup: { ...state.taskGroup, lastSubmittedTask: task diff --git a/webapp/src/ts/services/rules-engine.service.ts b/webapp/src/ts/services/rules-engine.service.ts index 8e7a4712e90..cf16f0e1380 100644 --- a/webapp/src/ts/services/rules-engine.service.ts +++ b/webapp/src/ts/services/rules-engine.service.ts @@ -214,6 +214,7 @@ export class RulesEngineService implements OnDestroy { private dirtyContactCallback(change) { const subjectIds = [this.isReport(change.doc) ? RegistrationUtils.getSubjectId(change.doc) : change.id]; + this.observable.next(subjectIds); if (this.debounceActive[this.CHANGE_WATCHER_KEY]?.active) { const oldSubjectIds = this.debounceActive[this.CHANGE_WATCHER_KEY].params; @@ -238,7 +239,6 @@ export class RulesEngineService implements OnDestroy { this.telemetryService.record(this.getTelemetryTrackName('refresh', 'dirty-contacts'), contactIds.length); await this.rulesEngineCore.updateEmissionsFor(contactIds); - this.observable.next(contactIds); trackPerformance?.stop({ name: this.getTelemetryTrackName('refresh') }); }, this.DEBOUNCE_CHANGE_MILLIS); diff --git a/webapp/tests/karma/ts/reducers/tasks.spec.ts b/webapp/tests/karma/ts/reducers/tasks.spec.ts index 97403ef4997..acfc397952f 100644 --- a/webapp/tests/karma/ts/reducers/tasks.spec.ts +++ b/webapp/tests/karma/ts/reducers/tasks.spec.ts @@ -277,6 +277,7 @@ describe('Tasks reducer', () => { tasksList: [ { _id: 'task1', dueDate: 22, state: 'Ready' }, { _id: 'task2', dueDate: 33, state: 'Ready' }, + { _id: 'task_id2', due: '33', field: 2 } ], loaded: true, taskGroup: { diff --git a/webapp/tests/karma/ts/services/rules-engine.service.spec.ts b/webapp/tests/karma/ts/services/rules-engine.service.spec.ts index a3dbc7e793d..9b20579d25c 100644 --- a/webapp/tests/karma/ts/services/rules-engine.service.spec.ts +++ b/webapp/tests/karma/ts/services/rules-engine.service.spec.ts @@ -468,11 +468,15 @@ describe('RulesEngineService', () => { const change = changesService.subscribe.args[0][0]; await change.callback(changeFeedFormat({ type: 'data_record', form: 'f', fields: { patient_id: 'p1' } })); + expect(callback.callCount).to.equal(1); await change.callback(changeFeedFormat({ _id: '2', type: 'person', patient_id: 'p2' })); + expect(callback.callCount).to.equal(2); tick(500); await change.callback(changeFeedFormat({ _id: '3', type: 'person', patient_id: 'p3' })); + expect(callback.callCount).to.equal(3); tick(900); await change.callback(changeFeedFormat({ type: 'data_record', form: 'f', fields: { patient_id: 'p3' }})); + expect(callback.callCount).to.equal(4); expect(rulesEngineCoreStubs.updateEmissionsFor.callCount).to.equal(0); @@ -480,7 +484,7 @@ describe('RulesEngineService', () => { expect(rulesEngineCoreStubs.updateEmissionsFor.callCount).to.equal(1); expect(rulesEngineCoreStubs.updateEmissionsFor.args[0][0]).to.have.members([ 'p3', '3', '2', 'p1' ]); - expect(callback.callCount).to.equal(1); + expect(callback.callCount).to.equal(4); subscription.unsubscribe(); expect(telemetryService.record.calledOnce).to.be.true; From e33cd8738be9bbe1e3c34566fe07d7bf9fa9da6f Mon Sep 17 00:00:00 2001 From: Jennifer Q <66472237+latin-panda@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:59:13 +0700 Subject: [PATCH 06/30] feat(#9598): add training materials page (#9592) --- .../translations/messages-en.properties | 17 +- .../translations/messages-es.properties | 5 +- .../translations/messages-fr.properties | 5 +- .../translations/messages-hi.properties | 4 + .../translations/messages-id.properties | 4 + .../translations/messages-ne.properties | 5 +- .../translations/messages-sw.properties | 5 +- .../enketo/training-cards.wdio-spec.js | 5 +- .../service-worker.wdio-spec.js | 1 + tests/e2e/default/suites.js | 1 + .../forms/expired-training.xml | 95 ++++ .../forms/first-training.xml | 95 ++++ .../forms/second-training.xml | 95 ++++ .../training-materials.wdio-spec.js | 166 +++++++ .../default/common/common.wdio.page.js | 8 + .../enketo/training-cards.wdio.page.js | 49 +- webapp/src/css/content-list.less | 15 + webapp/src/css/inbox.less | 21 +- webapp/src/css/modal.less | 61 ++- webapp/src/css/old-nav.less | 16 + webapp/src/css/variables.less | 5 + webapp/src/img/icon-check.svg | 4 + webapp/src/ts/actions/global.ts | 15 + webapp/src/ts/app-routing.module.ts | 2 + webapp/src/ts/app.component.ts | 21 +- webapp/src/ts/components/components.module.ts | 3 + .../components/header/header.component.html | 6 + .../modal-layout/modal-layout.component.html | 2 +- .../sidebar-menu/sidebar-menu.component.ts | 6 + .../training-cards-form.component.html | 15 + .../training-cards-form.component.ts | 230 ++++++++++ webapp/src/ts/modals/modals.module.ts | 3 + .../training-cards-confirm.component.html | 12 + .../training-cards-confirm.component.ts | 33 ++ .../training-cards.component.html | 42 +- .../training-cards.component.ts | 222 ++------- webapp/src/ts/modules/modules.module.ts | 8 + .../src/ts/modules/tasks/tasks.component.html | 8 +- .../trainings-content.component.html | 21 + .../trainings/trainings-content.component.ts | 112 +++++ .../trainings-route.guard.provider.ts | 18 + .../trainings/trainings.component.html | 42 ++ .../modules/trainings/trainings.component.ts | 129 ++++++ .../ts/modules/trainings/trainings.routes.ts | 28 ++ webapp/src/ts/reducers/global.ts | 5 + webapp/src/ts/selectors/index.ts | 1 + .../src/ts/services/training-cards.service.ts | 132 ++++-- webapp/src/ts/training-card.guard.provider.ts | 2 +- .../sidebar-menu.component.spec.ts | 6 + .../training-cards-form.component.spec.ts | 351 +++++++++++++++ .../training-cards.component.spec.ts | 394 ++-------------- .../trainings-content.component.spec.ts | 173 +++++++ .../trainings/trainings.component.spec.ts | 200 ++++++++ webapp/tests/karma/ts/selectors/index.spec.ts | 1 + .../services/training-cards.service.spec.ts | 426 +++++++++++------- 55 files changed, 2514 insertions(+), 837 deletions(-) create mode 100644 tests/e2e/default/training-materials/forms/expired-training.xml create mode 100644 tests/e2e/default/training-materials/forms/first-training.xml create mode 100644 tests/e2e/default/training-materials/forms/second-training.xml create mode 100644 tests/e2e/default/training-materials/training-materials.wdio-spec.js create mode 100644 webapp/src/img/icon-check.svg create mode 100644 webapp/src/ts/components/training-cards-form/training-cards-form.component.html create mode 100644 webapp/src/ts/components/training-cards-form/training-cards-form.component.ts create mode 100644 webapp/src/ts/modals/training-cards-confirm/training-cards-confirm.component.html create mode 100644 webapp/src/ts/modals/training-cards-confirm/training-cards-confirm.component.ts create mode 100644 webapp/src/ts/modules/trainings/trainings-content.component.html create mode 100644 webapp/src/ts/modules/trainings/trainings-content.component.ts create mode 100644 webapp/src/ts/modules/trainings/trainings-route.guard.provider.ts create mode 100644 webapp/src/ts/modules/trainings/trainings.component.html create mode 100644 webapp/src/ts/modules/trainings/trainings.component.ts create mode 100644 webapp/src/ts/modules/trainings/trainings.routes.ts create mode 100644 webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts create mode 100644 webapp/tests/karma/ts/modules/trainings/trainings-content.component.spec.ts create mode 100644 webapp/tests/karma/ts/modules/trainings/trainings.component.spec.ts diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index cb04a0a6e72..3bc93546cdc 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -437,7 +437,7 @@ configuration.sms = SMS configuration.sms.forms = SMS forms configuration.sms.forms.title = You need to choose both an XML file and a Meta file before clicking the upload button. You may only upload one app form file at a time and any existing forms will be overwritten. configuration.sms.settings = Basic settings -configuration.sms.test.description = Use this page to send a test message to the production application without going through the SMS Gateway. Be sure to use a phone number registered to a CHW’s profile to mimic a report coming from him or her about a particular patient. +configuration.sms.test.description = Use this page to send a test message to the production application without going through the SMS Gateway. Be sure to use a phone number registered to a CHW?s profile to mimic a report coming from him or her about a particular patient. configuration.sms.test.from.number = From phone number configuration.sms.test.message.description = Limit of 144 characters configuration.sms.test.number.validation.description = Please enter a valid phone number without dashes or punctuation. @@ -454,7 +454,7 @@ confirm.destructive.navigation.submit = Exit confirm.destructive.navigation.title = Exit form? confirm.logout = You will need an internet connection to log back in. password.updated = Your password has been successfully updated. -confirm.verification = This report will be verified as “correct”. This cannot be changed later. +confirm.verification = This report will be verified as ?correct?. This cannot be changed later. confirm.verification.submit = Verify as correct confirm.verification.title = Verify report contact.age = Age @@ -654,8 +654,8 @@ enketo.geopicker.altitude = altitude (m) enketo.geopicker.closepolygon = close polygon enketo.geopicker.kmlcoords = KML coordinates enketo.geopicker.kmlpaste = paste KML coordinates here -enketo.geopicker.latitude = latitude (x.y °) -enketo.geopicker.longitude = longitude (x.y °) +enketo.geopicker.latitude = latitude (x.y ) +enketo.geopicker.longitude = longitude (x.y ) enketo.geopicker.points = points enketo.geopicker.searchPlaceholder = search for place or address enketo.geopicker.removePoint = This will completely remove the current geopoint from the list of geopoints and cannot be undone. Are you sure you want to do this? @@ -686,7 +686,7 @@ export.dhis.place.all = All Places export.dhis.place.description = Filter exported data to include data associated with contacts under this place in the hierarchy. export.dhis.place.label = Filter by place export.dhis.unconfigured = DHIS2 integration is not configured. -export.feedback.description = Download a log of detected errors and user feedback submitted via the “Report bug” feature in CSV format. The table below shows the most recently submitted reports. +export.feedback.description = Download a log of detected errors and user feedback submitted via the ?Report bug? feature in CSV format. The table below shows the most recently submitted reports. export.messages.description = Download all messages that have ever been sent or received in CSV format. export.people.description = Download all contacts registered in the system in JSON format. export.reports.description = Download a summary of all the reports that have ever been submitted in CSV format. @@ -1215,7 +1215,7 @@ sync.last_success = Last sync sync.now = Sync now sync.retry = Retry sync.feedback.failure.unknown = Sync failed. Unable to connect. -sync.status.in_progress = Currently syncing… +sync.status.in_progress = Currently syncing? sync.status.not_required = All reports synced sync.status.required = Reports to sync sync.status.unknown = Unable to connect @@ -1264,7 +1264,10 @@ training_cards.confirm.title = Leave training? training_cards.error.loading = Error loading training. Please contact your supervisor. training_cards.error.save = Error saving training. training_cards.form.saved = Training completed. -training_cards.modal.title = Important changes +training_materials.page.no_more_trainings = No more trainings +training_materials.page.no_selected = No training material selected +training_materials.page.no_trainings = No trainings found +training_materials.page.title = Training materials translation.add = Add new translation key translation.key = Translation key unique.id = Unique ID diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 2bb18b9bcf7..4dcc0aa8506 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = \¿Salir del entrenamiento\? training_cards.error.loading = Hubo un error al cargar el entrenamiento. Por favor contacte a su supervisor. training_cards.error.save = Hubo un error al guardar el entrenamiento. training_cards.form.saved = Entrenamiento completado. -training_cards.modal.title = Cambios importantes +training_materials.page.no_more_trainings = No hay más entrenamientos +training_materials.page.no_selected = Ningún material de entrenamiento seleccionado +training_materials.page.no_trainings = No se encontraron entrenamientos +training_materials.page.title = Materiales de entrenamiento translation.add = Agregar Traducción translation.key = Clave de traducción unique.id = Identificación única diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index a82b00af6de..87b2ddd1e14 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = Quitter l'entraînement? training_cards.error.loading = Erreur lors du chargement de la formation. Veuillez contacter votre superviseur. training_cards.error.save = Erreur lors de l'enregistrement de la formation. training_cards.form.saved = Formation terminée. -training_cards.modal.title = Changements importants +training_materials.page.no_more_trainings = Aucune formation restante +training_materials.page.no_selected = Aucun matériel de formation sélectionné +training_materials.page.no_trainings = Aucune formation trouvée +training_materials.page.title = Matériel de formation translation.add = Ajouter une traduction translation.key = Clé de traduction unique.id = ID unique diff --git a/api/resources/translations/messages-hi.properties b/api/resources/translations/messages-hi.properties index 9b012194aff..e39d2bf0510 100644 --- a/api/resources/translations/messages-hi.properties +++ b/api/resources/translations/messages-hi.properties @@ -1168,6 +1168,10 @@ training_cards.confirm.exit = यह प्रशिक्षण समाप् training_cards.confirm.button.no = रद्द करें training_cards.confirm.button.yes = बाहर निकलें training_cards.confirm.title = प्रशिक्षण छोड़ें? +training_materials.page.no_more_trainings = अब और कोई प्रशिक्षण नहीं है +training_materials.page.no_selected = कोई प्रशिक्षण दस्तावेज़ नहीं चुना गया +training_materials.page.no_trainings = कोई प्रशिक्षण नहीं मिला +training_materials.page.title = प्रशिक्षण दस्तावेज़ translation.add = अनुवाद दर्ज करें translation.key = अनुवाद का गाइड unique.id = diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index 53a0b392f54..28e9a589877 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -1175,6 +1175,10 @@ training_cards.confirm.exit = Pelatihan ini belum selesai. Jika Anda keluar seka training_cards.confirm.button.no = Batalkan training_cards.confirm.button.yes = Keluar training_cards.confirm.title = Keluar dari pelatihan? +training_materials.page.no_more_trainings = Tidak ada lagi pelatihan +training_materials.page.no_selected = Tidak ada materi pelatihan yang dipilih +training_materials.page.no_trainings = Tidak ditemukan pelatihan +training_materials.page.title = Materi pelatihan translation.add = Tambah terjemahan translation.key = Kunci terjemahan unique.id = diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index ab858cbc35b..78fa02deecf 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = तालिम छोड्ने हो? training_cards.error.loading = तालिम लोड गर्दा त्रुटि भयो। तपाइँको सुपरभाइजरलाई सम्पर्क गर्नुहोस्। training_cards.error.save = तालिम सेभ गर्न त्रुटि। training_cards.form.saved = तालिम सम्पन्न भयो। -training_cards.modal.title = महत्वपुर्ण परिवर्तनहरु +training_materials.page.no_more_trainings = थप तालिम छैन +training_materials.page.no_selected = कुनै तालिम सामग्री छनोट गरिएको छैन। +training_materials.page.no_trainings = तालिम फेला परेन +training_materials.page.title = तालिम शीर्षक translation.add = नयाँ अनुवाद कुञ्जी थप्नुहोस् translation.key = अनुवाद कुञ्जी unique.id = आईडी diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index b7e0f9dae7e..13c4eadb09e 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -1264,7 +1264,10 @@ training_cards.confirm.title = Ungependa kuondoka kwenye mafunzo? training_cards.error.loading = Hitilafu katika kupakia mafunzo. Tafadhali wasiliana na msimamizi wako. training_cards.error.save = Hitilafu katika kuhifadhi mafunzo. training_cards.form.saved = Mafunzo yamekamilika. -training_cards.modal.title = Mabadiliko muhimu +training_materials.page.no_more_trainings = Hakuna mafunzo mengine +training_materials.page.no_selected = Hakuna nyenzo za mafunzo zilizochaguliwa +training_materials.page.no_trainings = Hakuna mafunzo yaliyopatikana +training_materials.page.title = Vifaa vya mafunzo translation.add = Ongeza tafsiri translation.key = Ufunguo wa tafsiri unique.id = Kitambulisho cha kipekee diff --git a/tests/e2e/default/enketo/training-cards.wdio-spec.js b/tests/e2e/default/enketo/training-cards.wdio-spec.js index b9bd689ba2f..1a5ff4d781c 100644 --- a/tests/e2e/default/enketo/training-cards.wdio-spec.js +++ b/tests/e2e/default/enketo/training-cards.wdio-spec.js @@ -4,7 +4,6 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const trainingCardsPage = require('@page-objects/default/enketo/training-cards.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); const userFactory = require('@factories/cht/users/users'); -const personFactory = require('@factories/cht/contacts/person'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const privacyPolicyFactory = require('@factories/cht/settings/privacy-policy'); const privacyPage = require('@page-objects/default/privacy-policy/privacy-policy.wdio.page'); @@ -26,7 +25,6 @@ describe('Training Cards', () => { before(async () => { const parent = placeFactory.place().build({ _id: 'dist1', type: 'district_hospital' }); const user = userFactory.build({ roles: [ 'nurse', 'chw' ] }); - const patient = personFactory.build({ parent: { _id: user.place._id, parent: { _id: parent._id } } }); const formDoc = commonPage.createFormDoc(`${__dirname}/forms/training-cards-text-only`); formDoc._id = `form:${formDocId}`; formDoc.internalId = formDocId; @@ -36,8 +34,7 @@ describe('Training Cards', () => { duration: 5, }; - await utils.saveDocs([ parent, patient ]); - await utils.saveDoc(formDoc); + await utils.saveDocs([ parent, formDoc ]); await utils.createUsers([ user ]); await loginPage.login(user); await commonPage.waitForPageLoaded(); diff --git a/tests/e2e/default/service-worker/service-worker.wdio-spec.js b/tests/e2e/default/service-worker/service-worker.wdio-spec.js index 3fe432351a0..4c1cf4c3302 100644 --- a/tests/e2e/default/service-worker/service-worker.wdio-spec.js +++ b/tests/e2e/default/service-worker/service-worker.wdio-spec.js @@ -120,6 +120,7 @@ describe('Service worker cache', () => { '/img/icon-pregnant-selected.svg', '/img/icon-pregnant.svg', '/img/icon-filter.svg', + '/img/icon-check.svg', '/img/icon.png', '/img/icon-back.svg', '/img/layers.png', diff --git a/tests/e2e/default/suites.js b/tests/e2e/default/suites.js index 7514c73f9b1..d0254acd85b 100644 --- a/tests/e2e/default/suites.js +++ b/tests/e2e/default/suites.js @@ -6,6 +6,7 @@ const SUITES = { './more-options-menu/**/*.wdio-spec.js', './users/**/*.wdio-spec.js', './about/**/*.wdio-spec.js', + './training-materials/**/*.wdio-spec.js', './navigation/**/*.wdio-spec.js', './old-navigation/**/*.wdio-spec.js', './privacy-policy/**/*.wdio-spec.js', diff --git a/tests/e2e/default/training-materials/forms/expired-training.xml b/tests/e2e/default/training-materials/forms/expired-training.xml new file mode 100644 index 00000000000..9461ad67964 --- /dev/null +++ b/tests/e2e/default/training-materials/forms/expired-training.xml @@ -0,0 +1,95 @@ + + + + First Training + + + + + **Old feature** + + + **New feature** + + + The "New Action" icon at the bottom of your app has also changed. + + + If you do not understand these changes, please contact your supervisor. + + + When you're ready, go ahead and start using your app. + + + There have been some changes to icons in your app. The next few screens will show you the difference. + + + Read each screen carefully and tap "Next" if you understand. If you need extra support, please contact your supervisor. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/training-materials/forms/first-training.xml b/tests/e2e/default/training-materials/forms/first-training.xml new file mode 100644 index 00000000000..3f50b1d7c07 --- /dev/null +++ b/tests/e2e/default/training-materials/forms/first-training.xml @@ -0,0 +1,95 @@ + + + + First Training + + + + + **Old feature** + + + **New feature** + + + The "New Action" icon at the bottom of your app has also changed. + + + If you do not understand these changes, please contact your supervisor. + + + When you're ready, go ahead and start using your app. + + + There have been some changes to icons in your app. The next few screens will show you the difference. + + + Read each screen carefully and tap "Next" if you understand. If you need extra support, please contact your supervisor. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/training-materials/forms/second-training.xml b/tests/e2e/default/training-materials/forms/second-training.xml new file mode 100644 index 00000000000..9bebba6a873 --- /dev/null +++ b/tests/e2e/default/training-materials/forms/second-training.xml @@ -0,0 +1,95 @@ + + + + Second Training + + + + + **Old icon** + + + **New icon** + + + The "New Action" icon at the bottom of your app has also changed. + + + If you do not understand these changes, please contact your supervisor. + + + When you're ready, go ahead and start using your app. + + + The next few screens will show you the difference. + + + Read each screen carefully and tap "Next" if you understand. If you need extra support, please contact your supervisor. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/default/training-materials/training-materials.wdio-spec.js b/tests/e2e/default/training-materials/training-materials.wdio-spec.js new file mode 100644 index 00000000000..26490843d3e --- /dev/null +++ b/tests/e2e/default/training-materials/training-materials.wdio-spec.js @@ -0,0 +1,166 @@ +const fs = require('fs'); +const utils = require('@utils'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const trainingCardsPage = require('@page-objects/default/enketo/training-cards.wdio.page'); +const placeFactory = require('@factories/cht/contacts/place'); +const userFactory = require('@factories/cht/users/users'); +const commonElements = require('@page-objects/default/common/common.wdio.page'); +const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); + +describe('Training Materials Page', () => { + const CONFIRM_TITLE = 'Leave training?'; + const CONFIRM_CONTENT = 'This training is not finished. ' + + 'If you leave now, you will lose your progress and be prompted again later to complete it.'; + const FORMS_FOLDER = `${__dirname}/../../../e2e/default/training-materials/forms`; + const FIRST_TRAINING_NAME = 'first_training'; + const FIRST_TRAINING_ID = `training:${FIRST_TRAINING_NAME}`; + const SECOND_TRAINING_NAME = 'second_training'; + const SECOND_TRAINING_ID = `training:${SECOND_TRAINING_NAME}`; + + before(async () => { + const facility = placeFactory.place().build({ _id: 'dist1', type: 'district_hospital' }); + const user = userFactory.build({ roles: [ 'pharmacist', 'chw' ] }); + + const firstXML = fs.readFileSync(`${FORMS_FOLDER}/first-training.xml`, 'utf8'); + const firstTraining = { + _id: `form:${FIRST_TRAINING_ID}`, + internalId: FIRST_TRAINING_ID, + title: FIRST_TRAINING_NAME, + type: 'form', + context: { start_date: new Date().getTime(), user_roles: [ 'pharmacist' ], duration: 5 }, + _attachments: { + xml: { content_type: 'application/octet-stream', data: Buffer.from(firstXML).toString('base64') }, + }, + }; + + const secondXML = fs.readFileSync(`${FORMS_FOLDER}/second-training.xml`, 'utf8'); + const secondTraining = { + _id: `form:${SECOND_TRAINING_ID}`, + internalId: SECOND_TRAINING_ID, + title: SECOND_TRAINING_NAME, + type: 'form', + context: { start_date: new Date().getTime(), user_roles: [ 'pharmacist' ], duration: 5 }, + _attachments: { + xml: { content_type: 'application/octet-stream', data: Buffer.from(secondXML).toString('base64') }, + }, + }; + + const expiredTrainingXML = fs.readFileSync(`${FORMS_FOLDER}/expired-training.xml`, 'utf8'); + const expiredTraining = { + _id: 'form:training:expired_training', + internalId: 'training:expired_training', + title: 'expired_training', + type: 'form', + context: { start_date: '2023-12-8', user_roles: [ 'pharmacist' ], duration: 50 }, + _attachments: { + xml: { content_type: 'application/octet-stream', data: Buffer.from(expiredTrainingXML).toString('base64') }, + }, + }; + + await utils.saveDocs([ facility, firstTraining, expiredTraining, secondTraining ]); + await utils.createUsers([ user ]); + await loginPage.login(user); + await commonElements.waitForPageLoaded(); + }); + + it('should quit training in modal, and be able to complete it later in the Training Material page,' + + ' verify completed trainings display in the list', async () => { + await trainingCardsPage.waitForTrainingCards(); + const trainingModalTitle = await trainingCardsPage.getTrainingTitle(); + expect(trainingModalTitle).to.equal(FIRST_TRAINING_NAME); + + const confirmMessage = await trainingCardsPage.quitTraining(); + expect(confirmMessage.header).to.equal(CONFIRM_TITLE); + expect(confirmMessage.body).to.contain(CONFIRM_CONTENT); + await trainingCardsPage.confirmQuitTraining(); + await trainingCardsPage.checkTrainingCardIsNotDisplayed(); + + await commonPage.openHamburgerMenu(); + await commonPage.openTrainingMaterials(); + + const trainings = await trainingCardsPage.getAllTrainingsText(); + expect(trainings.length).to.equal(2); + expect(trainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.false; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + + await trainingCardsPage.openTrainingMaterial(FIRST_TRAINING_ID); + const trainingMaterialTitle = await trainingCardsPage.getTrainingTitle(); + expect(trainingMaterialTitle).to.equal(FIRST_TRAINING_NAME); + + const introCard = await trainingCardsPage.getCardContent(FIRST_TRAINING_NAME, 'intro/intro_note_1:label"]'); + expect(introCard).to.equal( + 'There have been some changes to icons in your app. The next few screens will show you the difference.' + ); + const nextCard = await trainingCardsPage.getNextCardContent( + FIRST_TRAINING_NAME, + 'action_icons/action_icons_note_1:label"]', + ); + expect(nextCard).to.equal('The "New Action" icon at the bottom of your app has also changed.'); + const lastCard = await trainingCardsPage.getNextCardContent(FIRST_TRAINING_NAME, 'ending/ending_note_1:label"]'); + expect(lastCard).to.equal('If you do not understand these changes, please contact your supervisor.'); + await trainingCardsPage.submitTraining(false); + + const allTrainings = await trainingCardsPage.getAllTrainingsText(); + expect(allTrainings.length).to.equal(2); + expect(allTrainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.true; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + + await commonPage.goToReports(); + const firstReport = await reportsPage.getListReportInfo(await reportsPage.leftPanelSelectors.firstReport()); + expect(firstReport.form).to.equal(FIRST_TRAINING_ID); + }); + + it('should revisit completed trainings and load uncompleted trainings', async () => { + await commonPage.openHamburgerMenu(); + await commonPage.openTrainingMaterials(); + + const trainings = await trainingCardsPage.getAllTrainingsText(); + expect(trainings.length).to.equal(2); + expect(trainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.true; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + + await trainingCardsPage.openTrainingMaterial(FIRST_TRAINING_ID); + const trainingMaterialTitle = await trainingCardsPage.getTrainingTitle(); + expect(trainingMaterialTitle).to.equal(FIRST_TRAINING_NAME); + + const introCard = await trainingCardsPage.getCardContent(FIRST_TRAINING_NAME, 'intro/intro_note_1:label"]'); + expect(introCard).to.equal( + 'There have been some changes to icons in your app. The next few screens will show you the difference.' + ); + const nextCard = await trainingCardsPage.getNextCardContent( + FIRST_TRAINING_NAME, + 'action_icons/action_icons_note_1:label"]', + ); + expect(nextCard).to.equal('The "New Action" icon at the bottom of your app has also changed.'); + + const confirmMessage = await trainingCardsPage.quitTraining(); + expect(confirmMessage.header).to.equal(CONFIRM_TITLE); + expect(confirmMessage.body).to.contain(CONFIRM_CONTENT); + await trainingCardsPage.confirmQuitTraining(); + await trainingCardsPage.checkTrainingCardIsNotDisplayed(); + + await trainingCardsPage.openTrainingMaterial(SECOND_TRAINING_ID); + const secondTrainingTitle = await trainingCardsPage.getTrainingTitle(); + expect(secondTrainingTitle).to.equal(SECOND_TRAINING_NAME); + const secondIntroCard = await trainingCardsPage.getCardContent(SECOND_TRAINING_NAME, 'intro/intro_note_1:label"]'); + expect(secondIntroCard).to.equal( + 'The next few screens will show you the difference.' + ); + + const secondConfirmMessage = await trainingCardsPage.quitTraining(); + expect(secondConfirmMessage.header).to.equal(CONFIRM_TITLE); + expect(secondConfirmMessage.body).to.contain(CONFIRM_CONTENT); + await trainingCardsPage.confirmQuitTraining(); + await trainingCardsPage.checkTrainingCardIsNotDisplayed(); + + const allTrainings = await trainingCardsPage.getAllTrainingsText(); + expect(allTrainings.length).to.equal(2); + expect(allTrainings).to.have.members([ FIRST_TRAINING_NAME, SECOND_TRAINING_NAME ]); + expect(await trainingCardsPage.isTrainingComplete(FIRST_TRAINING_ID)).to.be.true; + expect(await trainingCardsPage.isTrainingComplete(SECOND_TRAINING_ID)).to.be.false; + }); +}); diff --git a/tests/page-objects/default/common/common.wdio.page.js b/tests/page-objects/default/common/common.wdio.page.js index 4c196d6f2f1..14579e7724f 100644 --- a/tests/page-objects/default/common/common.wdio.page.js +++ b/tests/page-objects/default/common/common.wdio.page.js @@ -20,6 +20,7 @@ const hamburgerMenuSelectors = { syncSuccess: () => $('aria/All reports synced'), syncInProgress: () => $('mat-sidenav-content').$('*="Currently syncing"'), aboutButton: () => $('aria/About'), + trainingMaterialsButton: () => $('aria/Training materials'), userSettingsButton: () => $('aria/User settings'), feedbackMenuOption: () => $('aria/Report bug'), logoutButton: () => $('aria/Log out'), @@ -436,6 +437,12 @@ const openUserSettingsAndFetchProperties = async () => { await (await userSettingsSelectors.editProfileButton()).waitForDisplayed(); }; +const openTrainingMaterials = async () => { + await (await hamburgerMenuSelectors.trainingMaterialsButton()).waitForClickable(); + await (await hamburgerMenuSelectors.trainingMaterialsButton()).click(); + await waitForPageLoaded(); +}; + const openEditProfile = async () => { await (await userSettingsSelectors.editProfileButton()).waitForClickable(); await (await userSettingsSelectors.editProfileButton()).click(); @@ -555,6 +562,7 @@ module.exports = { closeReportBug, openAboutMenu, openUserSettings, + openTrainingMaterials, openUserSettingsAndFetchProperties, openEditProfile, openAppManagement, diff --git a/tests/page-objects/default/enketo/training-cards.wdio.page.js b/tests/page-objects/default/enketo/training-cards.wdio.page.js index a88d0579cdb..8726e8e91f9 100644 --- a/tests/page-objects/default/enketo/training-cards.wdio.page.js +++ b/tests/page-objects/default/enketo/training-cards.wdio.page.js @@ -1,11 +1,18 @@ const genericFormPage = require('./generic-form.wdio.page'); const modalPage = require('@page-objects/default/common/modal.wdio.page'); +const commonElements = require('@page-objects/default/common/common.wdio.page'); -const ENKETO_MODAL = '.enketo-modal'; - -const trainingCardsForm = () => $(ENKETO_MODAL); +const trainingCardsForm = () => $('#training-cards-form'); const cardText = (context, field) => $(`.question-label[lang="en"][data-itext-id="/${context}/${field}`); -const quitTrainingBtn = () => $(`${ENKETO_MODAL} .item-content button[test-id="quit-training"]`); +const quitTrainingBtn = () => $('.item-content button[test-id="quit-training"]'); + +const TRAINING_LIST_ID = '#trainings-list'; +const ALL_TRAININGS = `${TRAINING_LIST_ID} li.content-row`; +const leftPanelSelectors = { + allTrainings: () => $$(ALL_TRAININGS), + trainingRowsText: () => $$(`${ALL_TRAININGS} .heading h4 span`), + trainingByUUID: (uuid) => $(`${TRAINING_LIST_ID} li.content-row[data-record-id="${uuid}"]`), +}; const waitForTrainingCards = async () => { await (await trainingCardsForm()).waitForDisplayed(); @@ -15,6 +22,12 @@ const checkTrainingCardIsNotDisplayed = async () => { await (await trainingCardsForm()).waitForDisplayed({ reverse: true }); }; +const getTrainingTitle = async () => { + const title = await $('#form-title'); + await title.waitForDisplayed(); + return title.getText(); +}; + const getCardContent = async (context, field) => { return await (await cardText(context, field)).getText(); }; @@ -35,17 +48,37 @@ const confirmQuitTraining = async () => { await modalPage.checkModalHasClosed(); }; -const submitTraining = async () => { +const submitTraining = async (checkModal = true) => { await genericFormPage.submitForm(); - await modalPage.checkModalHasClosed(); + if (checkModal) { + await modalPage.checkModalHasClosed(); + } +}; + +const openTrainingMaterial = async (formID) => { + await (await leftPanelSelectors.trainingByUUID(formID)).waitForClickable(); + await (await leftPanelSelectors.trainingByUUID(formID)).click(); +}; + +const getAllTrainingsText = async () => { + await (await leftPanelSelectors.allTrainings()[0]).waitForDisplayed(); + return commonElements.getTextForElements(leftPanelSelectors.trainingRowsText); +}; + +const isTrainingComplete = async (formId) => { + return (await $(`[data-record-id="${formId}"] .mat-icon-check`)).isExisting(); }; module.exports = { checkTrainingCardIsNotDisplayed, - waitForTrainingCards, + confirmQuitTraining, + getAllTrainingsText, getCardContent, getNextCardContent, + getTrainingTitle, + isTrainingComplete, + openTrainingMaterial, quitTraining, - confirmQuitTraining, submitTraining, + waitForTrainingCards, }; diff --git a/webapp/src/css/content-list.less b/webapp/src/css/content-list.less index 9df79d09088..9401940fdcf 100644 --- a/webapp/src/css/content-list.less +++ b/webapp/src/css/content-list.less @@ -217,6 +217,21 @@ } } +.trainings .content-row { + &.selected, &.selected-to-view { + background-color: @training-highlight; + } + + &:hover { + border-left-color: @training-color; + } + + &.completed .mat-icon-check { + padding-top: 6px; + color: @completed-state-color; + } +} + .contacts .content-row { &.selected { background-color: @contacts-highlight; diff --git a/webapp/src/css/inbox.less b/webapp/src/css/inbox.less index 71a71b4af99..8e324ff468a 100644 --- a/webapp/src/css/inbox.less +++ b/webapp/src/css/inbox.less @@ -53,18 +53,20 @@ body { } &.about, + &.trainings, &.testing, &.user, &.privacy-policy { .tool-bar { background-color: @top-header-color; - .ellipsis-title { + .ellipsis-title, + .app-menu-button .mat-icon[fonticon="fa-bars"]:before { color: @text-inverse-color; } - .app-menu-button .mat-icon[fonticon="fa-bars"]:before { - color: @text-inverse-color; + .navigation .mat-icon-back path { + fill: @text-inverse-color; } } @@ -479,7 +481,10 @@ mm-analytics-filters { background-color: @background-color; } -.reports .inbox-items, .contacts .inbox-items, .messages .inbox-items { +.reports .inbox-items, +.contacts .inbox-items, +.messages .inbox-items, +.trainings .inbox-items { overflow-y: hidden; overflow-x: hidden; height: 100%; @@ -846,6 +851,7 @@ a.fa:hover { .reports, .messages, +.trainings, .tasks { .item-content { .body { @@ -1376,6 +1382,11 @@ mm-sidebar-menu .mat-sidenav-container { margin-left: 10px; flex-shrink: 0; } + + mat-icon.fa-graduation-cap:before { + font-size: @font-medium; + vertical-align: middle; + } } .nav-item:not(:not(.hidden) ~ .nav-item) { // The first element without .hidden class, compatible with Chrome +90 @@ -1432,6 +1443,7 @@ mm-sidebar-menu .mat-sidenav-container { &.messages, &.tasks, &.reports, + &.trainings, &.contacts { .loading-status { margin: 10px 10px calc(@tab-navbar-size + 10px); @@ -1667,6 +1679,7 @@ mm-sidebar-menu .mat-sidenav-container { } } .reports, + .trainings, .tasks { .content .item-content .body > div > ul > li { padding: 10px; diff --git a/webapp/src/css/modal.less b/webapp/src/css/modal.less index 5e2dc806890..80e70b07d6f 100644 --- a/webapp/src/css/modal.less +++ b/webapp/src/css/modal.less @@ -36,6 +36,7 @@ mm-modal-layout { .modal-footer { padding: 24px 0; + margin-bottom: 0; } } @@ -55,7 +56,8 @@ mm-modal-layout { text-align: left; } - .enketo-modal { + .enketo-modal, + .modal-body training-cards-confirm { &.content-pane { position: relative; } @@ -63,15 +65,13 @@ mm-modal-layout { .item-content { padding: 0; background: none; + & > .body { + margin: 0; + } } .modal-body { overflow: hidden; - margin-bottom: 11px; - } - - .modal-footer { - padding: 0; } .enketo { @@ -100,18 +100,31 @@ mm-modal-layout { } } - form { - max-height: 65vh; + form.pages [role=page].current { + margin: 0; + display: block; + max-height: @training-cards-page-height; overflow-y: auto; + padding-bottom: @training-cards-footer-height; } } - .form-no-title #form-title { - display: none; + #form-title { + font-size: @font-XXL; + font-weight: normal; + letter-spacing: normal; + border: none; + background: @form-background-color; + width: 100%; } .form-footer { - padding: 24px 0; + position: absolute; + bottom: 0; + margin: 0; + background: @form-background-color; + padding: 20px 0; + z-index: 20; .btn, .btn:active, .btn:focus { @@ -121,16 +134,24 @@ mm-modal-layout { } } } + + &:not(.has-error) .enketo-modal .modal-footer { + padding: 0; + } + + &.has-error .enketo-modal .modal-body { + padding-top: 40px; + } + + .enketo-modal training-cards-confirm .modal-footer { + margin-bottom: 20px; + } } @media (max-width: @media-mobile) { mm-modal-layout { .enketo-modal { .enketo { - form { - max-height: 60vh; - } - .form-footer { .btn.btn-link.cancel { display: inline-block; @@ -142,13 +163,9 @@ mm-modal-layout { } } - // The form title is hidden by default for mobile devices, but in training cards, the title is optional. - :not(.form-no-title) #form-title { - display: inherit; - } - - .form-no-title #form-title { - display: none; + // The form's title is hidden by default for mobile devices; but in training cards, the title is displayed. + #form-title { + display: inline-block; } } } diff --git a/webapp/src/css/old-nav.less b/webapp/src/css/old-nav.less index c2d2dae7864..87c928d0f30 100644 --- a/webapp/src/css/old-nav.less +++ b/webapp/src/css/old-nav.less @@ -8,6 +8,18 @@ padding-top: 0; } + &.trainings { + .content .inner .page { + padding-top: 0; + padding-left: 0; + top: @top-without-filter; + } + + .tool-bar .inner { + background-color: @top-header-color; + } + } + .app-menu-button { display: none; } @@ -435,6 +447,10 @@ .targets .target:last-child { margin-bottom: 10px; } + + &.trainings.show-content .content .inner .page { + top: @toolbar-mobile-height; + } } #target-aggregates-list { diff --git a/webapp/src/css/variables.less b/webapp/src/css/variables.less index 3be3de65763..7453356dc76 100644 --- a/webapp/src/css/variables.less +++ b/webapp/src/css/variables.less @@ -68,6 +68,8 @@ @contacts-highlight: @pink-highlight; @reports-color: @yellow; @reports-highlight: @yellow-highlight; +@training-color: @gray-medium; +@training-highlight: @gray-ultra-lighter; @analytics-color: @teal; @analytics-highlight: @teal-highlight; @admin-color: @gray-dark; @@ -78,6 +80,7 @@ @pending-state-color: @yellow; @success-state-color: @teal-dark; @sent-state-color: @teal-dark; +@completed-state-color: @teal-dark; @muted-state-color: @gray-medium; @failed-state-color: @red; @cleared-state-color: @gray-medium; @@ -122,6 +125,8 @@ @tab-navbar-size: 80px; @more-options-icon-size-mobile: 32px; @more-options-icon-size-desktop: 24px; +@training-cards-footer-height: 95px; +@training-cards-page-height: 54vh; /* fonts */ @font-family-main: Noto, sans-serif; diff --git a/webapp/src/img/icon-check.svg b/webapp/src/img/icon-check.svg new file mode 100644 index 00000000000..cd4d2dece43 --- /dev/null +++ b/webapp/src/img/icon-check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/webapp/src/ts/actions/global.ts b/webapp/src/ts/actions/global.ts index c058d2e395f..0e1472723e5 100644 --- a/webapp/src/ts/actions/global.ts +++ b/webapp/src/ts/actions/global.ts @@ -11,6 +11,7 @@ export const Actions = { setLoadingContent: createSingleValueAction('SET_LOADING_CONTENT', 'loadingContent'), setShowContent: createSingleValueAction('SET_SHOW_CONTENT', 'showContent'), setForms: createSingleValueAction('SET_FORMS', 'forms'), + setTrainingMaterials: createSingleValueAction('SET_TRAINING_MATERIALS', 'trainingMaterials'), clearFilters: createSingleValueAction('CLEAR_FILTERS', 'skip'), setFilter: createSingleValueAction('SET_FILTER', 'filter'), setSidebarFilter: createSingleValueAction('SET_SIDEBAR_FILTER', 'sidebarFilter'), @@ -38,6 +39,7 @@ export const Actions = { openSidebarMenu: createAction('OPEN_SIDEBAR_MENU'), setSearchBar: createSingleValueAction('SET_SEARCH_BAR', 'searchBar'), setTrainingCard: createSingleValueAction('SET_TRAINING_CARD', 'trainingCard'), + clearTrainingCards: createAction('CLEAR_TRAINING_CARDS'), }; export class GlobalActions { @@ -71,6 +73,10 @@ export class GlobalActions { return this.store.dispatch(Actions.setForms(forms)); } + setTrainingMaterials(trainingMaterials) { + return this.store.dispatch(Actions.setTrainingMaterials(trainingMaterials)); + } + setShowContent(showContent) { return this.store.dispatch(Actions.setShowContent(showContent)); } @@ -100,6 +106,15 @@ export class GlobalActions { return this.store.dispatch(Actions.setTrainingCard(trainingCard)); } + clearTrainingCards() { + this.setTrainingCard({ + formId: null, + isOpen: false, + showConfirmExit: false, + nextUrl: null, + }); + } + clearSidebarFilter() { return this.store.dispatch(Actions.clearSidebarFilter()); } diff --git a/webapp/src/ts/app-routing.module.ts b/webapp/src/ts/app-routing.module.ts index 3ecbaafc0cd..9ba91864672 100644 --- a/webapp/src/ts/app-routing.module.ts +++ b/webapp/src/ts/app-routing.module.ts @@ -11,6 +11,7 @@ import { routes as messagesRoutes } from '@mm-modules/messages/messages.routes'; import { routes as contactsRoutes } from '@mm-modules/contacts/contacts.routes'; import { routes as privacyPolicyRoutes } from '@mm-modules/privacy-policy/privacy-policy.routes'; import { routes as tasksRoutes } from '@mm-modules/tasks/tasks.routes'; +import { routes as trainingRoutes } from '@mm-modules/trainings/trainings.routes'; import { routes as testingRoutes } from '@mm-modules/testing/testing.routes'; const routes: Routes = [ @@ -23,6 +24,7 @@ const routes: Routes = [ ...contactsRoutes, ...privacyPolicyRoutes, ...tasksRoutes, + ...trainingRoutes, ...testingRoutes, ...errorRoutes, ]; diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index 6ee6738633a..a0079893d52 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -83,7 +83,6 @@ export class AppComponent implements OnInit, AfterViewInit { private analyticsActions: AnalyticsActions; setupPromise; translationsLoaded; - currentTab = ''; privacyPolicyAccepted; isSidebarFilterOpen = false; @@ -94,14 +93,13 @@ export class AppComponent implements OnInit, AfterViewInit { canLogOut; replicationStatus; androidAppVersion; - unreadCount = {}; hasOldNav = false; initialisationComplete = false; - trainingCardFormId = ''; private readonly SVG_ICONS = new Map([ ['icon-close', './img/icon-close.svg'], ['icon-filter', './img/icon-filter.svg'], ['icon-back', './img/icon-back.svg'], + ['icon-check', './img/icon-check.svg'], ]); constructor ( @@ -482,16 +480,9 @@ export class AppComponent implements OnInit, AfterViewInit { combineLatest([ this.store.select(Selectors.getPrivacyPolicyAccepted), this.store.select(Selectors.getShowPrivacyPolicy), - this.store.select(Selectors.getTrainingCardFormId), - ]).subscribe(([ - privacyPolicyAccepted, - showPrivacyPolicy, - trainingCardFormId, - ]) => { + ]).subscribe(([ privacyPolicyAccepted, showPrivacyPolicy ]) => { this.showPrivacyPolicy = showPrivacyPolicy; this.privacyPolicyAccepted = privacyPolicyAccepted; - this.trainingCardFormId = trainingCardFormId || ''; - this.displayTrainingCards(); }); combineLatest([ @@ -502,14 +493,6 @@ export class AppComponent implements OnInit, AfterViewInit { }); } - private displayTrainingCards() { - if (!this.trainingCardFormId || (this.showPrivacyPolicy && !this.privacyPolicyAccepted)) { - return; - } - - this.trainingCardsService.displayTrainingCards(); - } - private async subscribeToSideFilterStore() { this.store .select(Selectors.getSidebarFilter) diff --git a/webapp/src/ts/components/components.module.ts b/webapp/src/ts/components/components.module.ts index f983ff0dd06..0c930b97e08 100644 --- a/webapp/src/ts/components/components.module.ts +++ b/webapp/src/ts/components/components.module.ts @@ -46,6 +46,7 @@ import { ModalLayoutComponent } from '@mm-components/modal-layout/modal-layout.c import { PanelHeaderComponent } from '@mm-components/panel-header/panel-header.component'; import { SidebarMenuComponent } from '@mm-components/sidebar-menu/sidebar-menu.component'; import { ToolBarComponent } from '@mm-components/tool-bar/tool-bar.component'; +import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component'; @NgModule({ declarations: [ @@ -75,6 +76,7 @@ import { ToolBarComponent } from '@mm-components/tool-bar/tool-bar.component'; ModalLayoutComponent, PanelHeaderComponent, SidebarMenuComponent, + TrainingCardsFormComponent, ToolBarComponent, ], imports: [ @@ -118,6 +120,7 @@ import { ToolBarComponent } from '@mm-components/tool-bar/tool-bar.component'; ModalLayoutComponent, PanelHeaderComponent, SidebarMenuComponent, + TrainingCardsFormComponent, ToolBarComponent, ] }) diff --git a/webapp/src/ts/components/header/header.component.html b/webapp/src/ts/components/header/header.component.html index be5bd14f2f2..cd347ff4880 100644 --- a/webapp/src/ts/components/header/header.component.html +++ b/webapp/src/ts/components/header/header.component.html @@ -75,6 +75,12 @@ +
  • + + + {{'training_materials.page.title' | translate}} + +
  • diff --git a/webapp/src/ts/components/modal-layout/modal-layout.component.html b/webapp/src/ts/components/modal-layout/modal-layout.component.html index 105d9cc16bb..40e97d26b33 100644 --- a/webapp/src/ts/components/modal-layout/modal-layout.component.html +++ b/webapp/src/ts/components/modal-layout/modal-layout.component.html @@ -1,5 +1,5 @@