From 2e26881a31e9376a347e1a83b1bb2346820a8eec Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Mon, 24 Jun 2024 15:47:53 -0400 Subject: [PATCH] feat(surveys): add configurable delay to popup surveys (#1228) * this works a treat * this works without looping survey send events * some conversion magic * okay so this appears to work now * oops, don't need this * added better testing, woohoo * this WORKS * confirmed that this works for my use case * this works for all the use cases * bring the comments back * more docs, more tests * reference refactor in comment * tests aren't ready * one more thing * changing a type name * added a bunch of tests for new behavior * better docs * Update src/__tests__/extensions/surveys.test.ts Co-authored-by: Neil Kakkar * Update src/__tests__/extensions/surveys.test.ts Co-authored-by: Neil Kakkar * Update src/__tests__/extensions/surveys.test.ts Co-authored-by: Neil Kakkar * fixed the test * addressed the rest of neil's feedback * use a string | null instead of a set --------- Co-authored-by: Neil Kakkar --- package.json | 2 + pnpm-lock.yaml | 103 ++++ src/__tests__/extensions/surveys.test.ts | 413 +++++++++++++++- src/extensions/surveys.tsx | 463 ++++++++++++------ .../surveys/components/QuestionTypes.tsx | 2 +- src/posthog-surveys-types.ts | 3 +- 6 files changed, 818 insertions(+), 168 deletions(-) diff --git a/package.json b/package.json index 69ff2e6d9..5a546a9a0 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "@rrweb/types": "2.0.0-alpha.13", "@sentry/types": "8.7.0", "@testing-library/dom": "^9.3.0", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/preact": "^3.2.4", "@types/eslint": "^8.44.6", "@types/jest": "^29.5.1", "@types/react-dom": "^18.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0aae4f925..888dbd591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,12 @@ devDependencies: '@testing-library/dom': specifier: ^9.3.0 version: 9.3.0 + '@testing-library/jest-dom': + specifier: ^6.4.5 + version: 6.4.5(@types/jest@29.5.1)(jest@27.5.1) + '@testing-library/preact': + specifier: ^3.2.4 + version: 3.2.4(preact@10.19.3) '@types/eslint': specifier: ^8.44.6 version: 8.44.6 @@ -212,6 +218,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /@adobe/css-tools@4.4.0: + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + dev: true + /@ampproject/remapping@2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -2713,6 +2723,20 @@ packages: resolution: {integrity: sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==} dev: true + /@testing-library/dom@8.20.1: + resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==} + engines: {node: '>=12'} + dependencies: + '@babel/code-frame': 7.23.5 + '@babel/runtime': 7.13.9 + '@types/aria-query': 5.0.1 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + /@testing-library/dom@9.3.0: resolution: {integrity: sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==} engines: {node: '>=14'} @@ -2727,6 +2751,49 @@ packages: pretty-format: 27.5.1 dev: true + /@testing-library/jest-dom@6.4.5(@types/jest@29.5.1)(jest@27.5.1): + resolution: {integrity: sha512-AguB9yvTXmCnySBP1lWjfNNUwpbElsaQ567lt2VdGqAdHtpieLgjmcVyv1q7PMIvLbgpDdkWV5Ydv3FEejyp2A==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@jest/globals': '>= 28' + '@types/bun': latest + '@types/jest': '>= 28' + jest: '>= 28' + vitest: '>= 0.32' + peerDependenciesMeta: + '@jest/globals': + optional: true + '@types/bun': + optional: true + '@types/jest': + optional: true + jest: + optional: true + vitest: + optional: true + dependencies: + '@adobe/css-tools': 4.4.0 + '@babel/runtime': 7.13.9 + '@types/jest': 29.5.1 + aria-query: 5.1.3 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + jest: 27.5.1 + lodash: 4.17.21 + redent: 3.0.0 + dev: true + + /@testing-library/preact@3.2.4(preact@10.19.3): + resolution: {integrity: sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==} + engines: {node: '>= 12'} + peerDependencies: + preact: '>=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0' + dependencies: + '@testing-library/dom': 8.20.1 + preact: 10.19.3 + dev: true + /@tootallnate/once@1.1.2: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -4030,6 +4097,14 @@ packages: supports-color: 5.5.0 dev: true + /chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.2.1 + supports-color: 7.1.0 + dev: true + /chalk@4.1.1: resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==} engines: {node: '>=10'} @@ -4362,6 +4437,10 @@ packages: engines: {iojs: '>=1.0.0', node: '>=0.5.2'} dev: true + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + /css@2.2.3: resolution: {integrity: sha512-0W171WccAjQGGTKLhw4m2nnl0zPHUlTO/I8td4XzJgIB8Hg3ZZx71qT4G4eX8OVsSiaAKiUMy73E3nsbPlg2DQ==} dependencies: @@ -4722,6 +4801,10 @@ packages: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dev: true + /dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + /dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} dev: true @@ -7951,6 +8034,11 @@ packages: dom-walk: 0.1.2 dev: true + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -8885,6 +8973,14 @@ packages: picomatch: 2.3.1 dev: true + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + /regenerate-unicode-properties@10.0.1: resolution: {integrity: sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==} engines: {node: '>=4'} @@ -9755,6 +9851,13 @@ packages: engines: {node: '>=6'} dev: true + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} diff --git a/src/__tests__/extensions/surveys.test.ts b/src/__tests__/extensions/surveys.test.ts index 1ca29ce76..79b17ac32 100644 --- a/src/__tests__/extensions/surveys.test.ts +++ b/src/__tests__/extensions/surveys.test.ts @@ -1,6 +1,18 @@ -import { generateSurveys, renderSurveysPreview, renderFeedbackWidgetPreview } from '../../extensions/surveys' +import { + generateSurveys, + renderSurveysPreview, + renderFeedbackWidgetPreview, + usePopupVisibility, + SurveyManager, +} from '../../extensions/surveys' import { createShadow } from '../../extensions/surveys/surveys-utils' import { Survey, SurveyQuestionType, SurveyType } from '../../posthog-surveys-types' +import { renderHook, act } from '@testing-library/preact' + +import '@testing-library/jest-dom' +import { PostHog } from '../../posthog-core' + +declare const global: any describe('survey display logic', () => { beforeEach(() => { @@ -17,42 +29,419 @@ describe('survey display logic', () => { expect(mockShadow.host.className).toBe(`PostHogSurvey${surveyId}`) }) - const mockSurveys: any[] = [ + const mockSurveys: Survey[] = [ { id: 'testSurvey1', name: 'Test survey 1', + description: 'Test survey description 1', type: SurveyType.Popover, - appearance: null, - start_date: '2021-01-01T00:00:00.000Z', + linked_flag_key: null, + targeting_flag_key: null, + internal_targeting_flag_key: null, questions: [ { question: 'How satisfied are you with our newest product?', description: 'This is a question description', - type: 'rating', + descriptionContentType: 'text', + type: SurveyQuestionType.Rating, display: 'number', scale: 10, - lower_bound_label: 'Not Satisfied', - upper_bound_label: 'Very Satisfied', + lowerBoundLabel: 'Not Satisfied', + upperBoundLabel: 'Very Satisfied', + originalQuestionIndex: 0, }, ], + appearance: null, + conditions: null, + start_date: '2021-01-01T00:00:00.000Z', + end_date: null, + current_iteration: null, + current_iteration_start_date: null, }, ] + const mockPostHog = { getActiveMatchingSurveys: jest.fn().mockImplementation((callback) => callback(mockSurveys)), get_session_replay_url: jest.fn(), capture: jest.fn().mockImplementation((eventName) => eventName), - } + } as unknown as PostHog - test('callSurveys runs on interval irrespective of url change', () => { + test('callSurveysAndEvaluateDisplayLogic runs on interval irrespective of url change', () => { jest.useFakeTimers() jest.spyOn(global, 'setInterval') generateSurveys(mockPostHog) expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(1) - expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 3000) + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 1000) - jest.advanceTimersByTime(3000) + jest.advanceTimersByTime(1000) expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(2) - expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 3000) + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 1000) + }) +}) + +describe('usePopupVisibility', () => { + const mockSurvey: Survey = { + id: 'testSurvey1', + name: 'Test survey 1', + description: 'Test survey description 1', + type: SurveyType.Popover, + linked_flag_key: null, + targeting_flag_key: null, + internal_targeting_flag_key: null, + questions: [ + { + question: 'How satisfied are you with our newest product?', + description: 'This is a question description', + descriptionContentType: 'text', + type: SurveyQuestionType.Rating, + display: 'number', + scale: 10, + lowerBoundLabel: 'Not Satisfied', + upperBoundLabel: 'Very Satisfied', + originalQuestionIndex: 0, + }, + ], + appearance: {}, + conditions: null, + start_date: '2021-01-01T00:00:00.000Z', + end_date: null, + current_iteration: null, + current_iteration_start_date: null, + } + const mockPostHog = { + getActiveMatchingSurveys: jest.fn().mockImplementation((callback) => callback([mockSurvey])), + get_session_replay_url: jest.fn(), + capture: jest.fn().mockImplementation((eventName) => eventName), + } as unknown as PostHog + + const removeSurvey = jest.fn() + + test('should set isPopupVisible to true immediately if delay is 0', () => { + const { result } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 0, false, removeSurvey)) + expect(result.current.isPopupVisible).toBe(true) + }) + + test('should set isPopupVisible to true after delay', () => { + jest.useFakeTimers() + const { result } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 1000, false, removeSurvey)) + expect(result.current.isPopupVisible).toBe(false) + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(result.current.isPopupVisible).toBe(true) + jest.useRealTimers() + }) + + test('should hide popup when PHSurveyClosed event is dispatched', () => { + const { result } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 0, false, removeSurvey)) + act(() => { + window.dispatchEvent(new Event('PHSurveyClosed')) + }) + expect(result.current.isPopupVisible).toBe(false) + }) + + test('should show thank you message when survey is sent and handle auto disappear', () => { + jest.useFakeTimers() + mockSurvey.appearance = { + displayThankYouMessage: true, + autoDisappear: true, + thankYouMessageHeader: 'Thank you!', + thankYouMessageDescription: 'We appreciate your feedback.', + } + + const { result } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 0, false, removeSurvey)) + act(() => { + window.dispatchEvent(new Event('PHSurveySent')) + }) + + expect(result.current.isSurveySent).toBe(true) + expect(result.current.isPopupVisible).toBe(true) + + act(() => { + jest.advanceTimersByTime(5000) + }) + + expect(result.current.isPopupVisible).toBe(false) + jest.useRealTimers() + }) + + test('should clean up event listeners and timers on unmount', () => { + jest.useFakeTimers() + const { unmount } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 1000, false, removeSurvey)) + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('PHSurveyClosed', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('PHSurveySent', expect.any(Function)) + jest.useRealTimers() + }) + + test('should set isPopupVisible to true if isPreviewMode is true', () => { + const { result } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 1000, true, removeSurvey)) + expect(result.current.isPopupVisible).toBe(true) + }) + + test('should set isPopupVisible to true after a delay of 500 milliseconds', () => { + jest.useFakeTimers() + const { result } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 500, false, removeSurvey)) + expect(result.current.isPopupVisible).toBe(false) + act(() => { + jest.advanceTimersByTime(500) + }) + expect(result.current.isPopupVisible).toBe(true) + jest.useRealTimers() + }) + + test('should not throw an error if posthog is undefined', () => { + const { result } = renderHook(() => usePopupVisibility(mockSurvey, undefined, 0, false, removeSurvey)) + expect(result.current.isPopupVisible).toBe(true) + }) + + test('should clean up event listeners on unmount when delay is 0', () => { + const { unmount } = renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 0, false, removeSurvey)) + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener') + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('PHSurveyClosed', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('PHSurveySent', expect.any(Function)) + }) + + test('should dispatch PHSurveyShown event when survey is shown', () => { + const dispatchEventSpy = jest.spyOn(window, 'dispatchEvent') + renderHook(() => usePopupVisibility(mockSurvey, mockPostHog, 0, false, removeSurvey)) + + expect(dispatchEventSpy).toHaveBeenCalledWith(new Event('PHSurveyShown')) + }) + + test('should handle multiple surveys with overlapping conditions', () => { + jest.useFakeTimers() + const mockSurvey2 = { ...mockSurvey, id: 'testSurvey2', name: 'Test survey 2' } as Survey + const { result: result1 } = renderHook(() => + usePopupVisibility(mockSurvey, mockPostHog, 0, false, removeSurvey) + ) + const { result: result2 } = renderHook(() => + usePopupVisibility(mockSurvey2, mockPostHog, 500, false, removeSurvey) + ) + + expect(result1.current.isPopupVisible).toBe(true) + expect(result2.current.isPopupVisible).toBe(false) + + act(() => { + jest.advanceTimersByTime(500) + }) + + expect(result2.current.isPopupVisible).toBe(true) + jest.useRealTimers() + }) +}) + +describe('SurveyManager', () => { + let mockPostHog: PostHog + let surveyManager: SurveyManager + let mockSurveys: Survey[] + + beforeEach(() => { + mockPostHog = { + getActiveMatchingSurveys: jest.fn(), + get_session_replay_url: jest.fn(), + capture: jest.fn(), + } as unknown as PostHog + + surveyManager = new SurveyManager(mockPostHog) + + mockSurveys = [ + { + id: 'testSurvey1', + name: 'Test survey 1', + description: 'Test survey description 1', + type: SurveyType.Popover, + linked_flag_key: null, + targeting_flag_key: null, + internal_targeting_flag_key: null, + questions: [ + { + question: 'How satisfied are you with our newest product?', + description: 'This is a question description', + descriptionContentType: 'text', + type: SurveyQuestionType.Rating, + display: 'number', + scale: 10, + lowerBoundLabel: 'Not Satisfied', + upperBoundLabel: 'Very Satisfied', + originalQuestionIndex: 0, + }, + ], + appearance: {}, + conditions: null, + start_date: '2021-01-01T00:00:00.000Z', + end_date: null, + current_iteration: null, + current_iteration_start_date: null, + }, + ] + }) + + test('callSurveysAndEvaluateDisplayLogic should handle a single popover survey correctly', () => { + mockPostHog.getActiveMatchingSurveys = jest.fn((callback) => callback([mockSurveys[0]])) + const handlePopoverSurveyMock = jest + .spyOn(surveyManager as any, 'handlePopoverSurvey') + .mockImplementation(() => {}) + const canShowNextEventBasedSurveyMock = jest + .spyOn(surveyManager as any, 'canShowNextEventBasedSurvey') + .mockReturnValue(true) + + surveyManager.callSurveysAndEvaluateDisplayLogic() + + expect(mockPostHog.getActiveMatchingSurveys).toHaveBeenCalled() + expect(handlePopoverSurveyMock).toHaveBeenCalledWith(mockSurveys[0]) + expect(canShowNextEventBasedSurveyMock).toHaveBeenCalled() + }) + + test('should initialize surveyInFocus correctly', () => { + expect(surveyManager).toBeDefined() + expect(typeof surveyManager.getTestAPI().addSurveyToFocus).toBe('function') + expect(typeof surveyManager.getTestAPI().removeSurveyFromFocus).toBe('function') + expect(typeof surveyManager.callSurveysAndEvaluateDisplayLogic).toBe('function') + expect(surveyManager.getTestAPI().surveyInFocus).toBe(null) + }) + + test('addSurveyToFocus should add survey ID to surveyInFocus', () => { + surveyManager.getTestAPI().addSurveyToFocus('survey1') + expect(surveyManager.getTestAPI().surveyInFocus).toEqual('survey1') + }) + + test('removeSurveyFromFocus should remove survey ID from surveyInFocus', () => { + surveyManager.getTestAPI().addSurveyToFocus('survey1') + surveyManager.getTestAPI().removeSurveyFromFocus('survey1') + expect(surveyManager.getTestAPI().surveyInFocus).toBe(null) + }) + + test('canShowNextEventBasedSurvey should return correct visibility status', () => { + const surveyDiv = document.createElement('div') + surveyDiv.className = 'PostHogSurvey_test' + surveyDiv.attachShadow({ mode: 'open' }) + surveyDiv.shadowRoot!.appendChild(document.createElement('style')) + document.body.appendChild(surveyDiv) + + expect(surveyManager.getTestAPI().canShowNextEventBasedSurvey()).toBe(true) + + surveyDiv.shadowRoot!.appendChild(document.createElement('div')) + expect(surveyManager.getTestAPI().canShowNextEventBasedSurvey()).toBe(false) + }) + + test('callSurveysAndEvaluateDisplayLogic should handle popup surveys correctly', () => { + mockPostHog.getActiveMatchingSurveys = jest.fn((callback) => callback([mockSurveys[0]])) + + const handlePopoverSurveyMock = jest + .spyOn(surveyManager as any, 'handlePopoverSurvey') + .mockImplementation(() => {}) + const handleWidgetMock = jest.spyOn(surveyManager as any, 'handleWidget').mockImplementation(() => {}) + const handleWidgetSelectorMock = jest + .spyOn(surveyManager as any, 'handleWidgetSelector') + .mockImplementation(() => {}) + jest.spyOn(surveyManager as any, 'canShowNextEventBasedSurvey').mockReturnValue(true) + + surveyManager.callSurveysAndEvaluateDisplayLogic() + + expect(mockPostHog.getActiveMatchingSurveys).toHaveBeenCalled() + expect(handlePopoverSurveyMock).toHaveBeenCalledWith(mockSurveys[0]) + expect(handleWidgetMock).not.toHaveBeenCalled() + expect(handleWidgetSelectorMock).not.toHaveBeenCalled() + }) + + test('handleWidget should render the widget correctly', () => { + const mockSurvey = mockSurveys[1] + const handleWidgetMock = jest.spyOn(surveyManager as any, 'handleWidget').mockImplementation(() => {}) + surveyManager.getTestAPI().handleWidget(mockSurvey) + expect(handleWidgetMock).toHaveBeenCalledWith(mockSurvey) + }) + + test('handleWidgetSelector should set up the widget selector correctly', () => { + const mockSurvey: Survey = { + id: 'testSurvey1', + name: 'Test survey 1', + description: 'Test survey description 1', + type: SurveyType.Widget, + linked_flag_key: null, + targeting_flag_key: null, + internal_targeting_flag_key: null, + questions: [ + { + question: 'How satisfied are you with our newest product?', + description: 'This is a question description', + descriptionContentType: 'text', + type: SurveyQuestionType.Rating, + display: 'number', + scale: 10, + lowerBoundLabel: 'Not Satisfied', + upperBoundLabel: 'Very Satisfied', + originalQuestionIndex: 0, + }, + ], + appearance: {}, + conditions: null, + start_date: '2021-01-01T00:00:00.000Z', + end_date: null, + current_iteration: null, + current_iteration_start_date: null, + } + document.body.innerHTML = '
' + const handleWidgetSelectorMock = jest + .spyOn(surveyManager as any, 'handleWidgetSelector') + .mockImplementation(() => {}) + surveyManager.getTestAPI().handleWidgetSelector(mockSurvey) + expect(handleWidgetSelectorMock).toHaveBeenNthCalledWith(1, mockSurvey) + }) + + test('callSurveysAndEvaluateDisplayLogic should not call surveys in focus', () => { + mockPostHog.getActiveMatchingSurveys = jest.fn((callback) => callback(mockSurveys)) + + surveyManager.getTestAPI().addSurveyToFocus('survey1') + surveyManager.callSurveysAndEvaluateDisplayLogic() + + expect(mockPostHog.getActiveMatchingSurveys).toHaveBeenCalledTimes(1) + expect(surveyManager.getTestAPI().surveyInFocus).toBe('survey1') + }) + + test('surveyInFocus handling works correctly with in callSurveysAndEvaluateDisplayLogic', () => { + mockPostHog.getActiveMatchingSurveys = jest.fn((callback) => callback(mockSurveys)) + + surveyManager.getTestAPI().addSurveyToFocus('survey1') + surveyManager.callSurveysAndEvaluateDisplayLogic() + + expect(mockPostHog.getActiveMatchingSurveys).toHaveBeenCalledTimes(1) + expect(surveyManager.getTestAPI().surveyInFocus).toBe('survey1') + + const handlePopoverSurveyMock = jest + .spyOn(surveyManager as any, 'handlePopoverSurvey') + .mockImplementation(() => {}) + + surveyManager.getTestAPI().removeSurveyFromFocus('survey1') + surveyManager.callSurveysAndEvaluateDisplayLogic() + + expect(mockPostHog.getActiveMatchingSurveys).toHaveBeenCalledTimes(2) + expect(surveyManager.getTestAPI().surveyInFocus).toBe(null) + expect(handlePopoverSurveyMock).toHaveBeenCalledTimes(1) + }) + + test('sortSurveysByAppearanceDelay should sort surveys correctly', () => { + const surveys: Survey[] = [ + { id: '1', appearance: { surveyPopupDelaySeconds: 5 } }, + { id: '2', appearance: { surveyPopupDelaySeconds: 2 } }, + { id: '3', appearance: {} }, + { id: '4', appearance: { surveyPopupDelaySeconds: 8 } }, + ] as unknown as Survey[] + + const sortedSurveys = surveyManager.getTestAPI().sortSurveysByAppearanceDelay(surveys) + + expect(sortedSurveys).toEqual([ + { id: '3', appearance: {} }, + { id: '2', appearance: { surveyPopupDelaySeconds: 2 } }, + { id: '1', appearance: { surveyPopupDelaySeconds: 5 } }, + { id: '4', appearance: { surveyPopupDelaySeconds: 8 } }, + ]) }) }) diff --git a/src/extensions/surveys.tsx b/src/extensions/surveys.tsx index 3bae8e71c..d8eff3ec2 100644 --- a/src/extensions/surveys.tsx +++ b/src/extensions/surveys.tsx @@ -23,7 +23,7 @@ import { import * as Preact from 'preact' import { createWidgetShadow, createWidgetStyle } from './surveys-widget' import { useState, useEffect, useRef, useContext, useMemo } from 'preact/hooks' -import { isNumber } from '../utils/type-utils' +import { isNull, isNumber } from '../utils/type-utils' import { ConfirmationMessage } from './surveys/components/ConfirmationMessage' import { OpenTextQuestion, @@ -31,87 +31,176 @@ import { RatingQuestion, MultipleChoiceQuestion, } from './surveys/components/QuestionTypes' +import { logger } from '../utils/logger' // We cast the types here which is dangerous but protected by the top level generateSurveys call const window = _window as Window & typeof globalThis const document = _document as Document -const handleWidget = (posthog: PostHog, survey: Survey) => { - const shadow = createWidgetShadow(survey) - const surveyStyleSheet = style(survey.appearance) - shadow.appendChild(Object.assign(document.createElement('style'), { innerText: surveyStyleSheet })) - Preact.render(, shadow) -} +export class SurveyManager { + private posthog: PostHog + private surveyInFocus: string | null -export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { - posthog?.getActiveMatchingSurveys((surveys) => { - const nonAPISurveys = surveys.filter((survey) => survey.type !== 'api') - nonAPISurveys.forEach((survey) => { - if (survey.type === SurveyType.Widget) { - if ( - survey.appearance?.widgetType === 'tab' && - document.querySelectorAll(`.PostHogWidget${survey.id}`).length === 0 - ) { - handleWidget(posthog, survey) - } - if (survey.appearance?.widgetType === 'selector' && survey.appearance?.widgetSelector) { - const selectorOnPage = document.querySelector(survey.appearance.widgetSelector) - if (selectorOnPage) { - if (document.querySelectorAll(`.PostHogWidget${survey.id}`).length === 0) { - handleWidget(posthog, survey) - } else if (document.querySelectorAll(`.PostHogWidget${survey.id}`).length === 1) { - // we have to check if user selector already has a survey listener attached to it because we always have to check if it's on the page or not - if (!selectorOnPage.getAttribute('PHWidgetSurveyClickListener')) { - const surveyPopup = document - .querySelector(`.PostHogWidget${survey.id}`) - ?.shadowRoot?.querySelector(`.survey-form`) as HTMLFormElement - selectorOnPage.addEventListener('click', () => { - if (surveyPopup) { - surveyPopup.style.display = - surveyPopup.style.display === 'none' ? 'block' : 'none' - surveyPopup.addEventListener( - 'PHSurveyClosed', - () => (surveyPopup.style.display = 'none') - ) - } - }) - selectorOnPage.setAttribute('PHWidgetSurveyClickListener', 'true') - } + constructor(posthog: PostHog) { + this.posthog = posthog + // This is used to track the survey that is currently in focus. We only show one survey at a time. + this.surveyInFocus = null + } + + private canShowNextEventBasedSurvey = (): boolean => { + // with event based surveys, we need to show the next survey without reloading the page. + // A simple check for div elements with the class name pattern of PostHogSurvey_xyz doesn't work here + // because preact leaves behind the div element for any surveys responded/dismissed with a