Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(#7250): race condition in loading contacts #8234

Merged
merged 9 commits into from
May 15, 2023
6 changes: 6 additions & 0 deletions webapp/src/ts/actions/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const Actions = {
selectContact: createMultiValueAction('SELECT_CONTACT'),
setSelectedContact: createSingleValueAction('SET_SELECTED_CONTACT', 'selected'),
setContactsLoadingSummary: createSingleValueAction('SET_CONTACT_LOADING_SUMMARY', 'value'),
setContactIdToLoad: createSingleValueAction('SET_CONTACT_ID_TO_LOAD', 'id'),
setLoadingSelectedContact: createAction('SET_LOADING_SELECTED_CONTACT'),
receiveSelectedContactChildren: createSingleValueAction('RECEIVE_SELECTED_CONTACT_CHILDREN', 'children'),
receiveSelectedContactReports: createSingleValueAction('RECEIVE_SELECTED_CONTACT_REPORTS', 'reports'),
Expand All @@ -22,11 +23,16 @@ export class ContactsActions {
private store: Store
) {}

setContactIdToLoad(id) {
return this.store.dispatch(Actions.setContactIdToLoad(id));
}

updateContactsList(contacts) {
return this.store.dispatch(Actions.updateContactsList(contacts));
}

clearSelection() {
this.store.dispatch(Actions.setContactIdToLoad(null));
return this.store.dispatch(Actions.setSelectedContact(null));
}

Expand Down
37 changes: 24 additions & 13 deletions webapp/src/ts/effects/contacts.effects.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store, select } from '@ngrx/store';
import { of } from 'rxjs';
import { combineLatest, of } from 'rxjs';
import { exhaustMap, withLatestFrom } from 'rxjs/operators';

import { Actions as ContactActionList, ContactsActions } from '@mm-actions/contacts';
Expand All @@ -16,10 +16,11 @@ import { TranslateService } from '@mm-services/translate.service';

@Injectable()
export class ContactsEffects {
private contactsActions;
private globalActions;
private contactsActions: ContactsActions;
private globalActions: GlobalActions;

private selectedContact;
private contactIdToLoad;

constructor(
private actions$: Actions,
Expand All @@ -34,9 +35,13 @@ export class ContactsEffects {
this.contactsActions = new ContactsActions(store);
this.globalActions = new GlobalActions(store);

this.store
.select(Selectors.getSelectedContact)
.subscribe(selectedContact => this.selectedContact = selectedContact);
combineLatest(
this.store.select(Selectors.getSelectedContact),
this.store.select(Selectors.getContactIdToLoad),
).subscribe(([ selectedContact, contactIdToLoad ]) => {
this.selectedContact = selectedContact;
this.contactIdToLoad = contactIdToLoad;
});
}

selectContact = createEffect(() => {
Expand All @@ -59,6 +64,8 @@ export class ContactsEffects {

const loadContact = this
.loadContact(id)
.then(() => this.verifySelectedContactNotChanged(id))
.then(() => this.setTitle())
.then(() => this.loadChildren(id, userFacilityId))
.then(() => this.loadReports(id, forms))
.then(() => this.loadTargetDoc(id))
Expand All @@ -74,34 +81,38 @@ export class ContactsEffects {
}
console.error('Error selecting contact', err);
this.globalActions.unsetSelected();
return of(this.contactsActions.setSelectedContact(null));
return of(this.contactsActions.clearSelection());
});

return of(loadContact);
}),
);
}, { dispatch: false });

