diff --git a/frontend/common/code-help/init/init-react.js b/frontend/common/code-help/init/init-react.js index 6b1a3412cf0e..2791237c6b1f 100644 --- a/frontend/common/code-help/init/init-react.js +++ b/frontend/common/code-help/init/init-react.js @@ -3,9 +3,20 @@ import Constants from 'common/constants' export default ( envId, { FEATURE_NAME, FEATURE_NAME_ALT, LIB_NAME, NPM_CLIENT }, -) => `// App root -import ${LIB_NAME} from "${NPM_CLIENT}"; -import { FlagsmithProvider } from '@flagsmith/flagsmith/react'; +) => `import ${LIB_NAME} from "${NPM_CLIENT}"; +import { FlagsmithProvider, useFlags } from '${NPM_CLIENT}/react'; + +export function HomePage() { + const flags = useFlags(['${FEATURE_NAME}','${FEATURE_NAME_ALT}']); // only causes re-render if specified flag values / traits change + const ${FEATURE_NAME} = flags.${FEATURE_NAME}.enabled + const ${FEATURE_NAME_ALT} = flags.${FEATURE_NAME_ALT}.value + return ( + <> + {\`${FEATURE_NAME}: \${${FEATURE_NAME}}\`} + {\`${FEATURE_NAME_ALT}: \${${FEATURE_NAME_ALT}}\`} + </> + ); +} export default function App() { return ( @@ -17,21 +28,8 @@ export default function App() { : '' } }} - flagsmith={flagsmith}> - {...Your app} - </FlagsmithProvider> - ); -} - -// Home Page -import ${LIB_NAME} from '${NPM_CLIENT}'; -import { useFlags, useFlagsmith } from '${NPM_CLIENT}/react'; - -export default function HomePage() { - const flags = useFlags(['${FEATURE_NAME}','${FEATURE_NAME_ALT}']); // only causes re-render if specified flag values / traits change - const ${FEATURE_NAME} = flags.${FEATURE_NAME}.enabled - const ${FEATURE_NAME_ALT} = flags.${FEATURE_NAME_ALT}.value - return ( - <>{...}</> + flagsmith={${LIB_NAME}}> + <HomePage /> + </FlagsmithProvider> ); }` diff --git a/frontend/documentation/pages/onboarding/OnboardingNextSteps.stories.tsx b/frontend/documentation/pages/onboarding/OnboardingNextSteps.stories.tsx new file mode 100644 index 000000000000..bbbf3e0802fa --- /dev/null +++ b/frontend/documentation/pages/onboarding/OnboardingNextSteps.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from 'storybook' + +import OnboardingNextSteps from 'components/pages/onboarding/OnboardingNextSteps' + +const meta: Meta = { + 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 + +export const Connected: Story = {} + +export const Locked: Story = { + args: { locked: true }, +} diff --git a/frontend/e2e/tests/onboarding-tests.pw.ts b/frontend/e2e/tests/onboarding-tests.pw.ts index a529760f6399..4f7e483ae588 100644 --- a/frontend/e2e/tests/onboarding-tests.pw.ts +++ b/frontend/e2e/tests/onboarding-tests.pw.ts @@ -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('✓'); @@ -81,7 +86,7 @@ test.describe('Onboarding', () => { // Rename the flag. Names are immutable, so this delete + recreates; the // Onboarding tag must survive (the recreate carries the old flag's tags). log('Rename the flag'); - const flagInput = page.getByLabel('Flag name'); + const flagInput = page.getByLabel('Edit flag'); await flagInput.fill('renamed_demo_flag'); await flagInput.press('Enter'); @@ -94,12 +99,34 @@ test.describe('Onboarding', () => { // Reload to prove it persisted (bootstrap reuses the renamed flag). await page.reload(); await flowReady(); - await expect(page.getByLabel('Flag name')).toHaveValue('renamed_demo_flag'); + await expect(page.getByLabel('Edit flag')).toHaveValue('renamed_demo_flag'); await expect( page .getByRole('region', { name: 'Your flags' }) .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$/); }); }); diff --git a/frontend/web/components/base/SelectableCard/SelectableCard.scss b/frontend/web/components/base/SelectableCard/SelectableCard.scss index 0315b3f4a852..72eae4590544 100644 --- a/frontend/web/components/base/SelectableCard/SelectableCard.scss +++ b/frontend/web/components/base/SelectableCard/SelectableCard.scss @@ -36,6 +36,8 @@ display: flex; flex-direction: column; gap: 4px; + flex: 1; + min-width: 0; } &__icon { diff --git a/frontend/web/components/base/SelectableCard/SelectableCard.tsx b/frontend/web/components/base/SelectableCard/SelectableCard.tsx index 3a614f228702..a88d42577312 100644 --- a/frontend/web/components/base/SelectableCard/SelectableCard.tsx +++ b/frontend/web/components/base/SelectableCard/SelectableCard.tsx @@ -1,11 +1,14 @@ 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 @@ -13,10 +16,15 @@ type SelectableCardProps = { 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 = ({ badge, + children, + className, description, disabled, icon, @@ -27,9 +35,14 @@ const SelectableCard: FC = ({ }) => { return ( @@ -46,6 +59,7 @@ const SelectableCard: FC = ({ ))} )} + {children} {badge && (
diff --git a/frontend/web/components/pages/onboarding/InlineInput/InlineInput.scss b/frontend/web/components/pages/onboarding/InlineInput/InlineInput.scss index 80fb93e7b9da..045c13b15612 100644 --- a/frontend/web/components/pages/onboarding/InlineInput/InlineInput.scss +++ b/frontend/web/components/pages/onboarding/InlineInput/InlineInput.scss @@ -1,32 +1,24 @@ -// Inline editable value in the welcome sentence: an action underline + pencil, -// with a faint highlight on hover/focus. Rationale lives in InlineInput.tsx. .inline-input { display: inline-flex; align-items: center; vertical-align: baseline; gap: 4px; - padding: 0 4px; - border-radius: var(--radius-xs); + padding: 1px 8px; + // Round the top only; the bottom stays square so it sits flush with the + // action underline (rounded bottom corners fight the border). + border-radius: var(--radius-xs) var(--radius-xs) 0 0; border-bottom: 1px solid var(--color-border-action); + // Resting fill so it reads as editable, not only on hover. + background-color: var(--color-surface-subtle); cursor: text; transition: background-color var(--duration-fast) var(--easing-standard); - svg { - opacity: 0.8; - transition: opacity var(--duration-fast) var(--easing-standard); - } - &:hover, &:focus-within { - background-color: var(--color-surface-subtle); - - svg { - opacity: 1; - } + background-color: var(--color-surface-muted); } - // The hero value (flag name): a filled soft-purple pill instead of an - // underline, so it reads as the thing the user will reference in code. + // Flag name: a filled pill rather than an underline (it's referenced in code). &--accent { padding: 1px 8px; border-bottom: none; diff --git a/frontend/web/components/pages/onboarding/InlineInput/InlineInput.tsx b/frontend/web/components/pages/onboarding/InlineInput/InlineInput.tsx index 82ac5f2b173e..4eba9d87737d 100644 --- a/frontend/web/components/pages/onboarding/InlineInput/InlineInput.tsx +++ b/frontend/web/components/pages/onboarding/InlineInput/InlineInput.tsx @@ -23,8 +23,8 @@ export type InlineInputProps = { } // Onboarding-local inline editable value (GhostInput + pencil) for the welcome -// sentence: an action underline + pencil mark it editable, it commits on -// blur/Enter, and an empty value reverts. Shares the VariationKeyLabel inline +// sentence: a resting fill, action underline and pencil mark it editable; it +// commits on blur/Enter, and an empty value reverts. Shares the VariationKeyLabel inline // edit's visual language but drops its buttons/validation to stay prose-like; // feature-local for now, both should converge on one primitive. const InlineInput: FC = ({ @@ -67,7 +67,8 @@ const InlineInput: FC = ({ value={draft} placeholder={label} maxLength={maxLength} - aria-label={`${label} name`} + // Avoid a "name" label here: Safari reads it as a contact field. + aria-label={`Edit ${label.toLowerCase()}`} onChange={(e) => { const raw = e.target.value setDraft(transform ? transform(raw) : raw) diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectWithAiPanel.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectWithAiPanel.tsx index 626a1000f44e..f16683da39c6 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectWithAiPanel.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectWithAiPanel.tsx @@ -32,7 +32,7 @@ Detect my stack, install the SDK, and wire ${featureName} into one place. Then r return ( <> - + Paste this into your AI coding agent’s chat
@@ -50,7 +50,9 @@ Detect my stack, install the SDK, and wire ${featureName} into one place. Then r
- What happens next + + What happens next +
  • Detects your stack: language, framework and package manager.
  • Installs the Flagsmith SDK and wires it into your code.
  • diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx index b66882d24569..a32a58387b42 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/ConnectYourCodePanel.tsx @@ -1,5 +1,6 @@ import React, { FC, useState } from 'react' import classNames from 'classnames' +import Chip from 'components/base/Chip' import CodeCard from './CodeCard' import SdkPicker from './SdkPicker' import { getSdkSnippet } from './sdkSnippets' @@ -9,6 +10,18 @@ type PackageManager = 'npm' | 'yarn' const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'yarn'] +// The chosen SDK as an accent badge (logo + label), matching the picker's +// selected chip - the code card labels its language with this. +const SdkBadge: FC<{ lang: SdkLang }> = ({ lang }) => { + const Logo = lang.logo + return ( + + + {lang.label} + + ) +} + export type ConnectYourCodePanelProps = { environmentKey: string featureName: string @@ -40,7 +53,9 @@ const ConnectYourCodePanel: FC = ({
    1 - Install the SDK + + Install the SDK +
    = ({ ))}
    ) : ( - - {sdkLang.label} - + ) } /> @@ -74,7 +87,7 @@ const ConnectYourCodePanel: FC = ({
    2 - + Wire it in & take instant control of what users see
    @@ -83,11 +96,7 @@ const ConnectYourCodePanel: FC = ({ language={sdkSnippet.language} onCopy={onCopyWire} copyLabel='Copy code snippet' - headerLeft={ - - {sdkLang.label} - - } + headerLeft={} />
    diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss index 55e5df84ecd0..80147b9e5daa 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss @@ -47,10 +47,10 @@ border: 0; border-radius: 0; color: var(--color-text-default); - // Reserve the scrollbar gutter permanently so hover doesn't shove the - // layout (the global dark-scroll mixin toggles display:none on a - // space-reserving webkit bar otherwise). - overflow-x: scroll; + // Scroll only when the snippet overflows. When it does, the webkit rule + // below forces the bar visible over the global dark-scroll mixin (which + // display:none-s it on hover), so a long snippet's bar doesn't flicker. + overflow-x: auto; overflow-y: hidden; &::-webkit-scrollbar, @@ -100,12 +100,6 @@ border-bottom: 1px solid var(--color-border-default); } - &__codecard-lang { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-secondary); - } - &__pm { border-radius: 6px; overflow: hidden; diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/SdkPicker.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/SdkPicker.tsx index e3f4ea0a3772..fb477210edc1 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/SdkPicker.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/SdkPicker.tsx @@ -69,6 +69,7 @@ const SdkPicker: FC = ({ onSelect, selected }) => { ref={(el) => { refs.current[lang.label] = el }} + className='font-weight-medium' role='radio' aria-checked={isSelected} tabIndex={lang.label === tabStopLabel ? 0 : -1} @@ -91,6 +92,7 @@ const SdkPicker: FC = ({ onSelect, selected }) => {
    {popularLangs.map(renderOption)} setMoreOpen((open) => !open)} aria-expanded={moreOpen} > diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/logos/javascript-logo.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/logos/javascript-logo.tsx index f7299f0ae2f8..dca252b249f8 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/logos/javascript-logo.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/logos/javascript-logo.tsx @@ -1,8 +1,16 @@ import React from 'react' -import Svg from './svg' +// Two-tone on purpose: the other logos are single-fill glyphs, but JS is a +// filled badge. Drawing the "JS" as solid black over the yellow square (rather +// than the shared wrapper's hole-punch, which shows the chip through it and +// washes out on a light chip) keeps it readable on both light and dark. Rounded +// so it reads as a badge. export const JavascriptLogo = () => ( - - - + + + + ) diff --git a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss index a55b52bb8bce..dc24f8b8f51a 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss +++ b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss @@ -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; diff --git a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx index 2b3fcf580760..58dc7fffa825 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx @@ -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' @@ -41,12 +42,20 @@ const OnboardingFlagsTable: FC = ({ className='onboarding-flags d-flex flex-column align-items-center' aria-labelledby='onboarding-flags-title' > -

    - Your flags -

    +
    +

    + Your flags +

    + {waiting && ( + + + Flip it once your app connects + + )} +
    { const [snippetCopied, setSnippetCopied] = useState(false) const { enabled: flagEnabled, + flagId, isToggling, ready: flagStateReady, tags: flagTags, @@ -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 (
    @@ -178,6 +200,10 @@ const OnboardingFlow: FC = () => { togglingFlag={isToggling ? featureName : null} togglesReady={flagStateReady} /> +