diff --git a/webapp/src/ts/modules/trainings/trainings-content.component.html b/webapp/src/ts/modules/trainings/trainings-content.component.html
new file mode 100644
index 00000000000..f9843168916
--- /dev/null
+++ b/webapp/src/ts/modules/trainings/trainings-content.component.html
@@ -0,0 +1,21 @@
+
+
+
{{ 'training_materials.page.no_selected' | translate }}
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/ts/modules/trainings/trainings-content.component.ts b/webapp/src/ts/modules/trainings/trainings-content.component.ts
new file mode 100644
index 00000000000..1fb5c052965
--- /dev/null
+++ b/webapp/src/ts/modules/trainings/trainings-content.component.ts
@@ -0,0 +1,112 @@
+import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Store } from '@ngrx/store';
+import { Subscription } from 'rxjs';
+
+import { GlobalActions } from '@mm-actions/global';
+import { ModalService } from '@mm-services/modal.service';
+import { PerformanceService } from '@mm-services/performance.service';
+import { Selectors } from '@mm-selectors/index';
+
+@Component({
+ selector: 'training-content',
+ templateUrl: './trainings-content.component.html'
+})
+export class TrainingsContentComponent implements OnInit, OnDestroy {
+ @ViewChild('confirmModal') confirmModalTemplate;
+ private readonly globalActions: GlobalActions;
+ private confirmModalRef;
+ private trackRender;
+ private trainingCardID;
+ private canExit = false;
+ showNoSelection = false;
+ showConfirmExit = false;
+ hasError = false;
+ subscriptions: Subscription = new Subscription();
+
+ constructor(
+ private readonly store: Store,
+ private readonly route: ActivatedRoute,
+ private readonly router: Router,
+ private readonly modalService: ModalService,
+ private readonly performanceService: PerformanceService,
+ ) {
+ this.globalActions = new GlobalActions(this.store);
+ }
+
+ ngOnInit() {
+ this.trackRender = this.performanceService.track();
+ this.subscribeToRouteParams();
+ this.subscribeToStore();
+ }
+
+ ngOnDestroy() {
+ this.subscriptions.unsubscribe();
+ this.globalActions.clearNavigation();
+ this.globalActions.clearTrainingCards();
+ }
+
+ private subscribeToStore() {
+ const reduxSubscription = this.store
+ .select(Selectors.getTrainingCardFormId)
+ .subscribe(trainingCardID => this.trainingCardID = trainingCardID);
+ this.subscriptions.add(reduxSubscription);
+ }
+
+ private subscribeToRouteParams() {
+ const routeSubscription = this.route.params.subscribe(params => {
+ this.globalActions.setTrainingCard({ formId: params?.id || null });
+ if (!params?.id) {
+ this.showNoSelection = true;
+ this.globalActions.setShowContent(false);
+ }
+ });
+ this.subscriptions.add(routeSubscription);
+ }
+
+ close(nextUrl?) {
+ this.canExit = true;
+ this.globalActions.clearNavigation();
+ this.globalActions.clearTrainingCards();
+ if (nextUrl) {
+ return this.router.navigateByUrl(nextUrl);
+ }
+ return this.router.navigate([ '/', 'trainings' ]);
+ }
+
+ exitTraining(nextUrl: string) {
+ this.trackRender?.stop({
+ name: [ 'enketo', this.trainingCardID, 'add', 'quit' ].join(':'),
+ });
+
+ this.close(nextUrl);
+ this.confirmModalRef?.close();
+ }
+
+ continueTraining() {
+ this.confirmModalRef?.close();
+ }
+
+ quit() {
+ if (this.hasError) {
+ return this.close();
+ }
+
+ this.showConfirmExit = true;
+ this.confirmModalRef = this.modalService.show(this.confirmModalTemplate);
+ const subscription = this.confirmModalRef
+ ?.afterClosed()
+ .subscribe(() => this.showConfirmExit = false);
+ this.subscriptions.add(subscription);
+ }
+
+ canDeactivate(nextUrl) {
+ if (this.canExit) {
+ this.canExit = false;
+ return true;
+ }
+ this.globalActions.setTrainingCard({ nextUrl });
+ this.quit();
+ return false;
+ }
+}
diff --git a/webapp/src/ts/modules/trainings/trainings-route.guard.provider.ts b/webapp/src/ts/modules/trainings/trainings-route.guard.provider.ts
new file mode 100644
index 00000000000..6fe39900d6b
--- /dev/null
+++ b/webapp/src/ts/modules/trainings/trainings-route.guard.provider.ts
@@ -0,0 +1,18 @@
+import { CanDeactivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
+import { Injectable } from '@angular/core';
+
+import { TrainingsContentComponent } from '@mm-modules/trainings/trainings-content.component';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class TrainingsRouteGuardProvider implements CanDeactivate
{
+ canDeactivate(
+ component:TrainingsContentComponent,
+ currentRoute:ActivatedRouteSnapshot,
+ currentState:RouterStateSnapshot,
+ nextState:RouterStateSnapshot,
+ ) {
+ return component.canDeactivate(nextState.url);
+ }
+}
diff --git a/webapp/src/ts/modules/trainings/trainings.component.html b/webapp/src/ts/modules/trainings/trainings.component.html
new file mode 100644
index 00000000000..95ff6bf484e
--- /dev/null
+++ b/webapp/src/ts/modules/trainings/trainings.component.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
{{ 'training_materials.page.no_trainings' | translate }}
+
+ {{ 'training_materials.page.no_more_trainings' | translate }}
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/ts/modules/trainings/trainings.component.ts b/webapp/src/ts/modules/trainings/trainings.component.ts
new file mode 100644
index 00000000000..802e3e0a486
--- /dev/null
+++ b/webapp/src/ts/modules/trainings/trainings.component.ts
@@ -0,0 +1,129 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { Subscription } from 'rxjs';
+
+import { GlobalActions } from '@mm-actions/global';
+import { PerformanceService } from '@mm-services/performance.service';
+import { TrainingCardsService, TrainingMaterial } from '@mm-services/training-cards.service';
+import { Selectors } from '@mm-selectors/index';
+import { ScrollLoaderProvider } from '@mm-providers/scroll-loader.provider';
+
+const PAGE_SIZE = 50;
+
+@Component({
+ templateUrl: './trainings.component.html'
+})
+export class TrainingsComponent implements OnInit, OnDestroy {
+ private readonly globalActions: GlobalActions;
+ private trackInitialLoadPerformance;
+ private isInitialized;
+ private trainingForms;
+ selectedTrainingId: null | string = null;
+ subscriptions: Subscription = new Subscription();
+ trainingList: TrainingMaterial[] = [];
+ moreTrainings = true;
+ loading = true;
+
+ constructor(
+ private readonly store: Store,
+ private readonly performanceService: PerformanceService,
+ private readonly trainingCardsService: TrainingCardsService,
+ private readonly scrollLoaderProvider: ScrollLoaderProvider,
+ ) {
+ this.globalActions = new GlobalActions(this.store);
+ }
+
+ ngOnInit() {
+ this.trackInitialLoadPerformance = this.performanceService.track();
+ this.subscribeToTrainingMaterials();
+ this.subscribeToSelectedTraining();
+ }
+
+ ngOnDestroy() {
+ this.subscriptions.unsubscribe();
+ this.globalActions.unsetSelected();
+ }
+
+ private subscribeToTrainingMaterials() {
+ const trainingSubscription = this.store
+ .select(Selectors.getTrainingMaterials)
+ .subscribe(forms => {
+ this.trainingList = [];
+ this.trainingForms = forms;
+ this.isInitialized = this.getTrainings();
+ });
+ this.subscriptions.add(trainingSubscription);
+ }
+
+ private subscribeToSelectedTraining() {
+ const selectedTraining = this.store
+ .select(Selectors.getTrainingCardFormId)
+ .subscribe(newSelectedId => this.isInitialized?.then(() => {
+ this.refreshList(this.selectedTrainingId, newSelectedId);
+ this.selectedTrainingId = newSelectedId;
+ }));
+ this.subscriptions.add(selectedTraining);
+ }
+
+ private refreshList(oldSelectedId, newSelectedId) {
+ const isTrainingClosing = oldSelectedId && !newSelectedId;
+ if (!isTrainingClosing) {
+ return;
+ }
+
+ const isUncompletedTraining = this.trainingList?.find(training => {
+ return training.code === oldSelectedId && !training.isCompletedTraining;
+ });
+
+ if (isUncompletedTraining) {
+ this.trainingList = [];
+ this.getTrainings();
+ }
+ }
+
+ async getTrainings() {
+ if (!this.trainingForms?.length) {
+ this.loading = false;
+ return;
+ }
+
+ try {
+ this.loading = true;
+ const list = await this.trainingCardsService.getNextTrainings(
+ this.trainingForms,
+ PAGE_SIZE,
+ this.trainingList.length,
+ ) ?? [];
+
+ this.moreTrainings = list.length >= PAGE_SIZE;
+ this.trainingList = [ ...this.trainingList, ...list ];
+
+ await this.recordInitialLoadPerformance();
+ this.initScroll();
+ } catch (error) {
+ console.error('Error getting training materials.', error);
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private async recordInitialLoadPerformance() {
+ if (!this.trackInitialLoadPerformance) {
+ return;
+ }
+ await this.trackInitialLoadPerformance.stop({ name: 'training_materials_list:load', recordApdex: true });
+ this.trackInitialLoadPerformance = null;
+ }
+
+ trackBy(index, training) {
+ return training._id + index + training._rev + training.selected;
+ }
+
+ private initScroll() {
+ this.scrollLoaderProvider.init(() => {
+ if (!this.loading && this.moreTrainings) {
+ this.getTrainings();
+ }
+ });
+ }
+}
diff --git a/webapp/src/ts/modules/trainings/trainings.routes.ts b/webapp/src/ts/modules/trainings/trainings.routes.ts
new file mode 100644
index 00000000000..54adf17b417
--- /dev/null
+++ b/webapp/src/ts/modules/trainings/trainings.routes.ts
@@ -0,0 +1,28 @@
+import { Routes } from '@angular/router';
+
+import { AppRouteGuardProvider } from '../../app-route.guard.provider';
+import { TrainingsComponent } from '@mm-modules/trainings/trainings.component';
+import { TrainingsContentComponent } from '@mm-modules/trainings/trainings-content.component';
+import { TrainingsRouteGuardProvider } from '@mm-modules/trainings/trainings-route.guard.provider';
+
+export const routes: Routes = [
+ {
+ path: 'trainings',
+ component: TrainingsComponent,
+ data: { permissions: [ 'can_edit' ], tab: 'trainings', hideTraining: true },
+ canActivate: [ AppRouteGuardProvider ],
+ children: [
+ {
+ path: '',
+ component: TrainingsContentComponent,
+ data: { hideTraining: true },
+ },
+ {
+ path: ':id',
+ component: TrainingsContentComponent,
+ data: { hideTraining: true },
+ canDeactivate: [ TrainingsRouteGuardProvider ],
+ },
+ ]
+ },
+];
diff --git a/webapp/src/ts/reducers/global.ts b/webapp/src/ts/reducers/global.ts
index e1c6f4c5651..c94c7124b9c 100644
--- a/webapp/src/ts/reducers/global.ts
+++ b/webapp/src/ts/reducers/global.ts
@@ -27,6 +27,7 @@ const initialState: GlobalState = {
trainingCard: { formId: null, isOpen: false, showConfirmExit: false, nextUrl: null },
sidebarMenu: { isOpen: false },
forms: null,
+ trainingMaterials: null,
lastChangedDoc: false,
loadingContent: false,
processingReportVerification: false,
@@ -77,6 +78,9 @@ const _globalReducer = createReducer(
on(Actions.setForms, (state, { payload: { forms } }) => {
return { ...state, forms };
}),
+ on(Actions.setTrainingMaterials, (state, { payload: { trainingMaterials } }) => {
+ return { ...state, trainingMaterials };
+ }),
on(Actions.clearFilters, (state, { payload: { skip } }) => {
const newValue = skip && state.filters[skip] ? { [skip]: state.filters[skip] } : {};
return { ...state, filters: newValue };
@@ -192,6 +196,7 @@ export interface GlobalState {
trainingCard: TrainingCardState;
sidebarMenu: SidebarMenuState;
forms: null | Record[];
+ trainingMaterials: null | Record[];
lastChangedDoc: boolean | Record;
loadingContent: boolean;
processingReportVerification: boolean;
diff --git a/webapp/src/ts/selectors/index.ts b/webapp/src/ts/selectors/index.ts
index 83b66d7658d..95c5fc52eb1 100644
--- a/webapp/src/ts/selectors/index.ts
+++ b/webapp/src/ts/selectors/index.ts
@@ -25,6 +25,7 @@ export const Selectors = {
getShowContent: createSelector(getGlobalState, (globalState) => globalState.showContent),
getSelectMode: createSelector(getGlobalState, (globalState) => globalState.selectMode),
getForms: createSelector(getGlobalState, (globalState) => globalState.forms),
+ getTrainingMaterials: createSelector(getGlobalState, (globalState) => globalState.trainingMaterials),
getFilters: createSelector(getGlobalState, (globalState) => globalState.filters),
getSidebarFilter: createSelector(getGlobalState, (globalState) => globalState.sidebarFilter),
getSearchBar: createSelector(getGlobalState, (globalState) => globalState.searchBar),
diff --git a/webapp/src/ts/services/training-cards.service.ts b/webapp/src/ts/services/training-cards.service.ts
index f3618d05d9d..b3b7d2346b1 100644
--- a/webapp/src/ts/services/training-cards.service.ts
+++ b/webapp/src/ts/services/training-cards.service.ts
@@ -3,6 +3,7 @@ import { Store } from '@ngrx/store';
import * as moment from 'moment';
import { v4 as uuid } from 'uuid';
import { first } from 'rxjs/operators';
+import { combineLatest } from 'rxjs';
import { XmlFormsService } from '@mm-services/xml-forms.service';
import { TrainingCardsComponent } from '@mm-modals/training-cards/training-cards.component';
@@ -11,6 +12,9 @@ import { GlobalActions } from '@mm-actions/global';
import { ModalService } from '@mm-services/modal.service';
import { SessionService } from '@mm-services/session.service';
import { RouteSnapshotService } from '@mm-services/route-snapshot.service';
+import { Selectors } from '@mm-selectors/index';
+import { TranslateService } from '@ngx-translate/core';
+import { TranslateFromService } from '@mm-services/translate-from.service';
export const TRAINING_PREFIX: string = 'training:';
@@ -22,22 +26,46 @@ export class TrainingCardsService {
private readonly STORAGE_KEY_LAST_VIEWED_DATE = 'training-cards-last-viewed-date';
constructor(
- private store: Store,
- private xmlFormsService: XmlFormsService,
- private dbService: DbService,
- private modalService: ModalService,
- private sessionService: SessionService,
- private routeSnapshotService: RouteSnapshotService,
+ private readonly store: Store,
+ private readonly xmlFormsService: XmlFormsService,
+ private readonly dbService: DbService,
+ private readonly modalService: ModalService,
+ private readonly sessionService: SessionService,
+ private readonly routeSnapshotService: RouteSnapshotService,
+ private readonly translateService: TranslateService,
+ private readonly translateFromService: TranslateFromService,
) {
this.globalActions = new GlobalActions(this.store);
+ this.subscribeToStore();
+ }
+
+ private subscribeToStore(): void {
+ combineLatest([
+ this.store.select(Selectors.getPrivacyPolicyAccepted),
+ this.store.select(Selectors.getShowPrivacyPolicy),
+ this.store.select(Selectors.getTrainingMaterials),
+ ]).subscribe(([ privacyPolicyAccepted, showPrivacyPolicy, xforms ]) => {
+ this.displayTrainingCards(xforms, showPrivacyPolicy, privacyPolicyAccepted);
+ });
+ }
+
+ private getFormTitle(labelKey?: string, label?: string): string|undefined {
+ if (labelKey) {
+ return this.translateService.instant(labelKey);
+ }
+
+ if (label) {
+ return this.translateFromService.get(label);
+ }
}
private getAvailableTrainingCards(xForms, userCtx) {
const today = moment();
return xForms
- .map(xForm => ({
+ ?.map(xForm => ({
id: xForm._id,
+ title: this.getFormTitle(xForm.titleKey, xForm.title),
code: xForm.internalId,
startDate: xForm.context?.start_date ? moment(xForm.context.start_date) : today.clone(),
duration: xForm.context?.duration,
@@ -80,47 +108,51 @@ export class TrainingCardsService {
return docs?.rows?.length ? new Set(docs.rows.map(row => row?.doc?.form)) : new Set();
}
- private async handleTrainingCards(error, xForms) {
- if (error) {
- console.error('Training Cards :: Error fetching forms.', error);
+ private openModal(form) {
+ this.globalActions.setTrainingCard({ formId: form?.code });
+
+ this.modalService
+ .show(TrainingCardsComponent, { closeOnNavigation: false, width: '700px' })
+ ?.afterOpened()
+ .pipe(first())
+ .subscribe(() => {
+ const key = this.getLocalStorageKey();
+ if (key) {
+ window.localStorage.setItem(key, new Date().toISOString());
+ }
+ });
+ }
+
+ private canDisplayTrainingCards(trainingForms, showPrivacyPolicy, privacyPolicyAccepted) {
+ if (!trainingForms?.length
+ || (showPrivacyPolicy && !privacyPolicyAccepted)
+ || this.hasBeenDisplayed()
+ ) {
+ return false;
+ }
+
+ const routeSnapshot = this.routeSnapshotService.get();
+ return !routeSnapshot?.data?.hideTraining;
+ }
+
+ async displayTrainingCards(trainingForms, showPrivacyPolicy, privacyPolicyAccepted) {
+ if (!this.canDisplayTrainingCards(trainingForms, showPrivacyPolicy, privacyPolicyAccepted)) {
return;
}
try {
- const firstChronologicalTrainingCard = await this.getFirstChronologicalForm(xForms);
+ const firstChronologicalTrainingCard = await this.getFirstChronologicalForm(trainingForms);
if (!firstChronologicalTrainingCard) {
return;
}
- this.globalActions.setTrainingCard({ formId: firstChronologicalTrainingCard.code });
+ this.openModal(firstChronologicalTrainingCard);
} catch (error) {
console.error('Training Cards :: Error showing modal.', error);
return;
}
}
- displayTrainingCards() {
- if (this.hasBeenDisplayed()) {
- return;
- }
-
- const routeSnapshot = this.routeSnapshotService.get();
- if (routeSnapshot?.data?.hideTraining) {
- return;
- }
-
- this.modalService
- .show(TrainingCardsComponent, { closeOnNavigation: false })
- ?.afterOpened()
- .pipe(first())
- .subscribe(() => {
- const key = this.getLocalStorageKey();
- if (key) {
- window.localStorage.setItem(key, new Date().toISOString());
- }
- });
- }
-
private async getFirstChronologicalForm(xForms) {
const userCtx = this.sessionService.userCtx();
const trainingCards = this.getAvailableTrainingCards(xForms, userCtx) || [];
@@ -139,7 +171,13 @@ export class TrainingCardsService {
this.xmlFormsService.subscribe(
'TrainingCards',
{ trainingCards: true },
- (error, xForms) => this.handleTrainingCards(error, xForms)
+ (error, xForms) => {
+ if (error) {
+ console.error('Training Cards :: Error fetching forms.', error);
+ return;
+ }
+ this.globalActions.setTrainingMaterials(xForms);
+ }
);
}
@@ -182,4 +220,28 @@ export class TrainingCardsService {
return `${this.STORAGE_KEY_LAST_VIEWED_DATE}-${username}`;
}
+
+ public async getNextTrainings(xForms, pageSize, skip): Promise {
+ const userCtx = this.sessionService.userCtx();
+ const availableTrainingCards = this.getAvailableTrainingCards(xForms, userCtx);
+ const trainingCards = availableTrainingCards?.slice(skip, skip + pageSize);
+ if (!trainingCards.length) {
+ return;
+ }
+
+ const completedTrainings = await this.getCompletedTrainings(userCtx);
+ return trainingCards
+ .map(form => ({ ...form, isCompletedTraining: completedTrainings.has(form.code) }))
+ .sort((a, b) => a.startDate.diff(b.startDate));
+ }
+}
+
+export interface TrainingMaterial {
+ id: string;
+ title: string;
+ code: string;
+ startDate: Date;
+ duration: number;
+ userRoles: string[];
+ isCompletedTraining: boolean;
}
diff --git a/webapp/src/ts/training-card.guard.provider.ts b/webapp/src/ts/training-card.guard.provider.ts
index 5c5f1ad2ad8..edd612acd5e 100644
--- a/webapp/src/ts/training-card.guard.provider.ts
+++ b/webapp/src/ts/training-card.guard.provider.ts
@@ -10,7 +10,7 @@ import { Selectors } from '@mm-selectors/index';
providedIn: 'root'
})
export class TrainingCardDeactivationGuardProvider implements CanDeactivate {
- private readonly globalActions;
+ private readonly globalActions: GlobalActions;
constructor(private readonly store: Store) {
this.globalActions = new GlobalActions(this.store);
diff --git a/webapp/tests/karma/ts/components/sidebar-menu/sidebar-menu.component.spec.ts b/webapp/tests/karma/ts/components/sidebar-menu/sidebar-menu.component.spec.ts
index 7951f75f9e1..ce7db203d4b 100644
--- a/webapp/tests/karma/ts/components/sidebar-menu/sidebar-menu.component.spec.ts
+++ b/webapp/tests/karma/ts/components/sidebar-menu/sidebar-menu.component.spec.ts
@@ -109,6 +109,12 @@ describe('SidebarMenuComponent', () => {
]);
expect(component.secondaryOptions).excluding('click').have.deep.members([
+ {
+ routerLink: 'trainings',
+ icon: 'fa-graduation-cap',
+ translationKey: 'training_materials.page.title',
+ canDisplay: true,
+ },
{
routerLink: 'about',
icon: 'fa-question',
diff --git a/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts b/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts
new file mode 100644
index 00000000000..fd0b192a47a
--- /dev/null
+++ b/webapp/tests/karma/ts/components/training-cards-form/training-cards-form.component.spec.ts
@@ -0,0 +1,351 @@
+import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { GeolocationService } from '@mm-services/geolocation.service';
+import { XmlFormsService } from '@mm-services/xml-forms.service';
+import { FormService } from '@mm-services/form.service';
+import { TranslateService } from '@mm-services/translate.service';
+import { Selectors } from '@mm-selectors/index';
+import { GlobalActions } from '@mm-actions/global';
+import { PerformanceService } from '@mm-services/performance.service';
+import { FeedbackService } from '@mm-services/feedback.service';
+import { ModalLayoutComponent } from '@mm-components/modal-layout/modal-layout.component';
+import { PanelHeaderComponent } from '@mm-components/panel-header/panel-header.component';
+import { EnketoComponent } from '@mm-components/enketo/enketo.component';
+import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component';
+
+describe('TrainingCardsFormComponent', () => {
+ let fixture: ComponentFixture;
+ let component: TrainingCardsFormComponent;
+ let store: MockStore;
+ let geolocationService;
+ let geoHandle;
+ let xmlFormsService;
+ let translateService;
+ let formService;
+ let globalActions;
+ let feedbackService;
+ let consoleErrorMock;
+ let performanceService;
+ let stopPerformanceTrackStub;
+
+ beforeEach(() => {
+ consoleErrorMock = sinon.stub(console, 'error');
+ geoHandle = { cancel: sinon.stub() };
+ geolocationService = { init: sinon.stub().returns(geoHandle) };
+ xmlFormsService = { get: sinon.stub().resolves() };
+ translateService = {
+ get: sinon.stub().resolvesArg(0),
+ instant: sinon.stub().returnsArg(0),
+ };
+ formService = {
+ unload: sinon.stub(),
+ save: sinon.stub(),
+ render: sinon.stub().resolves(),
+ };
+ feedbackService = { submit: sinon.stub() };
+ const mockedSelectors = [
+ { selector: Selectors.getEnketoStatus, value: {} },
+ { selector: Selectors.getEnketoSavingStatus, value: false },
+ { selector: Selectors.getEnketoError, value: false },
+ { selector: Selectors.getTrainingCardFormId, value: null },
+ { selector: Selectors.getTrainingCard, value: {} },
+ ];
+ globalActions = {
+ clearEnketoStatus: sinon.stub(GlobalActions.prototype, 'clearEnketoStatus'),
+ setEnketoSavingStatus: sinon.stub(GlobalActions.prototype, 'setEnketoSavingStatus'),
+ setEnketoError: sinon.stub(GlobalActions.prototype, 'setEnketoError'),
+ setSnackbarContent: sinon.stub(GlobalActions.prototype, 'setSnackbarContent'),
+ setTrainingCard: sinon.stub(GlobalActions.prototype, 'setTrainingCard'),
+ };
+ stopPerformanceTrackStub = sinon.stub();
+ performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) };
+
+ return TestBed
+ .configureTestingModule({
+ imports: [
+ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }),
+ ],
+ declarations: [
+ TrainingCardsFormComponent,
+ ModalLayoutComponent,
+ PanelHeaderComponent,
+ EnketoComponent,
+ ],
+ providers: [
+ provideMockStore({ selectors: mockedSelectors }),
+ { provide: GeolocationService, useValue: geolocationService },
+ { provide: XmlFormsService, useValue: xmlFormsService },
+ { provide: TranslateService, useValue: translateService },
+ { provide: FormService, useValue: formService },
+ { provide: PerformanceService, useValue: performanceService },
+ { provide: FeedbackService, useValue: feedbackService },
+ ],
+ })
+ .compileComponents()
+ .then(() => {
+ fixture = TestBed.createComponent(TrainingCardsFormComponent);
+ store = TestBed.inject(MockStore);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+ });
+
+ afterEach(() => sinon.restore());
+
+ it('should create component', () => {
+ expect(component).to.exist;
+ });
+
+ it('should unsubscribe from everything, cancel geohandle and clear enketo form', fakeAsync(() => {
+ const unsubscribeStub = sinon.stub(component.subscriptions, 'unsubscribe');
+
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+ sinon.resetHistory();
+ component.ngOnDestroy();
+ tick();
+
+ expect(unsubscribeStub.calledOnce).to.be.true;
+ expect(geoHandle.cancel.calledOnce).to.be.true;
+ expect(formService.unload.calledOnce).to.be.true;
+ expect(globalActions.clearEnketoStatus.calledOnce).to.be.true;
+ }));
+
+ describe('onInit', () => {
+ it('should subscribe to redux and init component', fakeAsync(() => {
+ const AddSpy = sinon.spy(component.subscriptions, 'add');
+ sinon.resetHistory();
+
+ component.ngOnInit();
+
+ expect(AddSpy.calledOnce).to.be.true;
+ expect(component.contentError).to.be.false;
+ expect(component.enketoError).to.be.false;
+ expect(component.trainingCardFormId).to.be.null;
+ expect(component.form).to.be.null;
+ expect(component.loadingContent).to.be.true;
+
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'form-123');
+ store.overrideSelector(
+ Selectors.getEnketoStatus,
+ { form: true, edited: false, saving: true, error: 'ups an error' },
+ );
+ store.overrideSelector(Selectors.getEnketoSavingStatus, true);
+ store.overrideSelector(Selectors.getEnketoError, 'ups an error');
+ store.refreshState();
+
+ flush();
+
+ expect(component.enketoError).to.equal('ups an error');
+ expect(component.trainingCardFormId).to.equal('form-123');
+ expect(component.enketoStatus).to.deep.equal({ form: true, edited: false, saving: true, error: 'ups an error' });
+ expect(component.enketoSaving).to.be.true;
+ }));
+
+ it('should reset component', () => {
+ component.contentError = true;
+ component.trainingCardFormId = 'training:a_form_id';
+ component.form = { the: 'rendered training form' };
+ component.loadingContent = false;
+ component.enketoError = 'some_error';
+ sinon.resetHistory();
+
+ component.ngOnInit();
+
+ expect(globalActions.setEnketoError.calledOnce).to.be.true;
+ expect(globalActions.setEnketoError.args[0]).to.deep.equal([ null ]);
+ expect(component.contentError).to.be.false;
+ expect(component.trainingCardFormId).to.be.null;
+ expect(component.form).to.be.null;
+ expect(component.loadingContent).to.be.true;
+ });
+ });
+
+ describe('saveForm', () => {
+ it('should do nothing if already saving', fakeAsync(() => {
+ const consoleDebugMock = sinon.stub(console, 'debug');
+ component.enketoSaving = true;
+
+ component.saveForm();
+ tick();
+
+ expect(consoleDebugMock.calledOnce).to.be.true;
+ expect(consoleDebugMock.args[0]).to.deep.equal([
+ 'Attempted to call TrainingCardsFormComponent:saveForm more than once'
+ ]);
+ expect(formService.save.notCalled).to.be.true;
+ expect(formService.unload.notCalled).to.be.true;
+ expect(globalActions.setEnketoSavingStatus.notCalled).to.be.true;
+ expect(globalActions.setSnackbarContent.notCalled).to.be.true;
+ expect(globalActions.setEnketoError.notCalled).to.be.true;
+ }));
+
+ it('should call enketo save, set content in snackbar and unload form', fakeAsync(() => {
+ const consoleDebugMock = sinon.stub(console, 'debug');
+ xmlFormsService.get.resolves({ _id: 'form:training:new_feature' });
+ formService.save.resolves([{ _id: 'completed_training' }]);
+ formService.render.resolves({
+ _id: 'form:training:new_feature',
+ pages: { activePages: [ { id: 'page-1' } ] },
+ });
+
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+ component.saveForm();
+ tick();
+
+ expect(formService.unload.calledOnce).to.be.true;
+ expect(formService.unload.args[0]).to.deep.equal([{
+ _id: 'form:training:new_feature',
+ pages: { activePages: [ { id: 'page-1' } ] },
+ }]);
+ expect(formService.save.calledOnce).to.be.true;
+ expect(formService.save.args[0]).to.deep.equal([
+ 'training:a_form_id',
+ {
+ _id: 'form:training:new_feature',
+ pages: { activePages: [ { id: 'page-1' } ] },
+ },
+ geoHandle
+ ]);
+ expect(consoleDebugMock.callCount).to.equal(1);
+ expect(consoleDebugMock.args[0]).to.deep.equal([
+ 'Saved form and associated docs',
+ [{ _id: 'completed_training' }]
+ ]);
+ expect(globalActions.setEnketoSavingStatus.calledTwice).to.be.true;
+ expect(globalActions.setEnketoSavingStatus.args).to.deep.equal([[ true ], [ false ]]);
+ expect(globalActions.setSnackbarContent.calledOnce).to.be.true;
+ expect(globalActions.setSnackbarContent.args[0]).to.deep.equal([ 'training_cards.form.saved' ]);
+ expect(stopPerformanceTrackStub.callCount).to.equal(3);
+ expect(stopPerformanceTrackStub.args[0][0])
+ .to.deep.equal({ name: 'enketo:training:a_form_id:add:render', recordApdex: true });
+ expect(stopPerformanceTrackStub.args[1][0])
+ .to.deep.equal({ name: 'enketo:training:a_form_id:add:user_edit_time' });
+ expect(stopPerformanceTrackStub.args[2][0]).to.deep.equal({ name: 'enketo:training:a_form_id:add:save' });
+ expect(globalActions.setEnketoError.notCalled).to.be.true;
+ }));
+
+ it('should catch enketo saving error', fakeAsync(() => {
+ sinon.resetHistory();
+ xmlFormsService.get.resolves({ the: 'rendered training form' });
+ formService.render.resolves({ the: 'rendered training form' });
+ formService.save.rejects({ some: 'error' });
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+
+ component.saveForm();
+ tick();
+
+ expect(formService.save.calledOnce).to.be.true;
+ expect(formService.save.args[0]).to.deep.equal([
+ 'training:a_form_id',
+ { the: 'rendered training form' },
+ geoHandle
+ ]);
+ expect(consoleErrorMock.calledOnce).to.be.true;
+ expect(consoleErrorMock.args[0]).to.deep.equal([
+ 'TrainingCardsFormComponent :: Error submitting form data.',
+ { some: 'error' }
+ ]);
+ expect(globalActions.setEnketoError.calledOnce).to.be.true;
+ expect(globalActions.setEnketoError.args[0]).to.deep.equal([ 'training_cards.error.save' ]);
+ expect(globalActions.setEnketoSavingStatus.calledTwice).to.be.true;
+ expect(globalActions.setEnketoSavingStatus.args).to.deep.equal([[ true ], [ false ]]);
+ expect(globalActions.setSnackbarContent.notCalled).to.be.true;
+ }));
+ });
+
+ describe('loadForm', () => {
+ it('should load form', fakeAsync(() => {
+ sinon.resetHistory();
+ const xmlForm = { _id: 'training:a_form_id', some: 'content' };
+ const renderedForm = { rendered: 'form', model: {}, instance: {} };
+ xmlFormsService.get.resolves(xmlForm);
+ formService.render.resolves(renderedForm);
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+
+ expect(geolocationService.init.calledOnce).to.be.true;
+ expect(xmlFormsService.get.calledOnce).to.be.true;
+ expect(xmlFormsService.get.args[0]).to.deep.equal([ 'training:a_form_id' ]);
+ expect(formService.render.calledOnce).to.be.true;
+ expect(formService.render.args[0][0].formDoc).to.deep.equal(xmlForm);
+ expect(component.form).to.equal(renderedForm);
+ expect(consoleErrorMock.notCalled).to.be.true;
+ expect(feedbackService.submit.notCalled).to.be.true;
+ expect(stopPerformanceTrackStub.calledOnce).to.be.true;
+ expect(stopPerformanceTrackStub.args[0][0])
+ .to.deep.equal({ name: 'enketo:training:a_form_id:add:render', recordApdex: true });
+
+ const resetFormError = formService.render.args[0][0].valuechangeListener;
+ resetFormError();
+ expect(globalActions.setEnketoError.notCalled).to.be.true; // No error so no call
+ component.enketoError = 'some error';
+ resetFormError();
+ expect(globalActions.setEnketoError.calledOnce).to.be.true;
+ expect(globalActions.setEnketoError.args[0]).to.deep.equal([ null ]);
+ }));
+
+ it('should reset geohandle on reload', fakeAsync(() => {
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+
+ expect(geoHandle.cancel.notCalled).to.be.true;
+ expect(geolocationService.init.calledOnce).to.be.true;
+
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:another_form_id');
+ store.refreshState();
+ tick();
+
+ expect(geolocationService.init.calledTwice).to.be.true;
+ expect(geoHandle.cancel.calledOnce).to.be.true;
+ }));
+
+ it('should catch form loading errors', fakeAsync(() => {
+ xmlFormsService.get.rejects({ error: 'boom' });
+ sinon.resetHistory();
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+
+ expect(xmlFormsService.get.calledOnce).to.be.true;
+ expect(formService.render.notCalled).to.be.true;
+ expect(consoleErrorMock.calledOnce).to.be.true;
+ expect(consoleErrorMock.args[0]).to.deep.equal([
+ 'TrainingCardsFormComponent :: Error fetching form.',
+ { error: 'boom' }
+ ]);
+ expect(component.errorTranslationKey).to.equal('training_cards.error.loading');
+ expect(component.loadingContent).to.be.false;
+ expect(component.contentError).to.be.true;
+ }));
+
+ it('should catch enketo errors', fakeAsync(() => {
+ xmlFormsService.get.resolves({ _id: 'training:a_form_id', some: 'content' });
+ formService.render.rejects({ some: 'error' });
+ sinon.resetHistory();
+ store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
+ store.refreshState();
+ tick();
+
+ expect(xmlFormsService.get.calledOnce).to.be.true;
+ expect(formService.render.calledOnce).to.be.true;
+ expect(component.form).to.equal(null);
+ expect(consoleErrorMock.calledOnce).to.be.true;
+ expect(consoleErrorMock.args[0]).to.deep.equal([
+ 'TrainingCardsFormComponent :: Error rendering form.',
+ { some: 'error' }
+ ]);
+ }));
+ });
+});
diff --git a/webapp/tests/karma/ts/modals/training-cards/training-cards.component.spec.ts b/webapp/tests/karma/ts/modals/training-cards/training-cards.component.spec.ts
index b00309fa782..794c80d9f2f 100644
--- a/webapp/tests/karma/ts/modals/training-cards/training-cards.component.spec.ts
+++ b/webapp/tests/karma/ts/modals/training-cards/training-cards.component.spec.ts
@@ -1,76 +1,46 @@
import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core';
-import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
-import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
+import { provideMockStore } from '@ngrx/store/testing';
import { Router } from '@angular/router';
-import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { MatDialogRef } from '@angular/material/dialog';
import { expect } from 'chai';
import sinon from 'sinon';
import { TrainingCardsComponent } from '@mm-modals/training-cards/training-cards.component';
-import { GeolocationService } from '@mm-services/geolocation.service';
-import { XmlFormsService } from '@mm-services/xml-forms.service';
-import { FormService } from '@mm-services/form.service';
-import { TranslateService } from '@mm-services/translate.service';
-import { Selectors } from '@mm-selectors/index';
import { GlobalActions } from '@mm-actions/global';
import { PerformanceService } from '@mm-services/performance.service';
-import { FeedbackService } from '@mm-services/feedback.service';
import { ModalLayoutComponent } from '@mm-components/modal-layout/modal-layout.component';
-import { PanelHeaderComponent } from '@mm-components/panel-header/panel-header.component';
+import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component';
+import { XmlFormsService } from '@mm-services/xml-forms.service';
+import { FormService } from '@mm-services/form.service';
+import { GeolocationService } from '@mm-services/geolocation.service';
import { EnketoComponent } from '@mm-components/enketo/enketo.component';
+import { Selectors } from '@mm-selectors/index';
describe('TrainingCardsComponent', () => {
let fixture: ComponentFixture;
let component: TrainingCardsComponent;
- let store: MockStore;
- let geolocationService;
- let geoHandle;
- let xmlFormsService;
let matDialogRef;
- let translateService;
- let formService;
let globalActions;
- let feedbackService;
- let consoleErrorMock;
let performanceService;
let stopPerformanceTrackStub;
let routerMock;
beforeEach(() => {
- consoleErrorMock = sinon.stub(console, 'error');
- geoHandle = { cancel: sinon.stub() };
- geolocationService = { init: sinon.stub().returns(geoHandle) };
- xmlFormsService = { get: sinon.stub().resolves() };
matDialogRef = { close: sinon.stub() };
- translateService = {
- get: sinon.stub().resolvesArg(0),
- instant: sinon.stub().returnsArg(0),
- };
- formService = {
- unload: sinon.stub(),
- save: sinon.stub(),
- render: sinon.stub().resolves(),
- };
routerMock = {
navigateByUrl: sinon.stub(),
};
- feedbackService = { submit: sinon.stub() };
- const mockedSelectors = [
- { selector: Selectors.getEnketoStatus, value: {} },
- { selector: Selectors.getEnketoSavingStatus, value: false },
- { selector: Selectors.getEnketoError, value: false },
- { selector: Selectors.getTrainingCardFormId, value: null },
- { selector: Selectors.getTrainingCard, value: {} },
- ];
globalActions = {
- clearEnketoStatus: sinon.stub(GlobalActions.prototype, 'clearEnketoStatus'),
- setEnketoSavingStatus: sinon.stub(GlobalActions.prototype, 'setEnketoSavingStatus'),
- setEnketoError: sinon.stub(GlobalActions.prototype, 'setEnketoError'),
- setSnackbarContent: sinon.stub(GlobalActions.prototype, 'setSnackbarContent'),
+ clearTrainingCards: sinon.stub(GlobalActions.prototype, 'clearTrainingCards'),
setTrainingCard: sinon.stub(GlobalActions.prototype, 'setTrainingCard'),
};
stopPerformanceTrackStub = sinon.stub();
performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) };
+ const mockedSelectors = [
+ { selector: Selectors.getTrainingCard, value: null },
+ { selector: Selectors.getTrainingCardFormId, value: null },
+ ];
return TestBed
.configureTestingModule({
@@ -80,351 +50,83 @@ describe('TrainingCardsComponent', () => {
declarations: [
TrainingCardsComponent,
ModalLayoutComponent,
- PanelHeaderComponent,
+ TrainingCardsFormComponent,
EnketoComponent,
],
providers: [
provideMockStore({ selectors: mockedSelectors }),
- { provide: GeolocationService, useValue: geolocationService },
- { provide: XmlFormsService, useValue: xmlFormsService },
- { provide: TranslateService, useValue: translateService },
- { provide: FormService, useValue: formService },
{ provide: PerformanceService, useValue: performanceService },
- { provide: FeedbackService, useValue: feedbackService },
{ provide: MatDialogRef, useValue: matDialogRef },
{ provide: Router, useValue: routerMock },
- { provide: MAT_DIALOG_DATA, useValue: {} },
+ { provide: XmlFormsService, useValue: {} },
+ { provide: FormService, useValue: { unload: sinon.stub() } },
+ { provide: GeolocationService, useValue: {} },
],
})
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(TrainingCardsComponent);
- store = TestBed.inject(MockStore);
component = fixture.componentInstance;
fixture.detectChanges();
+ sinon.resetHistory();
});
});
afterEach(() => sinon.restore());
- it('should create component', () => {
+ it('should create component', fakeAsync(() => {
expect(component).to.exist;
- });
- it('should close modal', () => {
- component.close();
+ component.ngOnInit();
+ flush();
- expect(matDialogRef.close.calledOnce).to.be.true;
- });
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
+ expect(globalActions.setTrainingCard.args[0][0]).to.deep.equal({ isOpen: true });
+ }));
- it('should unsubscribe from everything, cancel geohandle and clear enketo form', fakeAsync(() => {
- const unsubscribeStub = sinon.stub(component.subscription, 'unsubscribe');
+ it('should unsubscribe from store and clear training cards state', () => {
+ const unsubscribeStub = sinon.stub(component.subscriptions, 'unsubscribe');
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
- sinon.resetHistory();
component.ngOnDestroy();
- tick();
expect(unsubscribeStub.calledOnce).to.be.true;
- expect(geoHandle.cancel.calledOnce).to.be.true;
- expect(formService.unload.calledOnce).to.be.true;
- expect(globalActions.clearEnketoStatus.calledOnce).to.be.true;
- }));
-
- it('should close modal when quiting training', fakeAsync(() => {
- sinon.resetHistory();
- const xmlForm = { _id: 'training:a_form_id', some: 'content' };
- const renderedForm = { rendered: 'form', model: {}, instance: {} };
- xmlFormsService.get.resolves(xmlForm);
- formService.render.resolves(renderedForm);
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.overrideSelector(
- Selectors.getTrainingCard,
- { formId: '', isOpen: false, showConfirmExit: false, nextUrl: '' }
- );
- store.refreshState();
- tick();
-
- component.quitTraining();
-
- expect(matDialogRef.close.calledOnce).to.be.true;
- expect(geolocationService.init.calledOnce).to.be.true;
- expect(xmlFormsService.get.calledOnce).to.be.true;
- expect(xmlFormsService.get.args[0]).to.deep.equal([ 'training:a_form_id' ]);
- expect(formService.render.calledOnce).to.be.true;
- expect(formService.render.args[0][0].formDoc).to.deep.equal(xmlForm);
- expect(component.form).to.equal(renderedForm);
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
- expect(stopPerformanceTrackStub.callCount).to.equal(2);
- expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'enketo:training:a_form_id:add:render' });
- expect(stopPerformanceTrackStub.args[1][0]).to.deep.equal({ name: 'enketo:training:a_form_id:add:quit' });
- expect(globalActions.setTrainingCard.args[0]).to.deep.equal([{
- formId: null,
- isOpen: false,
- showConfirmExit: false,
- nextUrl: null,
- }]);
- }));
-
- it('should get training card state from store', fakeAsync(() => {
- sinon.resetHistory();
- store.overrideSelector(
- Selectors.getTrainingCard,
- { formId: '', isOpen: true, showConfirmExit: true, nextUrl: '/next/page' }
- );
- store.refreshState();
- tick();
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
+ });
- component.ngOnInit();
+ it('should set training cards state when not quitting the training', () => {
+ component.continueTraining();
expect(globalActions.setTrainingCard.calledOnce).to.be.true;
- expect(globalActions.setTrainingCard.args[0][0]).to.deep.equal({ isOpen: true });
- expect(component.showConfirmExit).to.be.true;
- expect(component.nextUrl).to.equal('/next/page');
- }));
+ expect(globalActions.setTrainingCard.args[0][0]).to.deep.equal({ showConfirmExit: false });
+ });
- it('should navigate to nextUrl if present', () => {
- sinon.resetHistory();
- component.nextUrl = '/next/page';
- component.quitTraining();
+ it('should exit training and navigate to nextUrl if present', () => {
+ const nextUrl = '/next/page';
+ component.exitTraining(nextUrl);
+
+ expect(stopPerformanceTrackStub.calledOnce).to.be.true;
expect(matDialogRef.close.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
expect(routerMock.navigateByUrl.calledOnce).to.be.true;
- expect(routerMock.navigateByUrl.args[0]).to.have.deep.members(['/next/page']);
+ expect(routerMock.navigateByUrl.args[0][0]).to.equal(nextUrl);
});
- it('should not navigate to nextUrl if not present', () => {
- sinon.resetHistory();
- component.nextUrl = null;
- component.quitTraining();
+ it('should exit training and not navigate to nextUrl if not present', () => {
+ component.exitTraining('');
+ expect(stopPerformanceTrackStub.calledOnce).to.be.true;
expect(matDialogRef.close.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
expect(routerMock.navigateByUrl.notCalled).to.be.true;
});
- describe('onInit', () => {
- it('should subscribe to redux and init component', () => {
- const AddSpy = sinon.spy(component.subscription, 'add');
- sinon.resetHistory();
-
- component.ngOnInit();
-
- expect(AddSpy.calledOnce).to.be.true;
- expect(component.contentError).to.be.false;
- expect(component.trainingCardFormId).to.be.null;
- expect(component.form).to.be.null;
- expect(component.loadingContent).to.be.true;
- expect(component.hideModalFooter).to.be.true;
- expect(component.nextUrl).to.be.undefined;
- expect(component.showConfirmExit).to.be.undefined;
- });
-
- it('should reset component', () => {
- component.contentError = true;
- component.trainingCardFormId = 'training:a_form_id';
- component.form = { the: 'rendered training form' };
- component.loadingContent = false;
- component.hideModalFooter = false;
- component.enketoError = 'some_error';
- sinon.resetHistory();
-
- component.ngOnInit();
-
- expect(globalActions.setEnketoError.calledOnce).to.be.true;
- expect(globalActions.setEnketoError.args[0]).to.deep.equal([ null ]);
- expect(component.contentError).to.be.false;
- expect(component.trainingCardFormId).to.be.null;
- expect(component.form).to.be.null;
- expect(component.loadingContent).to.be.true;
- expect(component.hideModalFooter).to.be.true;
- });
- });
-
- describe('saveForm', () => {
- it('should do nothing if already saving', fakeAsync(() => {
- const consoleDebugMock = sinon.stub(console, 'debug');
- component.enketoSaving = true;
-
- component.saveForm();
- tick();
-
- expect(consoleDebugMock.calledOnce).to.be.true;
- expect(consoleDebugMock.args[0]).to.deep.equal([
- 'Attempted to call TrainingCardsComponent:saveForm more than once'
- ]);
- expect(formService.save.notCalled).to.be.true;
- expect(formService.unload.notCalled).to.be.true;
- expect(globalActions.setEnketoSavingStatus.notCalled).to.be.true;
- expect(globalActions.setSnackbarContent.notCalled).to.be.true;
- expect(globalActions.setEnketoError.notCalled).to.be.true;
- expect(matDialogRef.close.notCalled).to.be.true;
- }));
-
- it('should call enketo save, set content in snackbar and unload form', fakeAsync(() => {
- const consoleDebugMock = sinon.stub(console, 'debug');
- xmlFormsService.get.resolves({ _id: 'form:training:new_feature' });
- formService.save.resolves([{ _id: 'completed_training' }]);
- formService.render.resolves({
- _id: 'form:training:new_feature',
- pages: { activePages: [ { id: 'page-1' } ] },
- });
-
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
- component.saveForm();
- tick();
-
- expect(formService.unload.calledOnce).to.be.true;
- expect(formService.unload.args[0]).to.deep.equal([{
- _id: 'form:training:new_feature',
- pages: { activePages: [ { id: 'page-1' } ] },
- }]);
- expect(formService.save.calledOnce).to.be.true;
- expect(formService.save.args[0]).to.deep.equal([
- 'training:a_form_id',
- {
- _id: 'form:training:new_feature',
- pages: { activePages: [ { id: 'page-1' } ] },
- },
- geoHandle
- ]);
- expect(consoleDebugMock.callCount).to.equal(1);
- expect(consoleDebugMock.args[0]).to.deep.equal([
- 'Saved form and associated docs',
- [{ _id: 'completed_training' }]
- ]);
- expect(globalActions.setEnketoSavingStatus.calledTwice).to.be.true;
- expect(globalActions.setEnketoSavingStatus.args).to.deep.equal([[ true ], [ false ]]);
- expect(globalActions.setSnackbarContent.calledOnce).to.be.true;
- expect(globalActions.setSnackbarContent.args[0]).to.deep.equal([ 'training_cards.form.saved' ]);
- expect(stopPerformanceTrackStub.callCount).to.equal(3);
- expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'enketo:training:a_form_id:add:render' });
- expect(stopPerformanceTrackStub.args[1][0])
- .to.deep.equal({ name: 'enketo:training:a_form_id:add:user_edit_time' });
- expect(stopPerformanceTrackStub.args[2][0]).to.deep.equal({ name: 'enketo:training:a_form_id:add:save' });
- expect(globalActions.setEnketoError.notCalled).to.be.true;
- expect(matDialogRef.close.calledOnce).to.be.true;
- }));
-
- it('should catch enketo saving error', fakeAsync(() => {
- sinon.resetHistory();
- xmlFormsService.get.resolves({ the: 'rendered training form' });
- formService.render.resolves({ the: 'rendered training form' });
- formService.save.rejects({ some: 'error' });
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
-
- component.saveForm();
- tick();
+ it('should close training cards', () => {
+ component.close();
- expect(formService.save.calledOnce).to.be.true;
- expect(formService.save.args[0]).to.deep.equal([
- 'training:a_form_id',
- { the: 'rendered training form' },
- geoHandle
- ]);
- expect(consoleErrorMock.calledOnce).to.be.true;
- expect(consoleErrorMock.args[0]).to.deep.equal([
- 'Training Cards :: Error submitting form data.',
- { some: 'error' }
- ]);
- expect(globalActions.setEnketoError.calledOnce).to.be.true;
- expect(globalActions.setEnketoError.args[0]).to.deep.equal([ 'training_cards.error.save' ]);
- expect(globalActions.setEnketoSavingStatus.calledTwice).to.be.true;
- expect(globalActions.setEnketoSavingStatus.args).to.deep.equal([[ true ], [ false ]]);
- expect(matDialogRef.close.notCalled).to.be.true;
- expect(globalActions.setSnackbarContent.notCalled).to.be.true;
- }));
+ expect(matDialogRef.close.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
});
- describe('loadForm', () => {
- it('should load form', fakeAsync(() => {
- sinon.resetHistory();
- const xmlForm = { _id: 'training:a_form_id', some: 'content' };
- const renderedForm = { rendered: 'form', model: {}, instance: {} };
- xmlFormsService.get.resolves(xmlForm);
- formService.render.resolves(renderedForm);
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
-
- expect(geolocationService.init.calledOnce).to.be.true;
- expect(xmlFormsService.get.calledOnce).to.be.true;
- expect(xmlFormsService.get.args[0]).to.deep.equal([ 'training:a_form_id' ]);
- expect(formService.render.calledOnce).to.be.true;
- expect(formService.render.args[0][0].formDoc).to.deep.equal(xmlForm);
- expect(component.form).to.equal(renderedForm);
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
- expect(stopPerformanceTrackStub.calledOnce).to.be.true;
- expect(stopPerformanceTrackStub.args[0][0]).to.deep.equal({ name: 'enketo:training:a_form_id:add:render' });
-
- const resetFormError = formService.render.args[0][0].valuechangeListener;
- resetFormError();
- expect(globalActions.setEnketoError.notCalled).to.be.true; // No error so no call
- component.enketoError = 'some error';
- resetFormError();
- expect(globalActions.setEnketoError.calledOnce).to.be.true;
- expect(globalActions.setEnketoError.args[0]).to.deep.equal([ null ]);
- }));
-
- it('should reset geohandle on reload', fakeAsync(() => {
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
-
- expect(geoHandle.cancel.notCalled).to.be.true;
- expect(geolocationService.init.calledOnce).to.be.true;
-
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:another_form_id');
- store.refreshState();
- tick();
-
- expect(geolocationService.init.calledTwice).to.be.true;
- expect(geoHandle.cancel.calledOnce).to.be.true;
- }));
-
- it('should catch form loading errors', fakeAsync(() => {
- xmlFormsService.get.rejects({ error: 'boom' });
- sinon.resetHistory();
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
-
- expect(xmlFormsService.get.calledOnce).to.be.true;
- expect(formService.render.notCalled).to.be.true;
- expect(consoleErrorMock.calledOnce).to.be.true;
- expect(consoleErrorMock.args[0]).to.deep.equal([
- 'Training Cards :: Error fetching form.',
- { error: 'boom' }
- ]);
- expect(component.errorTranslationKey).to.equal('training_cards.error.loading');
- expect(component.loadingContent).to.be.false;
- expect(component.hideModalFooter).to.be.false;
- expect(component.contentError).to.be.true;
- }));
-
- it('should catch enketo errors', fakeAsync(() => {
- xmlFormsService.get.resolves({ _id: 'training:a_form_id', some: 'content' });
- formService.render.rejects({ some: 'error' });
- sinon.resetHistory();
- store.overrideSelector(Selectors.getTrainingCardFormId, 'training:a_form_id');
- store.refreshState();
- tick();
-
- expect(xmlFormsService.get.calledOnce).to.be.true;
- expect(formService.render.calledOnce).to.be.true;
- expect(component.form).to.equal(null);
- expect(consoleErrorMock.calledOnce).to.be.true;
- expect(consoleErrorMock.args[0]).to.deep.equal([
- 'Training Cards :: Error rendering form.',
- { some: 'error' }
- ]);
- }));
- });
});
+
diff --git a/webapp/tests/karma/ts/modules/trainings/trainings-content.component.spec.ts b/webapp/tests/karma/ts/modules/trainings/trainings-content.component.spec.ts
new file mode 100644
index 00000000000..f49adeaa107
--- /dev/null
+++ b/webapp/tests/karma/ts/modules/trainings/trainings-content.component.spec.ts
@@ -0,0 +1,173 @@
+import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { provideMockStore } from '@ngrx/store/testing';
+import { ActivatedRoute, Router } from '@angular/router';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { of, Subject } from 'rxjs';
+
+import { GlobalActions } from '@mm-actions/global';
+import { PerformanceService } from '@mm-services/performance.service';
+import { ModalLayoutComponent } from '@mm-components/modal-layout/modal-layout.component';
+import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component';
+import { XmlFormsService } from '@mm-services/xml-forms.service';
+import { FormService } from '@mm-services/form.service';
+import { GeolocationService } from '@mm-services/geolocation.service';
+import { EnketoComponent } from '@mm-components/enketo/enketo.component';
+import { Selectors } from '@mm-selectors/index';
+import { TrainingsContentComponent } from '@mm-modules/trainings/trainings-content.component';
+import { ModalService } from '@mm-services/modal.service';
+
+describe('TrainingsContentComponent', () => {
+ let fixture: ComponentFixture;
+ let component: TrainingsContentComponent;
+ let modalService;
+ let globalActions;
+ let performanceService;
+ let stopPerformanceTrackStub;
+ let routerMock;
+
+ beforeEach(() => {
+ modalService = { show: sinon.stub() };
+ routerMock = {
+ navigateByUrl: sinon.stub(),
+ navigate: sinon.stub(),
+ };
+ globalActions = {
+ clearTrainingCards: sinon.stub(GlobalActions.prototype, 'clearTrainingCards'),
+ setTrainingCard: sinon.stub(GlobalActions.prototype, 'setTrainingCard'),
+ clearNavigation: sinon.stub(GlobalActions.prototype, 'clearNavigation'),
+ };
+ stopPerformanceTrackStub = sinon.stub();
+ performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) };
+ const mockedSelectors = [
+ { selector: Selectors.getTrainingCardFormId, value: null },
+ ];
+
+ return TestBed
+ .configureTestingModule({
+ imports: [
+ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }),
+ ],
+ declarations: [
+ TrainingsContentComponent,
+ ModalLayoutComponent,
+ TrainingCardsFormComponent,
+ EnketoComponent,
+ ],
+ providers: [
+ provideMockStore({ selectors: mockedSelectors }),
+ { provide: ActivatedRoute, useValue: { params: new Subject() } },
+ { provide: PerformanceService, useValue: performanceService },
+ { provide: ModalService, useValue: modalService },
+ { provide: Router, useValue: routerMock },
+ { provide: XmlFormsService, useValue: {} },
+ { provide: FormService, useValue: { unload: sinon.stub() } },
+ { provide: GeolocationService, useValue: {} },
+ ],
+ })
+ .compileComponents()
+ .then(() => {
+ fixture = TestBed.createComponent(TrainingsContentComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ sinon.resetHistory();
+ });
+ });
+
+ afterEach(() => sinon.restore());
+
+ it('should unsubscribe from store and clear training cards state', () => {
+ const unsubscribeStub = sinon.stub(component.subscriptions, 'unsubscribe');
+
+ component.ngOnDestroy();
+
+ expect(unsubscribeStub.calledOnce).to.be.true;
+ expect(globalActions.clearNavigation.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
+ });
+
+ it('should close confirmation modal when not quitting the training', () => {
+ const modalRefMock = { close: sinon.stub(), afterClosed: sinon.stub().returns(of(null)) };
+ modalService.show.returns(modalRefMock);
+ component.quit();
+
+ component.continueTraining();
+
+ expect(modalRefMock.close.calledOnce).to.be.true;
+ });
+
+ it('should exit training and navigate to nextUrl if present', () => {
+ const modalRefMock = { close: sinon.stub(), afterClosed: sinon.stub().returns(of(null)) };
+ modalService.show.returns(modalRefMock);
+ component.quit();
+
+ component.exitTraining('/next/url');
+
+ expect(stopPerformanceTrackStub.calledOnce).to.be.true;
+ expect(modalRefMock.close.calledOnce).to.be.true;
+ expect(globalActions.clearNavigation.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
+ expect(routerMock.navigateByUrl.calledOnceWith('/next/url')).to.be.true;
+ });
+
+ it('should exit training and navigate to default path', () => {
+ const modalRefMock = { close: sinon.stub(), afterClosed: sinon.stub().returns(of(null)) };
+ modalService.show.returns(modalRefMock);
+ component.quit();
+
+ component.exitTraining('');
+
+ expect(stopPerformanceTrackStub.calledOnce).to.be.true;
+ expect(modalRefMock.close.calledOnce).to.be.true;
+ expect(globalActions.clearNavigation.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
+ expect(routerMock.navigateByUrl.notCalled).to.be.true;
+ expect(routerMock.navigate.calledOnceWith([ '/', 'trainings' ])).to.be.true;
+ });
+
+ it('should not show confirm modal if there are errors', () => {
+ component.hasError = true;
+
+ component.quit();
+
+ expect(routerMock.navigate.calledOnceWith([ '/', 'trainings' ])).to.be.true;
+ expect(globalActions.clearNavigation.calledOnce).to.be.true;
+ expect(globalActions.clearTrainingCards.calledOnce).to.be.true;
+ expect(component.showConfirmExit).to.be.false;
+ expect(modalService.show.notCalled).to.be.true;
+ });
+
+ it('should show confirm modal if there are not errors', () => {
+ component.hasError = false;
+
+ component.quit();
+
+ expect(routerMock.navigate.notCalled).to.be.true;
+ expect(globalActions.clearNavigation.notCalled).to.be.true;
+ expect(globalActions.clearTrainingCards.notCalled).to.be.true;
+ expect(component.showConfirmExit).to.be.true;
+ expect(modalService.show.calledOnce).to.be.true;
+ });
+
+ it('should not deactivate navigation when it cannot exit', () => {
+ const result = component.canDeactivate('/next/url');
+
+ expect(result).to.be.false;
+ expect(globalActions.setTrainingCard.calledOnceWith({ nextUrl: '/next/url' })).to.be.true;
+ expect(component.showConfirmExit).to.be.true;
+ expect(modalService.show.calledOnce).to.be.true;
+ });
+
+ it('should deactivate navigation when it cannot exit', () => {
+ component.close();
+
+ const result = component.canDeactivate('/next/url');
+
+ expect(result).to.be.true;
+ expect(globalActions.setTrainingCard.notCalled).to.be.true;
+ expect(component.showConfirmExit).to.be.false;
+ expect(modalService.show.notCalled).to.be.true;
+ });
+});
+
diff --git a/webapp/tests/karma/ts/modules/trainings/trainings.component.spec.ts b/webapp/tests/karma/ts/modules/trainings/trainings.component.spec.ts
new file mode 100644
index 00000000000..346194e488a
--- /dev/null
+++ b/webapp/tests/karma/ts/modules/trainings/trainings.component.spec.ts
@@ -0,0 +1,200 @@
+import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { MatIconModule } from '@angular/material/icon';
+import { RouterModule } from '@angular/router';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { GlobalActions } from '@mm-actions/global';
+import { PerformanceService } from '@mm-services/performance.service';
+import { ModalLayoutComponent } from '@mm-components/modal-layout/modal-layout.component';
+import { TrainingCardsFormComponent } from '@mm-components/training-cards-form/training-cards-form.component';
+import { EnketoComponent } from '@mm-components/enketo/enketo.component';
+import { Selectors } from '@mm-selectors/index';
+import { TrainingsComponent } from '@mm-modules/trainings/trainings.component';
+import { TrainingCardsService } from '@mm-services/training-cards.service';
+import { ScrollLoaderProvider } from '@mm-providers/scroll-loader.provider';
+import { ToolBarComponent } from '@mm-components/tool-bar/tool-bar.component';
+
+describe('TrainingsComponent', () => {
+ let fixture: ComponentFixture;
+ let component: TrainingsComponent;
+ let globalActions;
+ let performanceService;
+ let stopPerformanceTrackStub;
+ let trainingCardsService;
+ let scrollLoaderProvider;
+ let consoleErrorMock;
+ let store;
+
+ beforeEach(() => {
+ consoleErrorMock = sinon.stub(console, 'error');
+ globalActions = {
+ unsetSelected: sinon.stub(GlobalActions.prototype, 'unsetSelected'),
+ };
+ stopPerformanceTrackStub = sinon.stub();
+ performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) };
+ trainingCardsService = { getNextTrainings: sinon.stub() };
+ scrollLoaderProvider = { init: sinon.stub() };
+ const mockedSelectors = [
+ { selector: Selectors.getTrainingMaterials, value: null },
+ { selector: Selectors.getTrainingCardFormId, value: null },
+ ];
+
+ return TestBed
+ .configureTestingModule({
+ imports: [
+ RouterModule,
+ MatIconModule,
+ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }),
+ ],
+ declarations: [
+ TrainingsComponent,
+ ModalLayoutComponent,
+ TrainingCardsFormComponent,
+ EnketoComponent,
+ ToolBarComponent,
+ ],
+ providers: [
+ provideMockStore({ selectors: mockedSelectors }),
+ { provide: PerformanceService, useValue: performanceService },
+ { provide: TrainingCardsService, useValue: trainingCardsService },
+ { provide: ScrollLoaderProvider, useValue: scrollLoaderProvider },
+ ],
+ })
+ .overrideComponent(ToolBarComponent, {
+ set: {
+ selector: 'mm-tool-bar',
+ template: 'Tool bar mock
'
+ }
+ })
+ .compileComponents()
+ .then(() => {
+ fixture = TestBed.createComponent(TrainingsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ sinon.resetHistory();
+ store = TestBed.inject(MockStore);
+ });
+ });
+
+ afterEach(() => {
+ store.resetSelectors();
+ sinon.restore();
+ });
+
+ it('should unsubscribe from store and clear selected from global state', () => {
+ const unsubscribeStub = sinon.stub(component.subscriptions, 'unsubscribe');
+
+ component.ngOnDestroy();
+
+ expect(unsubscribeStub.calledOnce).to.be.true;
+ expect(globalActions.unsetSelected.calledOnce).to.be.true;
+ });
+
+ it('should not get trainings when no forms', fakeAsync(async () => {
+ store.overrideSelector(Selectors.getTrainingMaterials, []);
+ store.refreshState();
+ flush();
+
+ expect(trainingCardsService.getNextTrainings.notCalled).to.be.true;
+ }));
+
+ it('should load trainings when there are forms', fakeAsync(async () => {
+ trainingCardsService.getNextTrainings.returns([ { code: 'form-1' } ]);
+ store.overrideSelector(Selectors.getTrainingMaterials, [ { _id: 'form-1' }, { _id: 'form-2' } ]);
+ store.refreshState();
+ flush();
+
+ expect(trainingCardsService.getNextTrainings.calledOnce).to.be.true;
+ expect(trainingCardsService.getNextTrainings.args[0]).to.have.deep.members([
+ [ { _id: 'form-1' }, { _id: 'form-2' } ],
+ 50,
+ 0,
+ ]);
+ expect(component.moreTrainings).to.be.false;
+ expect(component.loading).to.be.false;
+ expect(component.trainingList).to.have.deep.members([ { code: 'form-1' } ]);
+ expect(stopPerformanceTrackStub.calledOnce).to.be.true;
+ expect(scrollLoaderProvider.init.calledOnce).to.be.true;
+ expect(consoleErrorMock.notCalled).to.be.true;
+ }));
+
+ it('should load next page of trainings', fakeAsync(async () => {
+ store.overrideSelector(Selectors.getTrainingMaterials, [ { _id: 'form-3' }, { _id: 'form-4' } ]);
+ store.refreshState();
+ flush();
+ sinon.resetHistory();
+ const previousPage = Array
+ .from({ length: 50 })
+ .map((form, index) => ({
+ code: 'form-x-' + index,
+ id: 'form-id',
+ title: 'training-x',
+ startDate: new Date(),
+ duration: 1,
+ userRoles: [ 'chw' ],
+ isCompletedTraining: true,
+ }));
+ const nextPage = Array
+ .from({ length: 50 })
+ .map((form, index) => ({
+ code: 'form-y-' + index,
+ id: 'form-id',
+ title: 'training-y',
+ startDate: new Date(),
+ duration: 1,
+ userRoles: [ 'chw' ],
+ isCompletedTraining: true,
+ }));
+ component.trainingList = [...previousPage];
+ trainingCardsService.getNextTrainings.returns(nextPage);
+
+ await component.getTrainings();
+
+ expect(trainingCardsService.getNextTrainings.calledOnce).to.be.true;
+ expect(trainingCardsService.getNextTrainings.args[0]).to.have.deep.members([
+ [ { _id: 'form-3' }, { _id: 'form-4' } ],
+ 50,
+ 50,
+ ]);
+ expect(component.moreTrainings).to.be.true;
+ expect(component.loading).to.be.false;
+ expect(component.trainingList).to.have.deep.members([ ...previousPage, ...nextPage ]);
+ expect(scrollLoaderProvider.init.calledOnce).to.be.true;
+ expect(consoleErrorMock.notCalled).to.be.true;
+ }));
+
+ it('should catch error when loading a page of trainings', fakeAsync(async () => {
+ store.overrideSelector(Selectors.getTrainingMaterials, [ { _id: 'form-3' } ]);
+ store.refreshState();
+ flush();
+ sinon.resetHistory();
+ component.trainingList = [{
+ code: 'form-x',
+ id: 'form-id',
+ title: 'training-x',
+ startDate: new Date(),
+ duration: 1,
+ userRoles: [ 'chw' ],
+ isCompletedTraining: true,
+ }];
+ trainingCardsService.getNextTrainings.throws(new Error('Ups an error'));
+
+ await component.getTrainings();
+
+ expect(trainingCardsService.getNextTrainings.calledOnce).to.be.true;
+ expect(trainingCardsService.getNextTrainings.args[0]).to.have.deep.members([
+ [ { _id: 'form-3' } ],
+ 50,
+ 1,
+ ]);
+ expect(component.loading).to.be.false;
+ expect(stopPerformanceTrackStub.notCalled).to.be.true;
+ expect(scrollLoaderProvider.init.notCalled).to.be.true;
+ expect(consoleErrorMock.calledOnce).to.be.true;
+ expect(consoleErrorMock.args[0][0]).to.equal('Error getting training materials.');
+ }));
+});
+
diff --git a/webapp/tests/karma/ts/selectors/index.spec.ts b/webapp/tests/karma/ts/selectors/index.spec.ts
index ef29ab4fd92..579d7425eda 100644
--- a/webapp/tests/karma/ts/selectors/index.spec.ts
+++ b/webapp/tests/karma/ts/selectors/index.spec.ts
@@ -15,6 +15,7 @@ const globalState: GlobalState = {
showContent: true,
selectMode: false,
forms: [ { _id: 'these' } ],
+ trainingMaterials: [ { _id: 'these' } ],
filters: { some: 'filters' },
sidebarFilter: {
isOpen: false,
diff --git a/webapp/tests/karma/ts/services/training-cards.service.spec.ts b/webapp/tests/karma/ts/services/training-cards.service.spec.ts
index e7a67de3c7d..387c3718c4a 100644
--- a/webapp/tests/karma/ts/services/training-cards.service.spec.ts
+++ b/webapp/tests/karma/ts/services/training-cards.service.spec.ts
@@ -10,7 +10,8 @@ import { ModalService } from '@mm-services/modal.service';
import { TrainingCardsService } from '@mm-services/training-cards.service';
import { SessionService } from '@mm-services/session.service';
import { RouteSnapshotService } from '@mm-services/route-snapshot.service';
-import { FeedbackService } from '@mm-services/feedback.service';
+import { TranslateService } from '@ngx-translate/core';
+import { TranslateFromService } from '@mm-services/translate-from.service';
describe('TrainingCardsService', () => {
let service: TrainingCardsService;
@@ -20,24 +21,22 @@ describe('TrainingCardsService', () => {
let modalService;
let localDb;
let clock;
- let consoleErrorMock;
+ let consoleErrorSpy;
let sessionService;
let routeSnapshotService;
- let feedbackService;
beforeEach(() => {
- localDb = { allDocs: sinon.stub() };
+ localDb = { allDocs: sinon.stub().resolves({}) };
dbService = { get: () => localDb };
globalActions = { setTrainingCard: sinon.stub(GlobalActions.prototype, 'setTrainingCard') };
xmlFormsService = { subscribe: sinon.stub() };
modalService = { show: sinon.stub() };
sessionService = {
- userCtx: sinon.stub(),
+ userCtx: sinon.stub().returns({}),
hasRole: sinon.spy(SessionService.prototype, 'hasRole'),
};
- feedbackService = { submit: sinon.stub() };
- consoleErrorMock = sinon.stub(console, 'error');
- routeSnapshotService = { get: sinon.stub() };
+ consoleErrorSpy = sinon.spy(console, 'error');
+ routeSnapshotService = { get: sinon.stub().returns({}) };
TestBed.configureTestingModule({
providers: [
@@ -47,22 +46,23 @@ describe('TrainingCardsService', () => {
{ provide: ModalService, useValue: modalService },
{ provide: SessionService, useValue: sessionService },
{ provide: RouteSnapshotService, useValue: routeSnapshotService },
- { provide: FeedbackService, useValue: feedbackService },
- ]
+ { provide: TranslateService, useValue: { instant: sinon.stub() } },
+ { provide: TranslateFromService, useValue: { get: sinon.stub().returnsArg(0) } },
+ ],
});
service = TestBed.inject(TrainingCardsService);
});
afterEach(() => {
- clock && clock.restore();
+ clock?.restore();
sinon.restore();
document
.querySelectorAll('#enketo-test')
.forEach(element => element.remove());
});
- it('should set uncompleted training form when none are completed', async () => {
+ it('should show uncompleted training when none are completed', async () => {
sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
localDb.allDocs.resolves({ rows: [] });
clock = sinon.useFakeTimers(new Date('2022-05-23 20:29:25'));
@@ -104,16 +104,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
-
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.callCount).to.equal(4);
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -121,13 +115,12 @@ describe('TrainingCardsService', () => {
startkey: 'training:a_user:',
endkey: 'training:a_user:\ufff0',
});
- expect(globalActions.setTrainingCard.calledOnce);
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.args[0]).to.deep.equal([ { formId: 'training:form-b' } ]);
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
- it('should set uncompleted training form when there are some completed', async () => {
+ it('should show uncompleted training when there are some completed', async () => {
sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
localDb.allDocs.resolves({ rows: [
{ doc: { form: 'training:form-a' } },
@@ -172,16 +165,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
-
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.callCount).to.equal(4);
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -189,11 +176,10 @@ describe('TrainingCardsService', () => {
startkey: 'training:a_user:',
endkey: 'training:a_user:\ufff0',
});
- expect(globalActions.setTrainingCard.calledOnce);
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.args[0]).to.deep.equal([ { formId: 'training:form-d' } ]);
- expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(modalService.show.calledOnce).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should show uncompleted training form when they dont have duration set', async () => {
@@ -229,16 +215,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
-
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.calledThrice).to.be.true;
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -246,11 +226,10 @@ describe('TrainingCardsService', () => {
startkey: 'training:a_user:',
endkey: 'training:a_user:\ufff0',
});
- expect(globalActions.setTrainingCard.calledOnce);
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.args[0]).to.deep.equal([ { formId: 'training:form-c' } ]);
- expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(modalService.show.calledOnce).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should show uncompleted training form when they dont have start_date set', async () => {
@@ -294,16 +273,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
-
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.callCount).to.equal(4);
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -311,11 +284,30 @@ describe('TrainingCardsService', () => {
startkey: 'training:a_user:',
endkey: 'training:a_user:\ufff0',
});
- expect(globalActions.setTrainingCard.calledOnce);
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.args[0]).to.deep.equal([ { formId: 'training:form-c' } ]);
+ expect(modalService.show.calledOnce).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
+ });
+
+ it('should not show training when privacy policy has not been accepted yet', async () => {
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
+ localDb.allDocs.resolves({ rows: []});
+ clock = sinon.useFakeTimers(new Date('2022-05-23 20:29:25'));
+ const xforms = [{
+ _id: 'form:training:abc-100',
+ internalId: 'training:form-c',
+ context: { duration: 9, user_roles: [ 'chw' ] },
+ }];
+
+ await service.displayTrainingCards(xforms, true, false);
+
+ expect(sessionService.userCtx.notCalled).to.be.true;
+ expect(sessionService.hasRole.notCalled).to.be.true;
+ expect(localDb.allDocs.notCalled).to.be.true;
+ expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should not show training form when all trainings are completed', async () => {
@@ -355,16 +347,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
-
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.calledThrice).to.be.true;
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -374,8 +360,7 @@ describe('TrainingCardsService', () => {
});
expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should not show training forms when all trainings have expired', async () => {
@@ -413,21 +398,14 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
-
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
expect(sessionService.hasRole.calledThrice).to.be.true;
expect(localDb.allDocs.notCalled).to.be.true;
expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should not show training forms when all trainings start in the future', async () => {
@@ -463,21 +441,14 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
-
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
expect(sessionService.hasRole.calledThrice).to.be.true;
expect(localDb.allDocs.notCalled).to.be.true;
expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should not show training forms if their internalID does not have the right prefix', async () => {
@@ -513,25 +484,19 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
-
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
expect(localDb.allDocs.notCalled).to.be.true;
expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.calledThrice).to.be.true;
- expect(consoleErrorMock.args[0][0].message)
+ expect(consoleErrorSpy.calledThrice).to.be.true;
+ expect(consoleErrorSpy.args[0][0].message)
.to.equal('Training Cards :: Incorrect internalId format. Doc ID: form:training:cards-1');
- expect(consoleErrorMock.args[1][0].message)
+ expect(consoleErrorSpy.args[1][0].message)
.to.equal('Training Cards :: Incorrect internalId format. Doc ID: form:training:cards-2');
- expect(consoleErrorMock.args[2][0].message)
+ expect(consoleErrorSpy.args[2][0].message)
.to.equal('Training Cards :: Incorrect internalId format. Doc ID: form:training:cards-3');
});
@@ -539,21 +504,14 @@ describe('TrainingCardsService', () => {
sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
localDb.allDocs.resolves({ rows: [] });
clock = sinon.useFakeTimers(new Date('2022-05-23 20:29:25'));
- service.initTrainingCards();
-
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
- await callback(null, []);
+ await service.displayTrainingCards([], true, true);
expect(sessionService.hasRole.notCalled).to.be.true;
expect(localDb.allDocs.notCalled).to.be.true;
expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should log error from xmlFormsService', async () => {
@@ -566,14 +524,10 @@ describe('TrainingCardsService', () => {
await callback(new Error('some error'), []);
- expect(localDb.allDocs.notCalled).to.be.true;
- expect(sessionService.userCtx.notCalled).to.be.true;
- expect(sessionService.hasRole.notCalled).to.be.true;
expect(globalActions.setTrainingCard.notCalled).to.be.true;
- expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.calledOnce).to.be.true;
- expect(consoleErrorMock.args[0][0]).to.equal('Training Cards :: Error fetching forms.');
- expect(consoleErrorMock.args[0][1].message).to.equal('some error');
+ expect(consoleErrorSpy.calledOnce).to.be.true;
+ expect(consoleErrorSpy.args[0][0]).to.equal('Training Cards :: Error fetching forms.');
+ expect(consoleErrorSpy.args[0][1].message).to.equal('some error');
});
it('should catch exception', async () => {
@@ -589,34 +543,37 @@ describe('TrainingCardsService', () => {
user_roles: [ 'chw' ],
},
}];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
-
- await callback(null, xforms);
+ await service.displayTrainingCards(xforms, true, true);
expect(sessionService.hasRole.calledOnce).to.be.true;
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(localDb.allDocs.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.notCalled).to.be.true;
expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.calledOnce).to.be.true;
- expect(consoleErrorMock.args[0][0]).to.equal('Training Cards :: Error showing modal.');
- expect(consoleErrorMock.args[0][1].message).to.equal('some error');
+ expect(consoleErrorSpy.calledOnce).to.be.true;
+ expect(consoleErrorSpy.args[0][0]).to.equal('Training Cards :: Error showing modal.');
+ expect(consoleErrorSpy.args[0][1].message).to.equal('some error');
});
- it('should not display training if route has hideTraining flag', async () => {
+ it('should display training if route has hideTraining false', async () => {
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
+ localDb.allDocs.resolves({ rows: [ { doc: { form: 'training:form-b' } } ] });
+ clock = sinon.useFakeTimers(new Date('2022-05-23 20:29:25'));
+ const xforms = [ {
+ _id: 'form:training:abc-100',
+ internalId: 'training:form-c',
+ context: { duration: 9, user_roles: [ 'chw' ] },
+ }];
+
routeSnapshotService.get.returns({ data: { hideTraining: true } });
- service.displayTrainingCards();
+ await service.displayTrainingCards(xforms, true, true);
+
expect(modalService.show.notCalled).to.be.true;
- });
- it('should display training', () => {
routeSnapshotService.get.returns({ data: { hideTraining: false } });
- service.displayTrainingCards();
+ await service.displayTrainingCards(xforms, true, true);
+
expect(modalService.show.calledOnce).to.be.true;
});
@@ -672,16 +629,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
+ await service.displayTrainingCards(xforms, true, true);
- await callback(null, xforms);
-
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.callCount).to.equal(5);
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -689,11 +640,10 @@ describe('TrainingCardsService', () => {
startkey: 'training:a_user:',
endkey: 'training:a_user:\ufff0',
});
- expect(globalActions.setTrainingCard.calledOnce);
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.args[0]).to.deep.equal([ { formId: 'training:form-e' } ]);
- expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(modalService.show.calledOnce).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should show uncompleted training when form does not have user_roles defined', async () => {
@@ -721,16 +671,10 @@ describe('TrainingCardsService', () => {
},
},
];
- service.initTrainingCards();
- expect(xmlFormsService.subscribe.calledOnce).to.be.true;
- expect(xmlFormsService.subscribe.args[0][0]).to.equal('TrainingCards');
- expect(xmlFormsService.subscribe.args[0][1]).to.deep.equal({ trainingCards: true });
- const callback = xmlFormsService.subscribe.args[0][2];
+ await service.displayTrainingCards(xforms, true, true);
- await callback(null, xforms);
-
- expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.userCtx.calledTwice).to.be.true;
expect(sessionService.hasRole.calledOnce).to.be.true;
expect(localDb.allDocs.calledOnce).to.be.true;
expect(localDb.allDocs.args[0][0]).to.deep.equal({
@@ -738,11 +682,10 @@ describe('TrainingCardsService', () => {
startkey: 'training:a_user:',
endkey: 'training:a_user:\ufff0',
});
- expect(globalActions.setTrainingCard.calledOnce);
+ expect(globalActions.setTrainingCard.calledOnce).to.be.true;
expect(globalActions.setTrainingCard.args[0]).to.deep.equal([ { formId: 'training:form-d' } ]);
- expect(modalService.show.notCalled).to.be.true;
- expect(consoleErrorMock.notCalled).to.be.true;
- expect(feedbackService.submit.notCalled).to.be.true;
+ expect(modalService.show.calledOnce).to.be.true;
+ expect(consoleErrorSpy.notCalled).to.be.true;
});
it('should evaluate if the internalID is from a training card', () => {
@@ -777,46 +720,207 @@ describe('TrainingCardsService', () => {
it('should display training when it has not been displayed today', async () => {
routeSnapshotService.get.returns({ data: { hideTraining: false } });
- sessionService.userCtx.returns({ name: 'ronald' });
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'ronald' });
window.localStorage.setItem('training-cards-last-viewed-date-ronald', '2024-05-23 20:29:25');
clock = sinon.useFakeTimers(new Date('2025-05-25 20:29:25'));
+ localDb.allDocs.resolves({ rows: [] });
+ const xforms = [ {
+ _id: 'form:training:abc-100',
+ internalId: 'training:form-c',
+ context: { duration: 9, user_roles: [ 'chw' ] },
+ }];
- service.displayTrainingCards();
+ await service.displayTrainingCards(xforms, true, true);
expect(modalService.show.calledOnce).to.be.true;
});
it('should display training when last viewed date is empty', async () => {
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'ronald' });
routeSnapshotService.get.returns({ data: { hideTraining: false } });
window.localStorage.setItem('training-cards-last-viewed-date-ronald', '');
clock = sinon.useFakeTimers(new Date('2025-05-25 20:29:25'));
+ localDb.allDocs.resolves({ rows: [] });
+ const xforms = [ {
+ _id: 'form:training:abc-100',
+ internalId: 'training:form-c',
+ context: { duration: 9, user_roles: [ 'chw' ] },
+ }];
- service.displayTrainingCards();
+ await service.displayTrainingCards(xforms, true, true);
expect(modalService.show.calledOnce).to.be.true;
});
it('should not display training when it has been displayed today for the same user', async () => {
routeSnapshotService.get.returns({ data: { hideTraining: false } });
- sessionService.userCtx.returns({ name: 'ronald' });
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'ronald' });
window.localStorage.setItem('training-cards-last-viewed-date-ronald', '2024-05-23 20:29:25');
clock = sinon.useFakeTimers(new Date('2024-05-23 06:29:25'));
+ localDb.allDocs.resolves({ rows: [] });
+ const xforms = [ {
+ _id: 'form:training:abc-100',
+ internalId: 'training:form-c',
+ context: { duration: 9, user_roles: [ 'chw' ] },
+ }];
- service.displayTrainingCards();
+ await service.displayTrainingCards(xforms, true, true);
expect(modalService.show.notCalled).to.be.true;
});
it('should display training when it has not been displayed for a different user', async () => {
routeSnapshotService.get.returns({ data: { hideTraining: false } });
- sessionService.userCtx.returns({ name: 'sarah' });
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'sarah' });
window.localStorage.setItem('training-cards-last-viewed-date-ronald', '2024-05-23 20:29:25');
clock = sinon.useFakeTimers(new Date('2024-05-23 06:29:25'));
+ localDb.allDocs.resolves({ rows: [ { doc: { form: 'training:form-b' } } ] });
+ const xforms = [ {
+ _id: 'form:training:abc-100',
+ internalId: 'training:form-c',
+ context: { duration: 9, user_roles: [ 'chw' ] },
+ }];
- service.displayTrainingCards();
+ await service.displayTrainingCards(xforms, true, true);
expect(modalService.show.calledOnce).to.be.true;
});
});
+ describe('Get next available trainings', () => {
+ it('should return list of available trainings', async () => {
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
+ localDb.allDocs.resolves({ rows: [
+ { doc: { form: 'training:form-a' } },
+ { doc: { form: 'training:form-b' } },
+ ]});
+ clock = sinon.useFakeTimers(new Date('2022-05-23 20:29:25'));
+ const xforms = [
+ {
+ _id: 'form:training:abc-789',
+ internalId: 'training:form-c',
+ context: { start_date: '2022-05-28', duration: 6, user_roles: [ 'chw' ] },
+ },
+ {
+ _id: 'form:training:abc-456',
+ internalId: 'training:form-a',
+ context: { start_date: '2022-05-18', duration: 2, user_roles: [ 'chw' ] },
+ },
+ {
+ _id: 'form:training:abc-123',
+ internalId: 'training:form-b',
+ context: { start_date: '2022-05-21', duration: 3, user_roles: [ 'chw' ] },
+ },
+ {
+ _id: 'form:training:abc-098',
+ internalId: 'training:form-d',
+ context: { start_date: '2022-05-21', duration: 9, user_roles: [ 'chw' ] },
+ },
+ ];
+
+ const result = await service.getNextTrainings(xforms, 50, 0);
+
+ expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.hasRole.callCount).to.equal(4);
+ expect(localDb.allDocs.calledOnce).to.be.true;
+ expect(localDb.allDocs.args[0][0]).to.deep.equal({
+ include_docs: true,
+ startkey: 'training:a_user:',
+ endkey: 'training:a_user:\ufff0',
+ });
+ expect(result).excluding('startDate').to.have.deep.members([
+ {
+ id: 'form:training:abc-123',
+ code: 'training:form-b',
+ isCompletedTraining: true,
+ title: undefined,
+ userRoles: [ 'chw' ],
+ duration: 3,
+ },
+ {
+ id: 'form:training:abc-098',
+ code: 'training:form-d',
+ isCompletedTraining: false,
+ title: undefined,
+ userRoles: [ 'chw' ],
+ duration: 9,
+ },
+ ]);
+ });
+
+ it('should paginate the list of available trainings', async () => {
+ sessionService.userCtx.returns({ roles: [ 'chw' ], name: 'a_user' });
+ localDb.allDocs.resolves({ rows: [
+ { doc: { form: 'training:form-a' } },
+ { doc: { form: 'training:form-b' } },
+ ]});
+ clock = sinon.useFakeTimers(new Date('2022-05-23 20:29:25'));
+ const xforms = [
+ ...Array.from({ length: 30 }).map((item, index) => ({
+ _id: 'form:training:abc-123' + index,
+ internalId: 'training:form-b',
+ context: { start_date: '2022-05-21', duration: 3, user_roles: [ 'chw' ] },
+ })),
+ ...Array.from({ length: 30 }).map((item, index) => ({
+ _id: 'form:training:abc-789' + index,
+ internalId: 'training:form-c',
+ context: { start_date: '2022-05-28', duration: 6, user_roles: [ 'chw' ] },
+ })),
+ ...Array.from({ length: 30 }).map((item, index) => ({
+ _id: 'form:training:abc-098' + index,
+ title: 'A Title',
+ internalId: 'training:form-d',
+ context: { start_date: '2022-05-21', duration: 9, user_roles: [ 'chw' ] },
+ })),
+ ];
+
+ const expectedPage1 = [
+ ...Array.from({ length: 30 }).map((item, index) => ({
+ id: 'form:training:abc-123' + index,
+ code: 'training:form-b',
+ duration: 3,
+ title: undefined,
+ isCompletedTraining: true,
+ userRoles: [ 'chw' ],
+ })),
+ ...Array.from({ length: 20 }).map((item, index) => ({
+ id: 'form:training:abc-098' + index,
+ title: 'A Title',
+ code: 'training:form-d',
+ isCompletedTraining: false,
+ duration: 9,
+ userRoles: [ 'chw' ],
+ })),
+ ];
+
+ const expectedPage2 = [
+ ...Array.from({ length: 10 }).map((item, index) => ({
+ id: 'form:training:abc-098' + (20 + index),
+ title: 'A Title',
+ code: 'training:form-d',
+ isCompletedTraining: false,
+ duration: 9,
+ userRoles: [ 'chw' ],
+ })),
+ ];
+
+ const resultPage1 = await service.getNextTrainings(xforms, 50, 0);
+
+ expect(sessionService.userCtx.calledOnce).to.be.true;
+ expect(sessionService.hasRole.callCount).to.equal(90);
+ expect(localDb.allDocs.calledOnce).to.be.true;
+ expect(localDb.allDocs.args[0][0]).to.deep.equal({
+ include_docs: true,
+ startkey: 'training:a_user:',
+ endkey: 'training:a_user:\ufff0',
+ });
+ expect(resultPage1?.length).to.equal(50);
+ expect(resultPage1).excluding('startDate').to.have.deep.members(expectedPage1);
+
+ const resultPage2 = await service.getNextTrainings(xforms, 50, 50);
+
+ expect(resultPage2?.length).to.equal(10);
+ expect(resultPage2).excluding('startDate').to.have.deep.members(expectedPage2);
+ });
+ });
});