Skip to content

Commit e0dd31d

Browse files
authored
Merge pull request #2628 from intersective/2.4.y.z/CORE-7940/persistence-dual-pulsecheck
[CORE-7940] fix: release fastFeedbackOpening lock on dismiss and destroy
2 parents 536b60c + cb9981e commit e0dd31d

4 files changed

Lines changed: 144 additions & 100 deletions

File tree

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 {
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
});

projects/v3/src/app/services/fast-feedback.service.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { Injectable } from '@angular/core';
22
import { NotificationsService } from './notifications.service';
33
import { BrowserStorageService } from '@v3/services/storage.service';
44
import { UtilsService } from '@v3/services/utils.service';
5-
import { of, from, Observable } from 'rxjs';
6-
import { switchMap, retry, finalize } from 'rxjs/operators';
5+
import { of, Observable } from 'rxjs';
6+
import { switchMap, retry } from 'rxjs/operators';
77
import { environment } from '@v3/environments/environment';
88
import { DemoService } from './demo.service';
99
import { ApolloService } from './apollo.service';
@@ -124,25 +124,26 @@ export class FastFeedbackService {
124124
questions?.length > 0 &&
125125
!fastFeedbackIsOpened
126126
) {
127-
// add a flag to indicate that a fast feedback pop up is opening
127+
// set a flag to indicate a fast feedback modal is currently opening to prevent duplicates.
128+
// the lock stays true until FastFeedbackComponent.dismiss() releases it.
128129
this.storage.set("fastFeedbackOpening", true);
129130

130-
return from(
131-
this.notificationsService.fastFeedbackModal(
132-
{
133-
questions,
134-
meta,
135-
},
136-
{
137-
closable: options.closable,
138-
modalOnly: options.modalOnly,
139-
}
140-
)
141-
).pipe(
142-
finalize(() => {
143-
this.storage.set("fastFeedbackOpening", false);
144-
})
145-
);
131+
// fire-and-forget: addModal() resolves immediately (before modal is dismissed),
132+
// so we must NOT use from(promise).pipe(finalize(...)) — that would release the
133+
// lock within 1 ms, defeating the duplicate-open guard.
134+
this.notificationsService.fastFeedbackModal(
135+
{
136+
questions,
137+
meta,
138+
},
139+
{
140+
closable: options.closable,
141+
modalOnly: options.modalOnly,
142+
}
143+
).catch(() => {
144+
// release the lock only if the modal fails to open
145+
this.storage.set("fastFeedbackOpening", false);
146+
});
146147
}
147148
return of(res);
148149
} catch (error) {

0 commit comments

Comments
 (0)