Skip to content

Commit 2c313e0

Browse files
feat(onboarding): gradual rollout quest screen (#8286)
1 parent 66b630a commit 2c313e0

10 files changed

Lines changed: 331 additions & 2 deletions

File tree

frontend/common/constants.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,29 @@ const Constants = {
284284
'category': 'Onboarding',
285285
'event': 'Onboarding flag toggled',
286286
},
287+
'ONBOARDING_ROLLOUT_CONTINUED': {
288+
'category': 'Onboarding',
289+
'event': 'Onboarding rollout quest continued',
290+
},
291+
// Only "Maybe later". Closing the modal from its chrome is not tracked, so
292+
// abandonment is viewed minus continued minus this.
293+
'ONBOARDING_ROLLOUT_DISMISSED': {
294+
'category': 'Onboarding',
295+
'event': 'Onboarding rollout quest dismissed',
296+
},
297+
'ONBOARDING_ROLLOUT_FEEDBACK': {
298+
'category': 'Onboarding',
299+
'event': 'Onboarding rollout feedback opened',
300+
},
301+
// The fake door's only real signal: demand for one-click rollouts.
302+
'ONBOARDING_ROLLOUT_NOTIFY_ME': {
303+
'category': 'Onboarding',
304+
'event': 'Onboarding rollout notify me',
305+
},
306+
'ONBOARDING_ROLLOUT_VIEWED': {
307+
'category': 'Onboarding',
308+
'event': 'Onboarding rollout quest viewed',
309+
},
287310
'ONBOARDING_SNIPPET_COPIED': {
288311
'category': 'Onboarding',
289312
'event': 'Onboarding snippet copied',

frontend/e2e/tests/onboarding-tests.pw.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ test.describe('Onboarding', () => {
156156
).toBeVisible();
157157

158158
await page.getByRole('button', { name: /Gradual rollout/ }).click();
159+
// Gated on onboarding_rollout_quest: on, the quest modal opens first and
160+
// "Create a rollout segment" lands on the tab the card used to link to.
161+
if (flagsmith.hasFeature('onboarding_rollout_quest')) {
162+
await expect(
163+
page.getByRole('heading', { name: 'How to roll out gradually today' }),
164+
).toBeVisible();
165+
await page
166+
.getByRole('button', { name: 'Create a rollout segment' })
167+
.click();
168+
}
159169
await expect(page).toHaveURL(
160170
/\/features\?feature=\d+&tab=segment-overrides/,
161171
);

frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import OnboardingFlagsTable from 'components/pages/onboarding/OnboardingFlagsTab
1010
import OnboardingNextSteps, {
1111
OnboardingNextStep,
1212
} from 'components/pages/onboarding/OnboardingNextSteps'
13+
import { openRolloutQuest } from 'components/pages/onboarding/OnboardingRolloutQuest'
1314
import { useEnsureOnboardingResources } from 'components/pages/onboarding/hooks/useEnsureOnboardingResources'
1415
import { useOnboardingFlagRename } from 'components/pages/onboarding/hooks/useOnboardingFlagRename'
1516
import { useOnboardingFlag } from 'components/pages/onboarding/hooks/useOnboardingFlag'
1617
import { useOnboardingConnection } from 'components/pages/onboarding/hooks/useOnboardingConnection'
1718
import { useUpdateOrganisationMutation } from 'common/services/useOrganisation'
1819
import { useUpdateProjectMutation } from 'common/services/useProject'
20+
import { useGetProfileQuery } from 'common/services/useProfile'
1921
import API from 'project/api'
2022
import Constants from 'common/constants'
2123
import { isPendingAuthorisation } from 'common/utils/pendingAuthorisation'
@@ -41,6 +43,8 @@ const OnboardingFlow: FC = () => {
4143
const history = useHistory()
4244
const [updateOrganisation] = useUpdateOrganisationMutation()
4345
const [updateProject] = useUpdateProjectMutation()
46+
// Already fetched by useEnsureOnboardingResources, so this is a cache read.
47+
const { data: profile } = useGetProfileQuery({})
4448

4549
// A client waiting on the consent screen sent this user here to sign up. It
4650
// is answered once the workspace exists, never on load: bootstrapping is a
@@ -144,8 +148,8 @@ const OnboardingFlow: FC = () => {
144148
}
145149
}
146150

147-
// Each next-step card deep-links to the flag's real config; nothing faked.
148-
const goToNextStep = (step: OnboardingNextStep) => {
151+
// Where a quest ends up: the flag's real config, nothing faked.
152+
const goToFlagConfig = (step: OnboardingNextStep) => {
149153
if (projectId === null) {
150154
return
151155
}
@@ -167,6 +171,20 @@ const OnboardingFlow: FC = () => {
167171
organisation_id: organisationId,
168172
project_id: projectId,
169173
}
174+
175+
// Off, rollout deep-links to the overrides tab like every other next step.
176+
// Read on click rather than on render: hasFeature counts an evaluation, and
177+
// this page re-renders on every connection poll.
178+
const goToNextStep = (step: OnboardingNextStep) =>
179+
step === 'rollout' &&
180+
Utils.getFlagsmithHasFeature('onboarding_rollout_quest')
181+
? openRolloutQuest({
182+
diagnosticIds,
183+
featureName,
184+
onContinue: () => goToFlagConfig('rollout'),
185+
who: { email: profile?.email, organisation: organisationDisplayName },
186+
})
187+
: goToFlagConfig(step)
170188
const trackSnippetCopied = (snippet: OnboardingSnippet) =>
171189
API.trackEvent({
172190
...Constants.events.ONBOARDING_SNIPPET_COPIED,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.onboarding-rollout-quest {
2+
// The fill alone is near-invisible in dark mode, where the card and the modal
3+
// are a shade apart. .border-default sets only a colour, so the shorthand
4+
// lives here, as it does in MetricsTable and VariationTable.
5+
&__card {
6+
border: 1px solid var(--color-border-default);
7+
}
8+
9+
&__step-number {
10+
width: 24px;
11+
height: 24px;
12+
}
13+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React, { FC } from 'react'
2+
import Button from 'components/base/forms/Button'
3+
import Icon from 'components/icons/Icon'
4+
import RolloutComingSoonCard from './RolloutComingSoonCard'
5+
import {
6+
ROLLOUT_GUIDE_URL,
7+
RolloutPrerequisite,
8+
getRolloutSteps,
9+
} from './rolloutSteps'
10+
import './OnboardingRolloutQuest.scss'
11+
12+
export type OnboardingRolloutQuestProps = {
13+
featureName: string
14+
onContinue: () => void
15+
onDismiss: () => void
16+
onNotifyMe: () => void
17+
onFeedback: () => void
18+
}
19+
20+
const OnboardingRolloutQuest: FC<OnboardingRolloutQuestProps> = ({
21+
featureName,
22+
onContinue,
23+
onDismiss,
24+
onFeedback,
25+
onNotifyMe,
26+
}) => (
27+
<div className='onboarding-rollout-quest d-flex flex-column gap-4'>
28+
<p className='fs-caption lh-sm text-secondary m-0'>
29+
Release {featureName} to a growing percentage of your users.
30+
</p>
31+
32+
<section className='onboarding-rollout-quest__card bg-surface-muted rounded-xl p-4 d-flex flex-column gap-3'>
33+
<h6 className='m-0'>How to roll out gradually today</h6>
34+
<ol className='list-unstyled d-flex flex-column gap-3 m-0'>
35+
{getRolloutSteps(featureName).map((step, index) => (
36+
<li key={step.title} className='d-flex gap-3'>
37+
<span className='onboarding-rollout-quest__step-number bg-surface-action-subtle text-action rounded-full fs-captionSmall fw-bold d-flex align-items-center justify-content-center flex-shrink-0'>
38+
{index + 1}
39+
</span>
40+
<span className='d-flex flex-column gap-1'>
41+
<span className='fw-bold text-default'>{step.title}</span>
42+
<span className='fs-caption lh-sm text-secondary'>
43+
{step.body}
44+
</span>
45+
</span>
46+
</li>
47+
))}
48+
</ol>
49+
<RolloutPrerequisite />
50+
<Button
51+
theme='text'
52+
href={ROLLOUT_GUIDE_URL}
53+
target='_blank'
54+
className='align-self-start'
55+
>
56+
<Icon name='file-text' width={14} />
57+
Read the gradual rollout guide
58+
</Button>
59+
</section>
60+
61+
<RolloutComingSoonCard onNotifyMe={onNotifyMe} onFeedback={onFeedback} />
62+
63+
<div className='d-flex align-items-center gap-3'>
64+
<Button onClick={onContinue}>
65+
<Icon name='layers' width={14} />
66+
Create a rollout segment
67+
</Button>
68+
<Button theme='text' onClick={onDismiss}>
69+
Maybe later
70+
</Button>
71+
</div>
72+
</div>
73+
)
74+
75+
export default OnboardingRolloutQuest
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, { FC, useState } from 'react'
2+
import flagsmith from '@flagsmith/flagsmith'
3+
import Button from 'components/base/forms/Button'
4+
import Chip from 'components/base/Chip'
5+
import Icon from 'components/icons/Icon'
6+
import { ROLLOUT_BETA_REQUESTED } from './trackRolloutInterest'
7+
8+
export const ROLLOUT_FEEDBACK_URL =
9+
'mailto:support@flagsmith.com?subject=Gradual%20rollout'
10+
11+
export type RolloutComingSoonCardProps = {
12+
onNotifyMe: () => void
13+
onFeedback: () => void
14+
}
15+
16+
// The one thing we don't ship yet: the three steps above in a single action. It
17+
// promises a simpler flow, never the capability.
18+
const RolloutComingSoonCard: FC<RolloutComingSoonCardProps> = ({
19+
onFeedback,
20+
onNotifyMe,
21+
}) => {
22+
// A trait rather than local state, so asking survives a reload and the beta
23+
// can later be handed out by targeting it. Read at render, as Announcement
24+
// does, so reopening the quest reflects it; the trait read does not
25+
// subscribe, so local state covers the click itself.
26+
const [notified, setNotified] = useState(false)
27+
const alreadyAsked = notified || !!flagsmith.getTrait(ROLLOUT_BETA_REQUESTED)
28+
const notifyMe = () => {
29+
setNotified(true)
30+
flagsmith.setTrait(ROLLOUT_BETA_REQUESTED, true)
31+
onNotifyMe()
32+
}
33+
return (
34+
<section className='bg-surface-action-subtle rounded-xl p-4 d-flex flex-column gap-2'>
35+
{/* Accent rather than the design's solid purple: there is no inverse
36+
text token, and white on the dark-mode action surface is ~3.2:1. */}
37+
<Chip variant='accent' size='xs' className='align-self-start'>
38+
<Icon name='flash' width={12} />
39+
Coming soon
40+
</Chip>
41+
<h6 className='m-0'>We’re making gradual rollouts one-click</h6>
42+
<p className='fs-caption lh-sm text-secondary m-0'>
43+
Automatically release according to a schedule, without manual editing of
44+
segments. Want early access?
45+
</p>
46+
<div className='d-flex align-items-center gap-3'>
47+
{alreadyAsked ? (
48+
<span className='fs-caption d-inline-flex align-items-center gap-2 text-action'>
49+
<Icon name='checkmark-circle' width={16} />
50+
Thanks, we’ll be in touch.
51+
</span>
52+
) : (
53+
<Button onClick={notifyMe}>
54+
<Icon name='bell' width={14} />
55+
Notify me
56+
</Button>
57+
)}
58+
{/* No target: a mailto in a new tab leaves a blank tab behind. */}
59+
<Button theme='text' href={ROLLOUT_FEEDBACK_URL} onClick={onFeedback}>
60+
Tell us what you need
61+
</Button>
62+
</div>
63+
</section>
64+
)
65+
}
66+
67+
export default RolloutComingSoonCard
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default } from './OnboardingRolloutQuest'
2+
export type { OnboardingRolloutQuestProps } from './OnboardingRolloutQuest'
3+
export { default as openRolloutQuest } from './openRolloutQuest'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react'
2+
import API from 'project/api'
3+
import Constants from 'common/constants'
4+
import trackRolloutInterest, {
5+
ROLLOUT_BETA_REQUESTED,
6+
ROLLOUT_FEEDBACK_CLICKED,
7+
} from './trackRolloutInterest'
8+
import OnboardingRolloutQuest from './OnboardingRolloutQuest'
9+
10+
type RolloutQuest = {
11+
featureName: string
12+
/** Where "Create a rollout segment" ends up. */
13+
onContinue: () => void
14+
diagnosticIds: Record<string, unknown>
15+
/** Who to reply to when someone asks for access. */
16+
who: { email?: string; organisation?: string }
17+
}
18+
19+
// The segment overrides tab alone shows none of the steps a rollout takes, so
20+
// this opens first.
21+
const openRolloutQuest = ({
22+
diagnosticIds,
23+
featureName,
24+
onContinue,
25+
who,
26+
}: RolloutQuest): void => {
27+
const track = (event: { category: string; event: string }) =>
28+
API.trackEvent({ ...event, extra: diagnosticIds })
29+
30+
track(Constants.events.ONBOARDING_ROLLOUT_VIEWED)
31+
openModal(
32+
// Matches the next-step card that opens it.
33+
'Gradual rollout',
34+
React.createElement(OnboardingRolloutQuest, {
35+
featureName,
36+
onContinue: () => {
37+
track(Constants.events.ONBOARDING_ROLLOUT_CONTINUED)
38+
closeModal()
39+
onContinue()
40+
},
41+
onDismiss: () => {
42+
track(Constants.events.ONBOARDING_ROLLOUT_DISMISSED)
43+
closeModal()
44+
},
45+
onFeedback: () => {
46+
track(Constants.events.ONBOARDING_ROLLOUT_FEEDBACK)
47+
trackRolloutInterest(ROLLOUT_FEEDBACK_CLICKED, who)
48+
},
49+
onNotifyMe: () => {
50+
track(Constants.events.ONBOARDING_ROLLOUT_NOTIFY_ME)
51+
trackRolloutInterest(ROLLOUT_BETA_REQUESTED, who)
52+
},
53+
}),
54+
'modal--wide',
55+
)
56+
}
57+
58+
export default openRolloutQuest
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React, { FC } from 'react'
2+
import Icon from 'components/icons/Icon'
3+
4+
export const ROLLOUT_GUIDE_URL =
5+
'https://docs.flagsmith.com/managing-flags/rollout/rollout-by-percentage'
6+
7+
export const getRolloutSteps = (
8+
featureName: string,
9+
): { title: string; body: string }[] => [
10+
{
11+
body: 'Add a “Percentage split” rule and set your starting share, e.g. 10% of users.',
12+
title: 'Create a segment',
13+
},
14+
{
15+
body: `Turn ${featureName} on for that segment, so only those users get it.`,
16+
title: 'Override the flag',
17+
},
18+
{
19+
body: 'Raise the percentage as your confidence grows: 10% → 25% → 50% → 100%.',
20+
title: 'Increase over time',
21+
},
22+
]
23+
24+
// Sits with the steps rather than in docs: without it the override reaches
25+
// nobody and the user has no way to tell.
26+
export const RolloutPrerequisite: FC = () => (
27+
<p className='fs-captionSmall lh-sm text-secondary d-flex gap-2 m-0'>
28+
{/* info hardcodes a blue fill, so it needs telling to inherit. */}
29+
<Icon name='info' width={14} fill='currentColor' />
30+
<span>
31+
Percentage splits only apply to users you identify. If your app doesn’t
32+
call <code className='fs-captionSmall'>flagsmith.identify(...)</code> yet,
33+
add it first, or everyone keeps getting the same value.
34+
</span>
35+
</p>
36+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import flagsmith from '@flagsmith/flagsmith'
2+
3+
export const ROLLOUT_BETA_REQUESTED = 'rollout_beta_requested'
4+
export const ROLLOUT_FEEDBACK_CLICKED = 'rollout_feedback_clicked'
5+
6+
type RolloutInterest = {
7+
email?: string
8+
organisation?: string
9+
}
10+
11+
// The onboarding funnel events say an organisation wants this; this says who to
12+
// reply to. Sent through flagsmith.trackEvent, same as the segment sources door.
13+
const trackRolloutInterest = (
14+
event: string,
15+
{ email, organisation }: RolloutInterest,
16+
): void => {
17+
flagsmith.trackEvent(event, {
18+
metadata: {
19+
email,
20+
organisation,
21+
origin: 'onboarding-rollout-quest',
22+
},
23+
})
24+
}
25+
26+
export default trackRolloutInterest

0 commit comments

Comments
 (0)