Skip to content

Commit d7c4f15

Browse files
authored
Merge pull request #2627 from intersective/prerelease
Release 2.4.7.3
2 parents 37e9039 + e0dd31d commit d7c4f15

7 files changed

Lines changed: 234 additions & 105 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ www/*
2727
.env
2828
projects/v3/src/environments/environment.ts
2929
projects/v3/src/environments/environment.prod.ts
30+
output/
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# CORE-8002: Pulse check popup displayed twice
2+
3+
## issue summary
4+
In a moderated assessment flow (`feedback available` -> mark review as read -> continue), learners could receive the same pulse check popup twice:
5+
1. first popup after feedback/read + review rating flow
6+
2. second popup after navigating to Home
7+
8+
## root cause
9+
`fastFeedbackOpening` was cleared too early in `FastFeedbackService.pullFastFeedback()`.
10+
11+
- the lock was set before opening the modal
12+
- it was reset in `finalize()` right after modal creation/enqueue (not after modal dismissal)
13+
- during this gap, another pulse check check (for example Home `updateDashboard()`) could run and queue/open the same pulse check again
14+
15+
## fix applied
16+
Updated `projects/v3/src/app/services/fast-feedback.service.ts`:
17+
- moved `retry` before `switchMap` so retries only re-run the API fetch, never the modal open
18+
- replaced `catchError` with `finalize` on the modal observable so the lock is released on success, error, and unsubscribe
19+
20+
### operator ordering change
21+
22+
```
23+
before (retry after switchMap — retried modal opens):
24+
fetch() → switchMap(modal()) → retry(3)
25+
26+
after (retry before switchMap — only retries fetch):
27+
fetch() → retry(3) → switchMap(modal())
28+
```
29+
30+
`retry` re-subscribes to everything **above** it in the pipe. placing it before `switchMap` means a fetch error retries the fetch, but a modal error passes through without re-triggering the entire pipeline.
31+
32+
## lock state diagram
33+
34+
the `fastFeedbackOpening` storage flag acts as a mutex to prevent concurrent modal opens:
35+
36+
```
37+
pullFastFeedback() called
38+
39+
40+
┌───────────────┐
41+
│ lock = true? │──── YES ──→ skip modal, return of(res)
42+
└───────┬───────┘
43+
│ NO
44+
45+
set lock = TRUE ← acquire
46+
47+
48+
open modal popup
49+
50+
┌───────┴────────┐
51+
│ │
52+
success error/dismiss
53+
│ │
54+
▼ ▼
55+
finalize() finalize() ← both paths release
56+
│ │
57+
▼ ▼
58+
set lock = FALSE ← release
59+
```
60+
61+
`finalize` runs when the observable completes, errors, or is unsubscribed — covering all exit paths:
62+
- user submits pulse check → observable completes → lock released
63+
- modal throws error → observable errors → lock released
64+
- user navigates away (component destroyed) → subscription unsubscribed → lock released
65+
66+
## why this is safe
67+
- preserves existing behavior for successful pulse check display
68+
- prevents duplicate modal opens from concurrent trigger paths
69+
- `finalize` guarantees lock release on every exit path — no deadlock possible
70+
71+
## reference trigger paths observed
72+
- review rating close path may request pulse check (`ReviewRatingComponent.fastFeedbackOrRedirect`)
73+
- Home dashboard load also requests pulse check (`HomePage.updateDashboard`)
74+
- assessment submission path (`AssessmentService._afterSubmit`)
75+
- review submission path (`ReviewDesktopPage`)
76+
- manual user click on traffic light (`TrafficLightGroupComponent.navigateToPulseCheck`)
77+
78+
the first two can fire back-to-back in the reported reproduce steps.
79+
80+
## verification checklist
81+
- reproduce original scenario with moderated assessment feedback-read flow
82+
- confirm only one pulse check popup is shown
83+
- confirm pulse check can still be shown later when a genuinely new pulse check is available

projects/v3/src/app/components/fast-feedback/fast-feedback.component.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,17 @@ describe('FastFeedbackComponent', () => {
8484
expect(Object.keys(component.fastFeedbackForm.controls).length).toBe(5);
8585
});
8686

87-
it('when testing dismiss(), it should dismiss', () => {
87+
it('when testing dismiss(), it should dismiss and release lock', () => {
88+
const storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj<BrowserStorageService>;
8889
component.dismiss({});
8990
expect(modalSpy.dismiss.calls.count()).toBe(1);
91+
expect(storageSpy.set).toHaveBeenCalledWith('fastFeedbackOpening', false);
92+
});
93+
94+
it('ngOnDestroy() should release fastFeedbackOpening lock', () => {
95+
const storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj<BrowserStorageService>;
96+
component.ngOnDestroy();
97+
expect(storageSpy.set).toHaveBeenCalledWith('fastFeedbackOpening', false);
9098
});
9199

92100
describe('when testing submit()', () => {

projects/v3/src/app/components/fast-feedback/fast-feedback.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ export class FastFeedbackComponent implements OnInit, OnDestroy {
279279
ngOnDestroy(): void {
280280
// Clean up ESC key listener
281281
document.removeEventListener('keydown', this.handleKeyDown);
282+
283+
// safety: release the lock if the component is destroyed without dismiss
284+
// (e.g. user navigates away while modal is still open)
285+
this.storage.set('fastFeedbackOpening', false);
282286
}
283287

284288
get isRedColor(): boolean {

projects/v3/src/app/pages/activity-desktop/activity-desktop.page.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
</ion-row>
4343
</ion-grid>
4444

45-
<ng-container *ngIf="currentTask?.type === 'Assessment'">
45+
<ng-container *ngIf="currentTask?.type === 'Assessment' && false">
46+
<!-- temporary hide fab button - assessment question navigator -->
4647
<span class="tooltip-container"
4748
[ngStyle]="tooltipStyle"
4849
*ngIf="tooltipVisible">
Lines changed: 111 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1-
import { TestBed } from '@angular/core/testing';
1+
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
22
import { FastFeedbackService } from './fast-feedback.service';
3-
import { of, throwError } from 'rxjs';
4-
import { RequestService } from 'request';
3+
import { of } from 'rxjs';
54
import { TestUtils } from '@testingv3/utils';
65
import { NotificationsService } from '@v3/services/notifications.service';
76
import { BrowserStorageService } from '@v3/services/storage.service';
87
import { UtilsService } from '@v3/services/utils.service';
8+
import { DemoService } from './demo.service';
9+
import { ApolloService } from './apollo.service';
10+
11+
// helper to build a valid pulse check API response
12+
function makePulseCheckResponse(questions: any[] = [], meta: any = null) {
13+
return {
14+
data: {
15+
pulseCheck: {
16+
questions,
17+
meta,
18+
}
19+
}
20+
};
21+
}
22+
23+
const VALID_QUESTIONS = [
24+
{ id: 7, name: 'Q1', choices: [{ id: 1, name: 'Yes' }, { id: 2, name: 'No' }] },
25+
{ id: 8, name: 'Q2', choices: [{ id: 3, name: 'Yes' }, { id: 4, name: 'No' }] },
26+
];
27+
28+
const VALID_META = { teamId: 100, teamName: 'Team A', contextId: 200 };
929

1030
describe('FastFeedbackService', () => {
1131
let service: FastFeedbackService;
12-
let requestSpy: jasmine.SpyObj<RequestService>;
32+
let apolloSpy: jasmine.SpyObj<ApolloService>;
1333
let notificationSpy: jasmine.SpyObj<NotificationsService>;
1434
let storageSpy: jasmine.SpyObj<BrowserStorageService>;
15-
const testUtils = new TestUtils();
1635

1736
beforeEach(() => {
1837
TestBed.configureTestingModule({
@@ -23,21 +42,30 @@ describe('FastFeedbackService', () => {
2342
useClass: TestUtils,
2443
},
2544
{
26-
provide: RequestService,
27-
useValue: jasmine.createSpyObj('RequestService', ['get', 'post'])
45+
provide: ApolloService,
46+
useValue: jasmine.createSpyObj('ApolloService', {
47+
graphQLFetch: of({}),
48+
graphQLMutate: of({}),
49+
}),
2850
},
2951
{
3052
provide: NotificationsService,
31-
useValue: jasmine.createSpyObj('NotificationsService', ['modal'])
53+
useValue: jasmine.createSpyObj('NotificationsService', {
54+
fastFeedbackModal: Promise.resolve(),
55+
}),
3256
},
3357
{
3458
provide: BrowserStorageService,
35-
useValue: jasmine.createSpyObj('BrowserStorageService', ['set', 'get'])
36-
}
59+
useValue: jasmine.createSpyObj('BrowserStorageService', ['set', 'get']),
60+
},
61+
{
62+
provide: DemoService,
63+
useValue: jasmine.createSpyObj('DemoService', ['fastFeedback', 'normalResponse']),
64+
},
3765
]
3866
});
3967
service = TestBed.inject(FastFeedbackService);
40-
requestSpy = TestBed.inject(RequestService) as jasmine.SpyObj<RequestService>;
68+
apolloSpy = TestBed.inject(ApolloService) as jasmine.SpyObj<ApolloService>;
4169
notificationSpy = TestBed.inject(NotificationsService) as jasmine.SpyObj<NotificationsService>;
4270
storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj<BrowserStorageService>;
4371
});
@@ -46,99 +74,102 @@ describe('FastFeedbackService', () => {
4674
expect(service).toBeTruthy();
4775
});
4876

49-
it('should get fastfeedback from API', () => {
50-
requestSpy.get.and.returnValue(of({}));
51-
service["_getFastFeedback"]().subscribe();
52-
expect(requestSpy.get.calls.count()).toBe(1);
77+
it('should fetch pulse check data from API', () => {
78+
apolloSpy.graphQLFetch.and.returnValue(of({}));
79+
service['_getFastFeedback']().subscribe();
80+
expect(apolloSpy.graphQLFetch).toHaveBeenCalledTimes(1);
5381
});
5482

55-
/*it('should open fastfeedback modal', () => {
56-
service.fastFeedbackModal();
57-
expect(notificationSpy.modal.calls.count()).toBe(1);
58-
});*/
59-
6083
describe('when testing pullFastFeedback()', () => {
61-
it('should pop up modal', () => {
62-
requestSpy.get.and.returnValue(of({
63-
data: {
64-
slider: {
65-
length: 1
66-
},
67-
meta: {
68-
any: 'data'
69-
}
70-
}
71-
}));
72-
storageSpy.get.and.returnValue(false);
73-
service.pullFastFeedback().subscribe(res => {
74-
expect(storageSpy.set.calls.count()).toBe(1);
75-
expect(notificationSpy.modal.calls.count()).toBe(1);
84+
it('should open modal and set lock when pulse check data is valid', () => {
85+
apolloSpy.graphQLFetch.and.returnValue(of(makePulseCheckResponse(VALID_QUESTIONS, VALID_META)));
86+
storageSpy.get.and.returnValue(false); // fastFeedbackOpening = false
87+
88+
service.pullFastFeedback().subscribe(() => {
89+
// should set fastFeedbackOpening = true
90+
expect(storageSpy.set).toHaveBeenCalledWith('fastFeedbackOpening', true);
91+
// should call fastFeedbackModal
92+
expect(notificationSpy.fastFeedbackModal).toHaveBeenCalledTimes(1);
7693
});
7794
});
7895

79-
it('should not pop up modal when slider object length is 0', () => {
80-
requestSpy.get.and.returnValue(of({
81-
data: {
82-
slider: {
83-
length: 0
84-
}
85-
}
86-
}));
96+
it('should NOT release the lock after modal is opened (fire-and-forget)', fakeAsync(() => {
97+
apolloSpy.graphQLFetch.and.returnValue(of(makePulseCheckResponse(VALID_QUESTIONS, VALID_META)));
8798
storageSpy.get.and.returnValue(false);
88-
service.pullFastFeedback().subscribe(res => {
89-
expect(storageSpy.set.calls.count()).toBe(0);
90-
expect(notificationSpy.modal.calls.count()).toBe(0);
99+
100+
service.pullFastFeedback().subscribe();
101+
tick();
102+
103+
// lock is set to true and never released by the service
104+
const setCalls = storageSpy.set.calls.allArgs();
105+
const lockCalls = setCalls.filter(args => args[0] === 'fastFeedbackOpening');
106+
expect(lockCalls.length).toBe(1);
107+
expect(lockCalls[0]).toEqual(['fastFeedbackOpening', true]);
108+
}));
109+
110+
it('should not open modal when fastFeedbackOpening is already true', () => {
111+
apolloSpy.graphQLFetch.and.returnValue(of(makePulseCheckResponse(VALID_QUESTIONS, VALID_META)));
112+
storageSpy.get.and.returnValue(true); // lock already held
113+
114+
service.pullFastFeedback().subscribe(() => {
115+
expect(notificationSpy.fastFeedbackModal).not.toHaveBeenCalled();
91116
});
92117
});
93118

94-
it('should not pop up modal when get storage returns false', () => {
95-
requestSpy.get.and.returnValue(throwError(''));
119+
it('should not open modal when pulseCheck data is empty', () => {
120+
apolloSpy.graphQLFetch.and.returnValue(of({ data: { pulseCheck: null } }));
96121
storageSpy.get.and.returnValue(false);
97-
service.pullFastFeedback().subscribe(res => {
98-
expect(storageSpy.set.calls.count()).toBe(0);
99-
expect(notificationSpy.modal.calls.count()).toBe(0);
122+
123+
service.pullFastFeedback().subscribe(() => {
124+
expect(notificationSpy.fastFeedbackModal).not.toHaveBeenCalled();
100125
});
101126
});
102127

103-
it('should not popup modal when slider & meta are not available', () => {
104-
requestSpy.get.and.returnValue(of({
105-
data: {
106-
slider: undefined,
107-
meta: undefined,
108-
}
109-
}));
128+
it('should not open modal when questions are empty', () => {
129+
apolloSpy.graphQLFetch.and.returnValue(of(makePulseCheckResponse([], VALID_META)));
130+
storageSpy.get.and.returnValue(false);
110131

111-
service.pullFastFeedback().subscribe(res => {
112-
expect(notificationSpy.modal).not.toHaveBeenCalled();
132+
service.pullFastFeedback().subscribe(() => {
133+
expect(notificationSpy.fastFeedbackModal).not.toHaveBeenCalled();
113134
});
114135
});
115136

116-
it('should not popup modal when slider is not available', () => {
117-
requestSpy.get.and.returnValue(of({
118-
data: {
119-
slider: [],
120-
meta: { hasValue: true },
121-
}
122-
}));
137+
it('should not open modal when meta is empty', () => {
138+
apolloSpy.graphQLFetch.and.returnValue(of(makePulseCheckResponse(VALID_QUESTIONS, null)));
139+
storageSpy.get.and.returnValue(false);
123140

124-
service.pullFastFeedback().subscribe(res => {
125-
expect(notificationSpy.modal).not.toHaveBeenCalled();
141+
service.pullFastFeedback().subscribe(() => {
142+
expect(notificationSpy.fastFeedbackModal).not.toHaveBeenCalled();
126143
});
127144
});
128145

129-
it('should not popup modal when meta is not available', () => {
130-
requestSpy.get.and.returnValue(of({
131-
data: {
132-
slider: [1, 2],
133-
meta: undefined,
134-
}
135-
}));
146+
it('should release lock on modal open error', fakeAsync(() => {
147+
apolloSpy.graphQLFetch.and.returnValue(of(makePulseCheckResponse(VALID_QUESTIONS, VALID_META)));
148+
storageSpy.get.and.returnValue(false);
149+
notificationSpy.fastFeedbackModal.and.returnValue(Promise.reject('modal error'));
150+
151+
service.pullFastFeedback().subscribe();
152+
tick(); // resolve rejected promise
153+
154+
const setCalls = storageSpy.set.calls.allArgs();
155+
const lockCalls = setCalls.filter(args => args[0] === 'fastFeedbackOpening');
156+
// first set to true, then released to false on error
157+
expect(lockCalls).toEqual([
158+
['fastFeedbackOpening', true],
159+
['fastFeedbackOpening', false],
160+
]);
161+
}));
162+
});
163+
164+
describe('when testing submit()', () => {
165+
it('should call graphQLMutate with answers and params', () => {
166+
const answers = [{ questionId: 7, choiceId: 1 }];
167+
const params = { teamId: 100, contextId: 200 };
168+
apolloSpy.graphQLMutate.and.returnValue(of({ data: { submitPulseCheck: true } }));
136169

137-
service.pullFastFeedback().subscribe(res => {
138-
expect(notificationSpy.modal).not.toHaveBeenCalled();
170+
service.submit(answers, params).subscribe(() => {
171+
expect(apolloSpy.graphQLMutate).toHaveBeenCalledTimes(1);
139172
});
140173
});
141174
});
142-
143-
144175
});

0 commit comments

Comments
 (0)