-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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: in app prompts experiment #10956
Conversation
Heavily heavily heavily invested in this feature (from Mention Me, an enterprise customer). |
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.
Bunch of small/medium comments. Only thing really missing for approval would be some tests, especially around the api endpoint and the logic
const { runFirstValidSequence, closePrompts } = useActions(inAppPromptLogic) | ||
const { isPromptVisible } = useValues(inAppPromptLogic) | ||
|
||
console.log(isPromptVisible) |
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.
Reminder to remove this before merging
.LemonActionableTooltip__navigation--left { | ||
margin-right: 0.25rem; | ||
} | ||
.LemonActionableTooltip__navigation--right { | ||
margin-left: 0.25rem; | ||
} |
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.
A simpler option without needing to have extra classnames would be
.LemonActionableTooltip__navigation--left { | |
margin-right: 0.25rem; | |
} | |
.LemonActionableTooltip__navigation--right { | |
margin-left: 0.25rem; | |
} | |
> * + * { | |
margin-left: 0.25rem; | |
} |
This is what space-x
does as a utility for example
show.then(() => { | ||
actions.updatePromptState({ step: values.currentStep }) | ||
}).catch((err) => console.error(err)) |
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 super strongly opinionated on this but if we are in an async function perhaps we should keep things async-style and try-catch this?
[router.actions.locationChanged]: () => { | ||
actions.closePrompts() | ||
actions.findValidSequences() | ||
}, |
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's also urlToAction
from kea-router which could listen to *
route and trigger changes that way?
events(({ actions, cache }) => ({ | ||
afterMount: () => { | ||
posthog.onFeatureFlags(async (_, variants) => { | ||
if (variants[FEATURE_FLAGS.IN_APP_PROMPTS_EXPERIMENT] === 'test') { | ||
actions.syncState({ forceRun: true }) | ||
} | ||
}) | ||
}, | ||
beforeUnmount: [ | ||
() => { | ||
cache.runOnClose?.() | ||
}, | ||
], | ||
})), |
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 believe these have their own top-level builders so you could write it:
events(({ actions, cache }) => ({ | |
afterMount: () => { | |
posthog.onFeatureFlags(async (_, variants) => { | |
if (variants[FEATURE_FLAGS.IN_APP_PROMPTS_EXPERIMENT] === 'test') { | |
actions.syncState({ forceRun: true }) | |
} | |
}) | |
}, | |
beforeUnmount: [ | |
() => { | |
cache.runOnClose?.() | |
}, | |
], | |
})), | |
afterMount(({ actions }) => { | |
posthog.onFeatureFlags(async (_, variants) => { | |
if (variants[FEATURE_FLAGS.IN_APP_PROMPTS_EXPERIMENT] === 'test') { | |
actions.syncState({ forceRun: true }) | |
} | |
}) | |
}), | |
beforeUnmount(({ cache }) => cache.runOnClose?.()), |
@@ -239,6 +239,7 @@ export function DashboardHeader(): JSX.Element | null { | |||
<LemonButton | |||
type="secondary" | |||
data-attr="dashboard-share-button" | |||
data-tooltip="experiment-new-dashboard-product-tour-1" |
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.
Thinking to the future... We use data-attr
everywhere and encourage people to do so for autocapture
. Might be good to have that as a selectable option (thinking more about productizing the feature)
closePrompts() | ||
showNewDashboardModal() |
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 gonna be fun... We'll have to see how it goes with the trial but perhaps it shouldn't be possible to do anything other than interact with the tooltips as there are lots of other places that create modals...
if states_to_create: | ||
PromptSequenceState.objects.bulk_create(states_to_create) | ||
if states_to_update: | ||
PromptSequenceState.objects.bulk_update( | ||
states_to_update, ["last_updated_at", "step", "completed", "dismissed"] | ||
) |
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.
Totally fine for now but if we want to productionize this it's probably worth having this wrapped in a transaction, otherwise we could get into a weird state where half of your request worked. But probably overall would have a different implementation anyways so all good :D
Happy to draft up some copy for this and tooltip text v2 is on my current sprint... However, I do think it could be worth revisiting the idea of a multi-page tutorial flow in the future, even if we just test it down the line. The main reason for this is that it'd give a chance to provide better context of how some tools can be used together contextually -- such as insights and dashboards, persons and session recordings, etc. I also think that, if we're confident what a majority of people want to achieve and can bake that into the flow, it could lead to the best result. Not a strong feeling for now, just something I'd like to consider for the future. |
Also, to confirm: If the tutorial flow is going to be independant then will skipping the tutorial skip the ENTIRE tutorial, or just the content for a single page? |
I think we just need to slightly tweak the copy so that one doesn't feel like they jumped in the middle of something. From my experience, unless you force a flow, it's nearly impossible to dictate what the user does. Maybe a solution to this is to have a check-list of product-tours the user can do, and have them complete them all. That way they can do them in whatever order, and we keep the copy focused on the current page.
This would disable product tours in general, which can be then re-triggered from the Help Menu. |
For now, I've written up a draft of copy that gives 1-3 tooltip prompts for every main page in the app. It's a bit rough, with a focus on highlighting main functionality and features that may be missed (such as live events toggle). |
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.
First pass over, looks good. Some nice use of TS types 💖
Needs API tests and updating from master (but you know that :))
@@ -117,6 +117,7 @@ export const FEATURE_FLAGS = { | |||
TOOLBAR_LAUNCH_SIDE_ACTION: 'toolbar-launch-side-action', // owner: @pauldambra, | |||
FEATURE_FLAG_EXPERIENCE_CONTINUITY: 'feature-flag-experience-continuity', // owner: @neilkakkar | |||
ASYNC_EXPORT_CSV_FOR_LIVE_EVENTS: 'ASYNC_EXPORT_CSV_FOR_LIVE_EVENTS', // owner: @pauldambra | |||
IN_APP_PROMPTS_EXPERIMENT: 'IN_APP_PROMPTS_EXPERIMENT', // owner: @kappa90 |
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.
definitely need to update from master... there should be conflicts here I think
import type { inAppPromptEventCaptureLogicType } from './inAppPromptEventCaptureLogicType' | ||
import posthog from 'posthog-js' | ||
|
||
const inAppPromptEventCaptureLogic = kea<inAppPromptEventCaptureLogicType>([ |
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.
🙌 Yes, to not bloating the existing event capture logic!
icon?: string | ||
} | ||
|
||
export type Tooltip = Prompt & { type: 'tooltip' } |
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.
#til
latest_migrations.manifest
Outdated
@@ -3,7 +3,7 @@ auth: 0012_alter_user_first_name_max_length | |||
axes: 0006_remove_accesslog_trusted | |||
contenttypes: 0002_remove_content_type_name | |||
ee: 0013_silence_deprecated_tags_warnings | |||
posthog: 0251_event_buffer | |||
posthog: 0252_auto_20220721_0858 |
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 will clash as well. I snuck in 252 ahead of you :)
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.
And we should be naming the migrations when creating them
posthog/api/prompt.py
Outdated
|
||
@action(methods=["PATCH"], detail=False) | ||
def my_prompts(self, request: request.Request, **kwargs): | ||
if not request.user.is_authenticated: # for mypy |
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 use a test to prove it but I'd expect this to be handled by middleware not each route
posthog/models/prompt.py
Outdated
|
||
# this model is currently unused as we are running an experimentation config, might be used in the future | ||
# or completely removed if we allow feature flags to carry a payload, which would be a prompts configuration JSON | ||
class PromptSequence(models.Model): |
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.
We can't delete models/fields for various reasons. If we're not using this we should remove it and only add it when we need it. Otherwise there's a risk it lives on in the code unused for a long time
@benjackwhite @pauldambra introduced all your feedback, we can merge Monday morning :) |
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.
No big complaints only a few comments 👍
@@ -43,7 +44,7 @@ export function ProjectHomepage(): JSX.Element { | |||
Invite members | |||
</LemonButton> | |||
<LemonButton | |||
data-attr="project-home-new-insight" |
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 wonder if this is used anywhere in autocapture analytics....
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 definitely a mistake on my side, thanks for spotting that!
@@ -118,6 +118,7 @@ export function PlayerEvents(): JSX.Element { | |||
onClick={() => { | |||
handleEventClick(event.playerPosition) | |||
}} | |||
data-tooltip={'recording-event-list'} |
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.
nit: doesn't need to be bracketed
frontend/src/lib/components/LemonActionableTooltip/LemonActionableTooltip.scss
Show resolved
Hide resolved
frontend/src/lib/components/LemonActionableTooltip/LemonActionableTooltip.tsx
Show resolved
Hide resolved
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.
AntD alert
frontend/src/lib/components/LemonActionableTooltip/LemonActionableTooltip.tsx
Outdated
Show resolved
Hide resolved
frontend/src/lib/components/LemonActionableTooltip/LemonActionableTooltip.tsx
Outdated
Show resolved
Hide resolved
constraints = [ | ||
models.UniqueConstraint(fields=["team", "person", "key"], name="unique sequence key for person for team") | ||
] | ||
|
||
team: models.ForeignKey = models.ForeignKey("Team", on_delete=models.CASCADE) | ||
person: models.ForeignKey = models.ForeignKey("Person", on_delete=models.CASCADE) |
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.
As a user, I don't think it'd make sense for me to go through a flow multiple times just because I have multiple projects. Why not key this just ["person", "key"]
?
) | ||
|
||
if not person_id: | ||
return Response("", status=status.HTTP_404_NOT_FOUND) |
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 won't have a JSON payload, I believe we should raise a exceptions.NotFound()
(not sure actually, since this is an @action
, but we definitely should return the standard 404 JSON response)
|
||
for sequence in all_sequences: | ||
local_state = next((s for s in local_states if sequence["key"] == s["key"]), None) | ||
saved_state: Union[PromptSequenceState, None] = next( |
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.
Tip: Union[T, None]
can be represented as Optional[T]
;)
class PromptSequenceStateSerializer(serializers.HyperlinkedModelSerializer): | ||
class Meta: | ||
model = PromptSequenceState | ||
fields = ["key", "last_updated_at", "step", "completed", "dismissed"] |
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.
Should they all also be marked as read_only_fields
?
@@ -57,6 +58,7 @@ def api_not_found(request): | |||
) | |||
projects_router.register(r"annotations", annotation.AnnotationsViewSet, "project_annotations", ["team_id"]) | |||
projects_router.register(r"feature_flags", feature_flag.FeatureFlagViewSet, "project_feature_flags", ["team_id"]) | |||
projects_router.register(r"prompts", prompt.PromptSequenceStateViewSet, "project_feature_flags", ["team_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.
basename
needs to be updated
projects_router.register(r"prompts", prompt.PromptSequenceStateViewSet, "project_feature_flags", ["team_id"]) | |
projects_router.register(r"prompts", prompt.PromptSequenceStateViewSet, "project_prompts", ["team_id"]) |
As for the shape of the API though, wouldn't it be more intuitive for prompts to be scoped per user instead per project? (mentioned this in a comment above too)
data-attr="create-annotation" | ||
data-tooltip="annotations-new-button" |
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.
Could we just reuse data-attr
instead of adding data-tooltip
? 🤔 Seems like they serve a very similar purpose
@@ -118,6 +118,7 @@ export const FEATURE_FLAGS = { | |||
// Re-enable person modal CSV downloads when frontend can support new entity properties | |||
PERSON_MODAL_EXPORTS: 'person-modal-exports', // hot potato see https://github.com/PostHog/posthog/pull/10824 | |||
BILLING_LOCK_EVERYTHING: 'billing-lock-everything', // owner @timgl | |||
IN_APP_PROMPTS_EXPERIMENT: 'IN_APP_PROMPTS_EXPERIMENT', // owner: @kappa90 |
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.
Absolute nit… but could we keep the same kebab-case
convention for feature flag keys?
|
||
export function IconMessages(props: React.SVGProps<SVGSVGElement>): JSX.Element { | ||
return ( | ||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> |
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 doesn't seem right – all our icons should be using a 24x24 viewbox.
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> | ||
<path | ||
d="M13 2V9H3.17L2 10.17V2H13ZM14 0H1C0.45 0 0 0.45 0 1V15L4 11H14C14.55 11 15 10.55 15 10V1C15 0.45 14.55 0 14 0ZM19 4H17V13H4V15C4 15.55 4.45 16 5 16H16L20 20V5C20 4.45 19.55 4 19 4Z" | ||
fill="#747EA2" |
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.
Icons needs to have fill="currentColor"
so that they can adjust to button colors
Problem
Following this RFC, this PR is an experimental implementation of customizable in-app prompts
Changes
PATCH /prompts/my_prompts
, which is used to receive the prompt sequences that should be shown to a person, as well as update and receive the state for each sequence (i.e. which prompts have already been displayed for the current person)inAppPromptLogic
takes care of syncing the local person prompt state with the backend, as well as deciding which prompts to display/dashboard/*
). We will run this as an experiment so we can use the experiment cohorts logic to show this conditionally to certain persons, but in the future this could be a native feature of prompts (by extending feature flags to carry a prompt configuration payload).To-do:
Here's a video preview of the flow
How did you test this code?
New tests for the frontend logic