Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from 'storybook'

import OnboardingNextSteps from 'components/pages/onboarding/OnboardingNextSteps'

const meta: Meta<typeof OnboardingNextSteps> = {
args: {
locked: false,
onSelect: () => {},
},
component: OnboardingNextSteps,
parameters: {
docs: {
description: {
component:
'The "Choose your next quest" section at the bottom of the onboarding flow: the three ways the demo flag can level up (gradual rollout, experiment, remote config), each linking to its real config. Locked and dimmed until the app connects.',
},
},
layout: 'padded',
},
title: 'Pages/Onboarding/OnboardingNextSteps',
}
export default meta

type Story = StoryObj<typeof OnboardingNextSteps>

export const Connected: Story = {}

export const Locked: Story = {
args: { locked: true },
}
27 changes: 27 additions & 0 deletions frontend/e2e/tests/onboarding-tests.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ test.describe('Onboarding', () => {
await expect(page.getByText('LISTENING')).toBeVisible();
await expect(page.getByText('Copy install command')).not.toContainText('✓');

// Before the first evaluation, the next-quest cards are locked.
await expect(
page.getByText('Unlocks after your first evaluation'),
).toBeVisible();

log('Copy snippets, checklist ticks');
await page.getByRole('button', { name: 'Copy install command' }).click();
await expect(page.getByText('Copy install command')).toContainText('✓');
Expand Down Expand Up @@ -101,5 +106,27 @@ test.describe('Onboarding', () => {
.getByText('Onboarding', { exact: true }),
).toBeVisible();
await visualSnapshot(page, 'onboarding-renamed', testInfo);

// The next-quest cards unlock once connected, each deep-linking to the
// flag's real config (nothing faked).
log('Next-quest cards link to the real config');
await expect(
page.getByRole('heading', { name: 'Choose your next quest' }),
).toBeVisible();

await page.getByRole('button', { name: /Gradual rollout/ }).click();
await expect(page).toHaveURL(
/\/features\?feature=\d+&tab=segment-overrides/,
);

await page.goto('/getting-started?connected');
await flowReady();
await page.getByRole('button', { name: /Remote config/ }).click();
await expect(page).toHaveURL(/\/features\?feature=\d+&tab=value/);

await page.goto('/getting-started?connected');
await flowReady();
await page.getByRole('button', { name: /Experiment/ }).click();
await expect(page).toHaveURL(/\/experiments$/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}

&__icon {
Expand Down
22 changes: 18 additions & 4 deletions frontend/web/components/base/SelectableCard/SelectableCard.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import React, { FC, ReactNode } from 'react'
import cn from 'classnames'
import BareButton from 'components/base/forms/BareButton'
import './SelectableCard.scss'

type BadgeVariant = 'primary' | 'secondary'

type SelectableCardProps = {
selected: boolean
// Selection state. Omit for a card that acts on click without a chosen state
// (e.g. a card that navigates).
selected?: boolean
onClick: () => void
icon?: ReactNode
title: string
description: string
badge?: { label: string; variant: BadgeVariant }
tags?: string[]
disabled?: boolean
className?: string
// Extra content below the description (e.g. an illustrative preview).
children?: ReactNode
}

const SelectableCard: FC<SelectableCardProps> = ({
badge,
children,
className,
description,
disabled,
icon,
Expand All @@ -27,9 +35,14 @@ const SelectableCard: FC<SelectableCardProps> = ({
}) => {
return (
<BareButton
className={`selectable-card${
selected ? ' selectable-card--selected' : ''
}${disabled ? ' selectable-card--disabled' : ''}`}
className={cn(
'selectable-card',
{
'selectable-card--disabled': disabled,
'selectable-card--selected': selected,
},
className,
)}
onClick={onClick}
disabled={disabled}
>
Expand All @@ -46,6 +59,7 @@ const SelectableCard: FC<SelectableCardProps> = ({
))}
</div>
)}
{children}
</div>
{badge && (
<div className='selectable-card__aside'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
font-size: 16px;
}

// Lock affordance beside the title while the toggle is waiting to connect.
&__hint {
font-size: 13px;
color: var(--color-text-tertiary);
}

&__table {
width: 100%;
max-width: 760px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Tag as TTag } from 'common/types/responses'
import FeatureName from 'components/feature-summary/FeatureName'
import Tag from 'components/tags/Tag'
import Switch from 'components/Switch'
import Icon from 'components/icons/Icon'
import './OnboardingFlagsTable.scss'

export type OnboardingFlagsTableStatus = 'waiting' | 'connected'
Expand Down Expand Up @@ -41,12 +42,20 @@ const OnboardingFlagsTable: FC<OnboardingFlagsTableProps> = ({
className='onboarding-flags d-flex flex-column align-items-center'
aria-labelledby='onboarding-flags-title'
>
<h3
className='onboarding-flags__title m-0 fw-bold'
id='onboarding-flags-title'
>
Your flags
</h3>
<div className='onboarding-flags__heading d-flex align-items-center gap-2'>
<h3
className='onboarding-flags__title m-0 fw-bold'
id='onboarding-flags-title'
>
Your flags
</h3>
{waiting && (
<span className='onboarding-flags__hint d-flex align-items-center gap-1'>
<Icon name='lock' width={13} />
Flip it once your app connects
</span>
)}
</div>
<div
className={classNames(
'onboarding-flags__table bg-surface-default rounded-xl',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import ThemeToggle from 'components/pages/onboarding/ThemeToggle'
import OnboardingConnectPanel from 'components/pages/onboarding/OnboardingConnectPanel'
import OnboardingTerminal from 'components/pages/onboarding/OnboardingTerminal'
import OnboardingFlagsTable from 'components/pages/onboarding/OnboardingFlagsTable'
import OnboardingNextSteps, {
OnboardingNextStep,
} from 'components/pages/onboarding/OnboardingNextSteps'
import { useEnsureOnboardingResources } from 'components/pages/onboarding/hooks/useEnsureOnboardingResources'
import { useOnboardingFlagRename } from 'components/pages/onboarding/hooks/useOnboardingFlagRename'
import { useOnboardingFlag } from 'components/pages/onboarding/hooks/useOnboardingFlag'
Expand Down Expand Up @@ -64,6 +67,7 @@ const OnboardingFlow: FC = () => {
const [snippetCopied, setSnippetCopied] = useState(false)
const {
enabled: flagEnabled,
flagId,
isToggling,
ready: flagStateReady,
tags: flagTags,
Expand Down Expand Up @@ -117,6 +121,24 @@ const OnboardingFlow: FC = () => {
}
}

// Each next-step card deep-links to the flag's real config; nothing faked.
const goToNextStep = (step: OnboardingNextStep) => {
if (projectId === null) {
return
}
const base = `/project/${projectId}/environment/${environmentKey}`
if (step === 'experiment') {
history.push(`${base}/experiments`)
return
}
if (flagId === null) {
return
}
// Tab param is the slugified tab label (see TabMenu/Tabs urlParam).
const tab = step === 'rollout' ? 'segment-overrides' : 'value'
history.push(`${base}/features?feature=${flagId}&tab=${tab}`)
}

if (status === 'creating') {
return (
<div className='onboarding-flow mx-auto text-center'>
Expand Down Expand Up @@ -178,6 +200,10 @@ const OnboardingFlow: FC = () => {
togglingFlag={isToggling ? featureName : null}
togglesReady={flagStateReady}
/>
<OnboardingNextSteps
locked={connection !== 'connected'}
onSelect={goToNextStep}
/>
<div className='d-flex justify-content-end'>
<Button theme='text' onClick={skipToApp}>
<span className='d-inline-flex align-items-center gap-1'>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// "Choose your next quest" section. Layout comes from utilities in the markup;
// this file holds the type sizes, secondary text colour, the dimmed locked
// state, and the small per-card preview shapes (rollout track, A/B segmented,
// value pill). Secondary text isn't a utility: Bootstrap's .text-secondary
// (gold, !important) would win over our token utility, so it's set here.
.onboarding-next-steps {
&__title {
font-size: 16px;
}

&__subtitle {
font-size: 13px;
line-height: 1.5;
color: var(--color-text-secondary);
}

// Locked affordance: intentionally faint (disabled look). The lock icon
// inherits this via currentColor.
&__lock {
font-size: 12px;
color: var(--color-text-tertiary);
}

&__caption {
font-size: 12px;
color: var(--color-text-secondary);
}

// Dimmed until the app connects (the design's 0.4 locked state). The `inert`
// attribute on the same element blocks pointer + keyboard interaction.
&__body--locked {
opacity: 0.4;
}

// Equal-width cards regardless of their copy length.
&__row > * {
flex: 1 1 0;
min-width: 0;
}

// Illustrative preview sits a little below the card's description.
&__preview {
margin-top: 6px;
}

// Rollout: a part-filled pill track.
&__track {
display: block;
width: 100%;
height: 8px;
border-radius: var(--radius-full);
background: var(--color-border-default);
}

&__track-fill {
display: block;
width: 25%;
height: 100%;
border-radius: var(--radius-full);
background: var(--color-surface-action);
}

// Experiment: an A/B segmented control.
&__segmented {
align-self: flex-start;
overflow: hidden;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
}

&__segment {
padding: 5px 16px;
font-size: 12px;
color: var(--color-text-secondary);
background: var(--color-surface-default);

&--active {
color: var(--color-text-action);
background: var(--color-surface-action-subtle);
}
}

// Remote config: a value chip.
&__value-pill {
align-self: flex-start;
padding: 5px 10px;
border-radius: var(--radius-md);
font-size: 12px;
}
}
Loading
Loading