-
Notifications
You must be signed in to change notification settings - Fork 134
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
feat(surveys): add configurable delay to popup surveys #1228
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
Hey @dmarticus! 👋 |
Size Change: +4.52 kB (+0.45%) Total Size: 1.02 MB
ℹ️ View Unchanged
|
src/extensions/surveys.tsx
Outdated
export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { | ||
posthog?.getActiveMatchingSurveys((surveys) => { | ||
const nonAPISurveys = surveys.filter((survey) => survey.type !== 'api') | ||
nonAPISurveys.forEach((survey) => { | ||
if (visibleSurveys.has(survey.id)) { | ||
return // skip if survey is already visible |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI, I added this bit of code to prevent re-rendering the popup if this survey is already present on the page; I was noticing this weird behavior where we kept trying to render the survey afresh every few seconds. This change mitigates it, although it might have consequences that I don't fully understand. My UI tests seemed to work great, and it doesn't look like I regressed anything, but I wanted to call this out in case it was dangerous.
useEffect(() => { | ||
if (isPreviewMode || !posthog) { | ||
return | ||
} | ||
|
||
window.dispatchEvent(new Event('PHSurveyShown')) | ||
posthog.capture('survey shown', { | ||
$survey_name: survey.name, | ||
$survey_id: survey.id, | ||
$survey_iteration: survey.current_iteration, | ||
$survey_iteration_start_date: survey.current_iteration_start_date, | ||
sessionRecordingUrl: posthog.get_session_replay_url?.(), | ||
}) | ||
localStorage.setItem(`lastSeenSurveyDate`, new Date().toISOString()) | ||
|
||
window.addEventListener('PHSurveyClosed', () => { | ||
setIsPopupVisible(false) | ||
}) | ||
window.addEventListener('PHSurveySent', () => { | ||
if (!survey.appearance?.displayThankYouMessage) { | ||
return setIsPopupVisible(false) | ||
} | ||
|
||
setIsSurveySent(true) | ||
|
||
if (survey.appearance?.autoDisappear) { | ||
setTimeout(() => { | ||
setIsPopupVisible(false) | ||
}, 5000) | ||
} | ||
}) | ||
}, []) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
refactored this into the useSurveyPopup
hook.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One potential bug I'm not sure of, otherwise seems solid!
src/extensions/surveys.tsx
Outdated
@@ -99,6 +104,7 @@ export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { | |||
} | |||
|
|||
if (!localStorage.getItem(getSurveySeenKey(survey))) { | |||
visibleSurveys.add(survey.id) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we need to remove the survey.id when it's not visible as well?
I'd expect this set to always have one element. For cases when for example a survey is shown -> responded -> needs to show up again because config / iteration / whatever -> currently it wouldn't show up again if I'm reading things correctly.
src/extensions/surveys.tsx
Outdated
const timeoutId = setTimeout(() => { | ||
setIsPopupVisible(true) | ||
|
||
window.dispatchEvent(new Event('PHSurveyShown')) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we abstract this into a local function call that does all of these things?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is still todo :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry, must've missed this! Just addressed it.
src/extensions/surveys.tsx
Outdated
window.addEventListener('PHSurveySent', handleSurveySent) | ||
|
||
if (delay > 0) { | ||
const timeoutId = setTimeout(() => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hard for me to reason via the code, but what happens in the case when:
- We have a popup with delay 5 seconds (surveyA) that is active & matching
- We have a popup with delay 0 seconds (surveyB) that is active & matching
surveyA
matches first, and we end up here, call setTimeout.isPopupVisible
is false.surveyB
matches as well, and is shown directly, because no other survey is visible.- 5 seconds later,
surveyA
is shown on top ofsurveyB
. I think this will happen because the existing survey visible check happens at a higher level, and not inside the setTimeout here. I think the same problem magnifies when you have multiple surveys with different timeouts.
Or at least that's what I think will happen, haven't run this, try it out please :) I think this delay check needs to go at a higher level to prevent this. Another thing to figure out is how do we want to handle multiple delayed & non-delayed surveys matching - always show the non-delayed ones first, and only then start the timing for delayed surveys (I think I prefer this but haven't thought too much about it), or 'undefined behaviour', whichever ones comes first in the list is set to show first, so if there's a 30s delay, this one will wait 30 seconds, show up, and then any remaining non-delayed ones show up after.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a thoughtful comment, so I want to make sure I respond to it.
Basically, what I ended up doing was differentiating between the survey that was actively being focused on (I created an action queue of SurveyA, SurveyB, etc, ordered by the delay associated with that survey), and then visibility of the survey itself (which is handled by this usePopupVisibility
hook.
Here's what happens now:
- we load all surveys from the posthog SDK and throw them into a list, ordered by survey delay (where the non-delayed surveys all appear first, and then proceeding in sequential order with the longest delays at the end of this list)
- we add the first item in this list to the
surveysInFocus
set, which is a Set that has strictly one member, and it keeps track of the survey about which we are evaluating visibility - we then use
usePopupVisiblity
to determine how to display that survey on the page.
once that survey has completed its popup lifecycle, we then remove it from the surveysInFocus
set and proceed to the next one.
This way, handling visibility and handling which surveys to evaluate are separate processes, which prevent surveys from colliding with each other.
@neilkakkar thanks for the feedback; I didn't think about this critically enough from the multiple popups + repeated experience angle. Definitely some things I need to iron out there. Will do this today! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great job, I love this refactor & fixing! 🙌
jest.useRealTimers() | ||
}) | ||
|
||
test('should hide popup when PHSurveyClosed event is dispatched', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
love this test suite, great job!
end_date: null, | ||
targeting_flag_key: null, | ||
}, | ||
] as unknown as Survey[] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do you need to coerce type here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ha, I was just being a bit lazy. FWIW, the actual values of the survey data don't matter than much in this specific test, and I noticed that some of the existing test code were also using type coercions to handle the fact that these test surveys didn't compile (since they no longer match the Survey
type). I can clean all these test surveys up and make them match the correct schema; it's not much extra work and may save us in the future, but for these examples it didn't matter much since I'm not strictly testing the values within these surveys – I just needed some data I could pass in!
Anyway, I'll clean these up. It's a fair callout
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah all good, upto you, no strong feelings from me here 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cleaned up the types a bit.
src/extensions/surveys.tsx
Outdated
// This is important for correctly displaying popover surveys with a delay, where we want to show them | ||
// in order of their delay, rather than evaluate them all at once. | ||
// NB: This set should only ever have 0 or 1 items in it at a time. | ||
this.surveysInFocus = new Set<string>() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since should always have 0 or 1 items, confusing to me that its a set and named plural, why not instead:
this.surveyInFocus: string | null = null
and whenever we're trying to add a 2nd survey in focus when there's already a survey in focus, console.error with enough debug info that if it happens to a user we can replicate using this state
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aligned, and I love that idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, let me know what you think of the error message (I added it to the addSurveyToFocus
method)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
error looks good! Although it's still a set 😅 - sorry if this wasn't clear in my earlier message, I was suggesting making it a string | null, instead of a set, since to me there doesn't seem to be a need for a set here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(which is what creates the confusion for me, since if its a set, I somewhat expect to see it handling more than one item!)
}) | ||
selectorOnPage.setAttribute('PHWidgetSurveyClickListener', 'true') | ||
} | ||
private canShowNextEventBasedSurvey = (): boolean => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is still pretty hacky, but agree with your decision to not fix it within this already big PR
src/extensions/surveys.tsx
Outdated
return isPopupVisible ? ( | ||
<SurveyContext.Provider | ||
value={{ | ||
isPreviewMode, | ||
previewPageIndex: previewPageIndex, | ||
handleCloseSurveyPopup: () => dismissedSurveyEvent(survey, posthog, isPreviewMode), | ||
handleCloseSurveyPopup: () => { | ||
removeSurveyFromFocus(survey.id) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not a fan of having to pass on removeSurveyFromFocus
everywhere, it seems very bug-prone, and remembering to do this in every new feature we add (for example, eventually in widgets)
Instead, I think you should hook into the PHSurveyClosed
and PHSurveySent
event listeners, where we also set isPopupVisible or not - this removeSurveyFromFocus
and isPopupVisible
should always be in sync (right?) , so makes sense to me to handle them in the same place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, you're already doing that, do we still need this here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead, I think you should hook into the PHSurveyClosed and PHSurveySent event listeners, where we also set isPopupVisible or not - this removeSurveyFromFocus and isPopupVisible should always be in sync (right?) , so makes sense to me to handle them in the same place.
They're sorta in sync: isPopupVisible
handles whether the popup is literally visible or not, so it depends on things like the popup delay, the auto-disappear, etc. Whereas removeSurveyFromFocus
is more about managing the surveys being evaluated – i.e. a survey can be in focus without being visible. That said, removing them should be in sync, a survey that is not in focus should never be visible. That, re: this point
Instead, I think you should hook into the PHSurveyClosed and PHSurveySent event listeners, where we also set isPopupVisible or not - this removeSurveyFromFocus and isPopupVisible should always be in sync (right?) , so makes sense to me to handle them in the same place.
I hear you on not wanting to thread a survey visibility manager through all of the components and instead try and instead try and tie them to the event listeners; I think that's a more reasonable abstraction.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, that's exactly my point, removing them should be in sync, and for that we have the event listeners
expect(surveyManager).toBeDefined() | ||
expect(typeof surveyManager.getTestAPI().addSurveyToFocus).toBe('function') | ||
expect(typeof surveyManager.getTestAPI().removeSurveyFromFocus).toBe('function') | ||
expect(typeof surveyManager.callSurveysAndEvaluateDisplayLogic).toBe('function') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
check initial value of surveysInFocus
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
expect(handleWidgetSelectorMock).toHaveBeenCalledWith(mockSurvey) | ||
}) | ||
|
||
test('callSurveysAndEvaluateDisplayLogic should not call surveys in focus', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would love to see some tests around:
-
The sorting behaviour for delayed + non-delayed surveys (and wth add an api survey in the mix too :P )
-
SurveyinFocus handling for
callSurveysAndEvaluateDisplayLogic
- i.e. assume there is a surveyInFocus, (like in this test) - thenhandleSurveyPopover
is not called. Then you manually callremoveSurveyFromFocus
and then callcallSurveysAndEvaluateDisplayLogic
again and this timehandleSurveyPopover
is called. -
[For a follow up]. - I don't remember if this already exists, but would love to see some tests around the
getSurveySeenKey
and local storage handling as well, but it's not something you're currently touching, so happy to defer to you whenever / if you want to add these
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did the first 2 in these changes, and then I'll do the next bit in my refactor PR.
Co-authored-by: Neil Kakkar <[email protected]>
Co-authored-by: Neil Kakkar <[email protected]>
Co-authored-by: Neil Kakkar <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ship it!
Problem
Many folks have been asking for us to add the ability to add a configurable delay to when our popup surveys appear on customer websites. Now, with this change (and the associated
posthog
change), they can do this!Closes: PostHog/posthog#18102
Ships with the associated posthog changes: PostHog/posthog#22780
Significant Changes
I made two major changes to the
surveys
abstraction model.SurveyPopup
component to because I wanted to test it better. That component was kinda turning into a mega component that belied a lot of complexity, and it made me nervous to keep changing it without having tests.So, I refactored that component into a hook (that did all of the interesting logic around setting state, mounting listeners, etc) and then a component that just used my new hook to keep track of the state and rendered whatever it needed to.
Then, I wrote a bunch of unit tests for that hook. See the
usePopupVisibility
method and the associated tests for how i did that. Required adding the preact testing library to the devdeps; I hope that's okay.works a treat, though! And now it's easier to test!
SurveyManager
class that manages keeping track of the global state of which surveys we're evaluating a given time.Demo
Survey popup with no delay set
Survey popup with 5s delay
Automated Tests
surveyPopupDelay
field to theappearance
payloadManual Tests