private setTitle(selected) {
private setTitle() {
const routeSnapshot = this.routeSnapshotService.get();
const deceasedTitle = routeSnapshot?.data?.name === 'contacts.deceased'
? this.translateService.instant('contact.deceased.title') : null;
const title = deceasedTitle || selected.type?.name_key || 'contact.profile';
const title = deceasedTitle || this.selectedContact.type?.name_key || 'contact.profile';
this.globalActions.setTitle(this.translateService.instant(title));
}

private loadContact(id) {
this.contactsActions.setContactIdToLoad(id);
return this.contactViewModelGeneratorService
.getContact(id, { merge: false })
.then(model => {
this.globalActions.settingSelected();
this.contactsActions.setSelectedContact(model);
this.setTitle(model);
return this
.verifySelectedContactNotChanged(model._id)
.then(() => {
this.globalActions.settingSelected();
this.contactsActions.setSelectedContact(model);
});
});
}

private verifySelectedContactNotChanged(id) {
return this.selectedContact?._id !== id ? Promise.reject({code: 'SELECTED_CONTACT_CHANGED'}) : Promise.resolve();
return this.contactIdToLoad !== id ? Promise.reject({code: 'SELECTED_CONTACT_CHANGED'}) : Promise.resolve();
}

private loadChildren(contactId, userFacilityId) {
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/ts/modules/contacts/contacts-content.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class ContactsContentComponent implements OnInit, OnDestroy {

ngOnDestroy() {
this.subscription.unsubscribe();
this.contactsActions.setSelectedContact(null);
this.contactsActions.clearSelection();
this.globalActions.setRightActionBar({});
}

Expand Down Expand Up @@ -206,7 +206,7 @@ export class ContactsContentComponent implements OnInit, OnDestroy {

$('.tooltip').remove();
} else {
this.contactsActions.setSelectedContact(null);
this.contactsActions.clearSelection();
this.globalActions.unsetSelected();
}
});
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/ts/reducers/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Actions } from '@mm-actions/contacts';
const initialState = {
contacts: [],
contactsById: new Map(),
contactIdToLoad: null,
selected: null,
filters: {},
loadingSelectedChildren: false,
Expand Down Expand Up @@ -140,6 +141,7 @@ const receiveSelectedContactTargetDoc = (state, targetDoc) => {

const _contactsReducer = createReducer(
initialState,
on(Actions.setContactIdToLoad, (state, { payload: { id } }) => ({ ...state, contactIdToLoad: id })),
on(Actions.updateContactsList, (state, { payload: { contacts } }) => updateContacts(state, contacts)),
on(Actions.resetContactsList, (state) => ({ ...state, contacts: [], contactsById: new Map() })),
on(Actions.removeContactFromList, (state, { payload: { contact } }) => removeContact(state, contact)),
Expand Down
1 change: 1 addition & 0 deletions webapp/src/ts/selectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const Selectors = {
contactListContains: createSelector(getContactsState, (contactsState) => {
return (id) => contactsState.contactsById.has(id);
}),
getContactIdToLoad: createSelector(getContactsState, (contactState) => contactState.contactIdToLoad),
getSelectedContact: createSelector(getContactsState, (contactState) => contactState.selected),
getSelectedContactDoc: createSelector(getContactsState, (contactState) => contactState.selected?.doc),
getSelectedContactSummary: createSelector(getContactsState, (contactState) => contactState.selected?.summary),
Expand Down
25 changes: 18 additions & 7 deletions webapp/tests/karma/ts/effects/contacts.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,16 @@ describe('Contacts effects', () => {
describe('selectContact', () => {
let setLoadingSelectedContact;
let setContactsLoadingSummary;
let clearSelection;
let clearSelectionStub;
let setContactIdToLoadStub;

const simulateContactsReducer = () => {
let selectedContact = null;
let contactIdToLoad = null;

const refreshState = () => {
store.overrideSelector(Selectors.getSelectedContact, selectedContact);
store.overrideSelector(Selectors.getContactIdToLoad, contactIdToLoad);
store.refreshState();
};
refreshState();
Expand Down Expand Up @@ -116,21 +119,27 @@ describe('Contacts effects', () => {
selectedContact = { ...selectedContact, summary };
refreshState();
});

setContactIdToLoadStub.callsFake(id => {
contactIdToLoad = id;
refreshState();
});
};

beforeEach(() => {
setLoadingSelectedContact = sinon.stub(ContactsActions.prototype, 'setLoadingSelectedContact');
setContactsLoadingSummary = sinon.stub(ContactsActions.prototype, 'setContactsLoadingSummary');
setContactIdToLoadStub = sinon.stub(ContactsActions.prototype, 'setContactIdToLoad');
clearSelectionStub = sinon.stub(ContactsActions.prototype, 'clearSelection');
simulateContactsReducer();
});

it('should deselect when no provided id', waitForAsync(() => {
clearSelection = sinon.stub(ContactsActions.prototype, 'clearSelection');
actions$ = of(ContactActionList.selectContact({ }));
effects.selectContact.subscribe();

expect(clearSelection.callCount).to.equal(1);
expect(contactViewModelGeneratorService.getContact.callCount).to.equal(0);
expect(clearSelectionStub.calledOnce).to.be.true;
expect(contactViewModelGeneratorService.getContact.notCalled).to.be.true;
}));

it('should load the contact when not silent', async () => {
Expand All @@ -153,6 +162,8 @@ describe('Contacts effects', () => {
expect(setContactsLoadingSummary.args).to.deep.equal([[true], [false]]);
expect(settingSelected.callCount).to.equal(1);
expect(settingSelected.args[0]).to.deep.equal([]);
expect(setContactIdToLoadStub.calledOnce).to.be.true;
expect(setContactIdToLoadStub.args[0][0]).to.equal('contactid');
});

it('should load the contact when silent', async () => {
Expand All @@ -173,13 +184,14 @@ describe('Contacts effects', () => {
expect(setLoadingSelectedContact.callCount).to.equal(0);
expect(settingSelected.callCount).to.equal(1);
expect(settingSelected.args[0]).to.deep.equal([]);
expect(setContactIdToLoadStub.calledOnce).to.be.true;
expect(setContactIdToLoadStub.args[0][0]).to.equal('contactid');
});

it('should handle missing contacts', fakeAsync(() => {
const consoleErrorMock = sinon.stub(console, 'error');
const setSnackbarContent = sinon.stub(GlobalActions.prototype, 'setSnackbarContent');
const unsetSelected = sinon.stub(GlobalActions.prototype, 'unsetSelected');
const setSelectedContact:any = ContactsActions.prototype.setSelectedContact;
contactViewModelGeneratorService.getContact.rejects({ code: 404, error: 'not found'});
actions$ = of(ContactActionList.selectContact({ id: 'contactid', silent: false }));
effects.selectContact.subscribe();
Expand All @@ -189,8 +201,7 @@ describe('Contacts effects', () => {
expect(consoleErrorMock.args[0][0]).to.equal('Error selecting contact');
expect(setSnackbarContent.callCount).to.equal(1);
expect(unsetSelected.callCount).to.equal(1);
expect(setSelectedContact.callCount).to.equal(1);
expect(setSelectedContact.args[0][0]).to.equal(null);
expect(clearSelectionStub.calledOnce).to.be.true;
expect(contactViewModelGeneratorService.getContact.callCount).to.equal(1);
expect(contactViewModelGeneratorService.loadChildren.callCount).to.equal(0);
expect(contactViewModelGeneratorService.loadReports.callCount).to.equal(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ describe('Contacts content component', () => {
};
globalActions = {
setRightActionBar: sinon.spy(GlobalActions.prototype, 'setRightActionBar'),
updateRightActionBar: sinon.spy(GlobalActions.prototype, 'updateRightActionBar')
updateRightActionBar: sinon.spy(GlobalActions.prototype, 'updateRightActionBar'),
unsetSelected: sinon.spy(GlobalActions.prototype, 'unsetSelected'),
};
mutingTransition = { isUnmuteForm: sinon.stub() };
contactMutedService = { getMuted: sinon.stub() };
Expand All @@ -105,7 +106,7 @@ describe('Contacts content component', () => {
{ selector: Selectors.getSelectedContactChildren, value: null },
{ selector: Selectors.getFilters, value: {} },
];
activatedRoute = { params: of({ id: 'load contact' }), snapshot: { params: { id: 'load contact'} } };
activatedRoute = { params: of({}), snapshot: { params: {} } };
router = { navigate: sinon.stub() };
responsiveService = { isMobile: sinon.stub() };

Expand Down Expand Up @@ -155,6 +156,19 @@ describe('Contacts content component', () => {
expect(component).to.exist;
});

it('ngOnDestroy() should unsubscribe from observables and reset state', () => {
const unsubscribeSpy = sinon.spy(component.subscription, 'unsubscribe');
const clearSelectionStub = sinon.stub(ContactsActions.prototype, 'clearSelection');
sinon.resetHistory();

component.ngOnDestroy();

expect(unsubscribeSpy.calledOnce).to.be.true;
expect(clearSelectionStub.calledOnce).to.be.true;
expect(globalActions.setRightActionBar.calledOnce).to.be.true;
expect(globalActions.setRightActionBar.args[0][0]).to.deep.equal({});
});

describe('load the user home place on mobile', () => {
it(`should not load the user's home place when on mobile`, fakeAsync(() => {
const selectContact = sinon.stub(ContactsActions.prototype, 'selectContact');
Expand All @@ -172,11 +186,14 @@ describe('Contacts content component', () => {
it(`should not load the user's home place when a param id is set`, fakeAsync(() => {
const selectContact = sinon.stub(ContactsActions.prototype, 'selectContact');
store.overrideSelector(Selectors.getUserFacilityId, 'homeplace');
activatedRoute.params = of({ id: 'contact-1234' });
activatedRoute.snapshot.params = { id: 'contact-1234' };

component.ngOnInit();
flush();

expect(selectContact.callCount).to.equal(1);
expect(selectContact.args[0][0]).to.equal('load contact');
expect(selectContact.calledOnce).to.be.true;
expect(selectContact.args[0][0]).to.equal('contact-1234');
}));

it(`should not load the user's home place when a search term exists`, fakeAsync(() => {
Expand All @@ -186,8 +203,7 @@ describe('Contacts content component', () => {
component.ngOnInit();
flush();

expect(selectContact.callCount).to.equal(1);
expect(selectContact.args[0][0]).to.equal('load contact');
expect(selectContact.notCalled).to.be.true;
}));

it(`should load the user's home place when a param id not set and no search term exists`, fakeAsync(() => {
Expand All @@ -203,6 +219,18 @@ describe('Contacts content component', () => {
expect(selectContact.args[0][0]).to.equal('homeplace');
}));

it('should unset selected contact when a param id not set and no search term exists', fakeAsync(() => {
const clearSelectionStub = sinon.stub(ContactsActions.prototype, 'clearSelection');
store.overrideSelector(Selectors.getFilters, undefined);
sinon.resetHistory();

component.ngOnInit();
flush();

expect(globalActions.unsetSelected.calledOnce).to.be.true;
expect(clearSelectionStub.calledOnce).to.be.true;
}));

describe('Change feed process', () => {
let change;

Expand Down
Loading