diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 498c79129914..5e2e01377341 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -404,6 +404,8 @@ export type User = { last_login: string uuid: string onboarding: Onboarding + // Set client-side at login, not returned by the API. + isGettingStarted?: boolean // TODO: Use enum role: string } diff --git a/frontend/documentation/pages/onboarding/OnboardingFlagsTable.stories.tsx b/frontend/documentation/pages/onboarding/OnboardingFlagsTable.stories.tsx new file mode 100644 index 000000000000..30e11ba6f9d8 --- /dev/null +++ b/frontend/documentation/pages/onboarding/OnboardingFlagsTable.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from 'storybook' + +import { Tag } from 'common/types/responses' +import OnboardingFlagsTable, { + OnboardingFlagRow, +} from 'components/pages/onboarding/OnboardingFlagsTable' + +const demoFlag: OnboardingFlagRow = { + description: 'Controls the demo button shown to your users', + enabled: true, + name: 'show_demo_button', +} + +const meta: Meta = { + args: { + flags: [demoFlag], + onToggle: () => {}, + status: 'connected', + }, + component: OnboardingFlagsTable, + parameters: { + docs: { + description: { + component: + 'The "Your flags" card from the onboarding flow, reusing the product FeatureName / Tag / Switch. Prop-driven: the page owns the flag data and the persisted Dev toggle. `connected` lifts the card with the accent border and glow; `waiting` dims it until the first evaluation arrives.', + }, + }, + layout: 'padded', + }, + title: 'Pages/Onboarding/OnboardingFlagsTable', +} +export default meta + +type Story = StoryObj + +export const Connected: Story = {} + +export const Waiting: Story = { + args: { status: 'waiting' }, +} + +export const Off: Story = { + args: { flags: [{ ...demoFlag, enabled: false }] }, +} + +export const WithTag: Story = { + args: { + flags: [ + { ...demoFlag, tags: [{ color: '#6837FC', label: 'demo' } as Tag] }, + ], + }, +} diff --git a/frontend/documentation/pages/onboarding/OnboardingTerminal.stories.tsx b/frontend/documentation/pages/onboarding/OnboardingTerminal.stories.tsx new file mode 100644 index 000000000000..8320c402bdce --- /dev/null +++ b/frontend/documentation/pages/onboarding/OnboardingTerminal.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from 'storybook' + +import OnboardingTerminal from 'components/pages/onboarding/OnboardingTerminal' + +const meta: Meta = { + args: { + connected: false, + featureName: 'show_demo_button', + installCopied: false, + snippetCopied: false, + }, + component: OnboardingTerminal, + parameters: { + docs: { + description: { + component: + 'The onboarding verify console. The checklist ticks as the user acts (copy install, copy snippet), and the first evaluation flips the badge to LIVE and prints the connection receipt. Always dark, since a terminal reads the same in light and dark mode.', + }, + }, + layout: 'padded', + }, + title: 'Pages/Onboarding/OnboardingTerminal', +} +export default meta + +type Story = StoryObj + +export const Listening: Story = {} + +export const InstallCopied: Story = { + args: { installCopied: true }, +} + +export const SnippetsCopied: Story = { + args: { installCopied: true, snippetCopied: true }, +} + +export const Connected: Story = { + args: { connected: true, installCopied: true, snippetCopied: true }, +} diff --git a/frontend/e2e/tests/onboarding-tests.pw.ts b/frontend/e2e/tests/onboarding-tests.pw.ts new file mode 100644 index 000000000000..a529760f6399 --- /dev/null +++ b/frontend/e2e/tests/onboarding-tests.pw.ts @@ -0,0 +1,105 @@ +import { test, expect } from '../test-setup'; +import { byId, createHelpers, getFlagsmith, log, visualSnapshot } from '../helpers'; +import { E2E_SIGN_UP_USER, PASSWORD } from '../config'; + +// The single-page onboarding flow (onboarding_quickstart_flow), at +// /getting-started. Runs only when the flag is on (the legacy signup test runs +// when it's off). +test.describe('Onboarding', () => { + test('New user connects via the single-page onboarding flow @oss', async ({ + page, + }, testInfo) => { + const { addErrorLogging, click, setText, waitForElementVisible } = + createHelpers(page); + const flagsmith = await getFlagsmith(); + + test.skip( + !flagsmith.hasFeature('onboarding_quickstart_flow'), + 'Onboarding flow is behind onboarding_quickstart_flow', + ); + + await addErrorLogging(); + + // The welcome heading means bootstrap settled (loader, then heading/error). + const flowReady = async () => + page + .getByRole('heading', { name: /Welcome/ }) + .waitFor({ state: 'visible', timeout: 30000 }); + + log('Sign up'); + await page.goto('/'); + await click(byId('jsSignup')); + await waitForElementVisible(byId('firstName')); + await setText(byId('firstName'), 'Bullet'); + await setText(byId('lastName'), 'Train'); + await setText(byId('email'), E2E_SIGN_UP_USER); + await setText(byId('password'), PASSWORD); + await click(byId('signup-btn')); + + // Don't navigate manually - a goto races the post-signup auth and bounces to + // /?redirect=. The app routes a getting-started user here itself, so just wait. + log('Land on the onboarding flow'); + await page.waitForURL((url) => url.pathname === '/getting-started', { + timeout: 30000, + }); + await flowReady(); + await visualSnapshot(page, 'onboarding-flow', testInfo); + + await expect(page.getByText('LISTENING')).toBeVisible(); + await expect(page.getByText('Copy install command')).not.toContainText('✓'); + + log('Copy snippets, checklist ticks'); + await page.getByRole('button', { name: 'Copy install command' }).click(); + await expect(page.getByText('Copy install command')).toContainText('✓'); + await page.getByRole('button', { name: 'Copy code snippet' }).click(); + await expect(page.getByText('Copy code snippet')).toContainText('✓'); + + // No real first evaluation in a test, so force the connected state via + // ?connected (the #7767 stub); that unlocks the toggle and flips LIVE. + log('Force the connected state'); + await page.goto('/getting-started?connected'); + await flowReady(); + await expect(page.getByText('LIVE', { exact: true })).toBeVisible(); + + // Two switches on the page (theme + flag), so scope to the flags region. + log('Toggle the flag'); + const flagsTable = page.getByRole('region', { name: 'Your flags' }); + const flagSwitch = flagsTable.getByRole('switch'); + await flagSwitch.waitFor({ state: 'visible' }); + const wasChecked = (await flagSwitch.getAttribute('class'))?.includes( + 'switch-checked', + ); + await flagSwitch.click(); + await expect(flagSwitch).toHaveClass( + wasChecked ? /switch-unchecked/ : /switch-checked/, + ); + + // The Onboarding badge (attached in bootstrap) shows in the flags table. + // Exact match: the header crumb also contains the word "Onboarding". + await expect(flagsTable.getByText('Onboarding', { exact: true })).toBeVisible(); + + // 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'); + await flagInput.fill('renamed_demo_flag'); + await flagInput.press('Enter'); + + // Wait for the rename to persist before reloading - it's a delete + recreate, + // and reloading too early aborts the requests. The toast fires once both land. + await expect(page.getByText('Flag name updated')).toBeVisible({ + timeout: 20000, + }); + + // 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 + .getByRole('region', { name: 'Your flags' }) + .getByText('Onboarding', { exact: true }), + ).toBeVisible(); + await visualSnapshot(page, 'onboarding-renamed', testInfo); + }); +}); diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index aaf4ce4f6b7c..8ff5bb480de8 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -136,8 +136,17 @@ const App = class extends Component { } if (!AccountStore.getOrganisation() && !invite) { - // If user has no organisation redirect to /create - this.props.history.replace(`/create${query}`) + // New users with no organisation go through the single-page onboarding + // flow when it's enabled - it creates the organisation itself, so it + // replaces the legacy /create page. Everyone else still gets /create. + if ( + AccountStore.getUser()?.isGettingStarted && + Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') + ) { + this.props.history.replace('/getting-started') + } else { + this.props.history.replace(`/create${query}`) + } return } diff --git a/frontend/web/components/base/forms/GhostInput/GhostInput.tsx b/frontend/web/components/base/forms/GhostInput/GhostInput.tsx index 2b4b7b381f04..8b1128702a5a 100644 --- a/frontend/web/components/base/forms/GhostInput/GhostInput.tsx +++ b/frontend/web/components/base/forms/GhostInput/GhostInput.tsx @@ -63,6 +63,11 @@ const GhostInput = ({ onKeyDown={onKeyDown} aria-label={ariaLabel} spellCheck={false} + // Opt out of browser autofill + password-manager overlays (1Password, + // LastPass); their icons would overlap the trailing edit pencil. + autoComplete='off' + data-1p-ignore + data-lpignore='true' style={{ width: inputWidth }} /> diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx index 5dba7a0fe286..270897ec8742 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/CodeCard.tsx @@ -6,15 +6,22 @@ import { useCopyFeedback } from 'components/pages/onboarding/hooks/useCopyFeedba export type CodeCardProps = { code: string - // Syntax language for the body's highlighting (e.g. 'bash', 'javascript'). + // highlight.js language for the body. language: string - // Left side of the card header (e.g. the language label or npm/yarn pills). headerLeft: ReactNode + // Drives the verify checklist. + onCopy?: () => void + // Accessible name; the visible "Copy Code" label is identical on every card. + copyLabel?: string } -// Owns its own "Copied" feedback so each card is independent. Highlight escapes -// the body for display; Copy uses the raw string. -const CodeCard: FC = ({ code, headerLeft, language }) => { +const CodeCard: FC = ({ + code, + copyLabel, + headerLeft, + language, + onCopy, +}) => { const { copied, copy } = useCopyFeedback() return ( @@ -25,7 +32,11 @@ const CodeCard: FC = ({ code, headerLeft, language }) => { theme='primary' size='small' className='ms-auto' - onClick={() => copy(code)} + aria-label={copyLabel} + onClick={() => { + copy(code) + onCopy?.() + }} > void + onCopyWire?: () => void } // "Connect your code" tab: pick an SDK, then copy the install + wire snippets, -// pre-filled with the real env key and flag this onboarding created. +// pre-filled with the real env key and flag this onboarding created. The copy +// actions feed the verify checklist. const ConnectYourCodePanel: FC = ({ environmentKey, featureName, + onCopyInstall, + onCopyWire, }) => { const [sdkLang, setSdkLang] = useState(SDK_LANGS[0]) const [installPm, setInstallPm] = useState('npm') @@ -40,6 +45,8 @@ const ConnectYourCodePanel: FC = ({ @@ -74,6 +81,8 @@ const ConnectYourCodePanel: FC = ({ {sdkLang.label} diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss index 01d722c16eff..55e5df84ecd0 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.scss @@ -17,7 +17,7 @@ &__prompt-text { color: var(--color-text-default); - font-family: var(--font-family-mono, monospace); + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 0.8125rem; line-height: 1.5; background: transparent; diff --git a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.tsx b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.tsx index d74c815098fe..3e76d28ae58f 100644 --- a/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingConnectPanel/OnboardingConnectPanel.tsx @@ -28,6 +28,8 @@ const CONNECT_TABS: OnboardingTab[] = [ export type OnboardingConnectPanelProps = { environmentKey: string featureName: string + onCopyInstall?: () => void + onCopyWire?: () => void } // Two ways to connect an app to the pre-created flag: paste an agent-agnostic @@ -37,6 +39,8 @@ export type OnboardingConnectPanelProps = { const OnboardingConnectPanel: FC = ({ environmentKey, featureName, + onCopyInstall, + onCopyWire, }) => { const [tab, setTab] = useState('manual') @@ -68,6 +72,8 @@ const OnboardingConnectPanel: FC = ({ diff --git a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss new file mode 100644 index 000000000000..a55b52bb8bce --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.scss @@ -0,0 +1,68 @@ +// "Your flags" card. Layout/surface via utilities; this keeps the accent glow, +// the waiting state, and the non-standard metrics. +.onboarding-flags { + gap: 14px; // no utility step for 14px + + &__title { + color: var(--color-text-default); + font-size: 16px; + } + + &__table { + width: 100%; + max-width: 760px; + overflow: hidden; + // Accent glow, oklch-derived from the action colour so it tracks the theme. + border: 1px solid oklch(from var(--color-border-action) l c h / 0.4); + box-shadow: 0 18px 44px 2px oklch(from var(--color-border-action) l c h / 0.2), + var(--shadow-md); + transition: opacity var(--duration-fast) var(--easing-standard), + box-shadow var(--duration-fast) var(--easing-standard), + border-color var(--duration-fast) var(--easing-standard); + + &--waiting { + border-color: var(--color-border-default); + box-shadow: none; + opacity: 0.7; + } + } + + &__head { + gap: 12px; + padding: 8px 20px; + border-bottom: 1px solid var(--color-border-default); + } + + &__col { + color: var(--color-text-secondary); + font-size: 11px; + font-weight: var(--font-weight-bold); + letter-spacing: 0.5px; + + &--feature { + flex: 1; + } + &--enabled { + width: 96px; + } + } + + &__row { + gap: 12px; + padding: 14px 20px; + } + + &__feature { + flex: 1; + } + + &__desc { + color: var(--color-text-secondary); + font-size: 12px; + } + + // Match the ENABLED header column width so the toggle lines up under it. + &__toggle { + width: 96px; + } +} diff --git a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx new file mode 100644 index 000000000000..2b3fcf580760 --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/OnboardingFlagsTable.tsx @@ -0,0 +1,97 @@ +import React, { FC } from 'react' +import classNames from 'classnames' +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 './OnboardingFlagsTable.scss' + +export type OnboardingFlagsTableStatus = 'waiting' | 'connected' + +export type OnboardingFlagRow = { + name: string + description?: string + tags?: TTag[] + enabled: boolean +} + +export type OnboardingFlagsTableProps = { + status: OnboardingFlagsTableStatus + flags: OnboardingFlagRow[] + onToggle: (flag: OnboardingFlagRow, enabled: boolean) => void + togglingFlag?: string | null + // Defaults true; while false the toggle stays disabled (state not loaded yet). + togglesReady?: boolean +} + +// "Your flags" card: the pre-created flag in a table that reuses the product +// FeatureName / Tag / Switch. Connected lifts it with a glow; waiting dims it. +const OnboardingFlagsTable: FC = ({ + flags, + onToggle, + status, + togglesReady = true, + togglingFlag, +}) => { + const waiting = status === 'waiting' + // Locked until connected and the flag state has loaded (else a click no-ops). + const togglesLocked = waiting || !togglesReady + return ( +
+

+ Your flags +

+
+
+ + FEATURE + + + ENABLED + +
+ {flags.map((flag) => ( +
+
+
+ + {flag.tags?.map((tag) => ( + + ))} +
+ {flag.description && ( +

{flag.description}

+ )} +
+
+ onToggle(flag, enabled)} + aria-label={`Toggle ${flag.name}`} + /> +
+
+ ))} +
+
+ ) +} + +export default OnboardingFlagsTable diff --git a/frontend/web/components/pages/onboarding/OnboardingFlagsTable/index.ts b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/index.ts new file mode 100644 index 000000000000..435ba4d35415 --- /dev/null +++ b/frontend/web/components/pages/onboarding/OnboardingFlagsTable/index.ts @@ -0,0 +1,6 @@ +export { default } from './OnboardingFlagsTable' +export type { + OnboardingFlagRow, + OnboardingFlagsTableProps, + OnboardingFlagsTableStatus, +} from './OnboardingFlagsTable' diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index ab5c26ea3354..b3dcecf573bc 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -5,18 +5,18 @@ import Icon from 'components/icons/Icon' import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader' 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 { useEnsureOnboardingResources } from 'components/pages/onboarding/hooks/useEnsureOnboardingResources' import { useOnboardingFlagRename } from 'components/pages/onboarding/hooks/useOnboardingFlagRename' +import { useOnboardingFlag } from 'components/pages/onboarding/hooks/useOnboardingFlag' +import { useOnboardingConnection } from 'components/pages/onboarding/hooks/useOnboardingConnection' import { useUpdateOrganisationMutation } from 'common/services/useOrganisation' import { useUpdateProjectMutation } from 'common/services/useProject' import './OnboardingFlow.scss' -// The new single-page onboarding experience, rendered at /getting-started when -// the `onboarding_quickstart_flow` flag is on (see GettingStartedGate). -// -// Resources (org / project / Dev + Prod / first flag) are bootstrapped -// idempotently by useEnsureOnboardingResources, and the inline header chips -// persist renames. TODO(#7766): the verify console / flags table land on top. +// The single-page onboarding flow, rendered at /getting-started when +// onboarding_quickstart_flow is on (see GettingStartedGate). const OnboardingFlow: FC = () => { const { caseSensitive, @@ -34,17 +34,15 @@ const OnboardingFlow: FC = () => { const [updateOrganisation] = useUpdateOrganisationMutation() const [updateProject] = useUpdateProjectMutation() - // The flow is chromeless (no app nav), so it owns its only way out: skip to - // the org's projects and set things up manually. + // Chromeless flow, so it owns its only exit. const skipToApp = () => history.push( organisationId !== null ? `/organisation/${organisationId}/projects` : '/', ) - // Inline renames are optimistic and revert if the persist fails. The flag name - // also drives the connect-panel snippets/prompt, so it defaults to the - // bootstrapped flag (its real name, reused on revisit). + // Inline renames are optimistic, reverting on failure. featureName drives the + // snippets, so it defaults to the bootstrapped flag. const [renamedOrganisation, setRenamedOrganisation] = useState( null, ) @@ -59,8 +57,20 @@ const OnboardingFlow: FC = () => { projectId, }) - // Org/project are single-field PATCHes; the shell nav adopts the new names on - // its next load. + // Connection is stubbed until #7767 (useOnboardingConnection); the toggle is real. + const connection = useOnboardingConnection() + // Session-only: a reload resets the checklist. Fine for onboarding. + const [installCopied, setInstallCopied] = useState(false) + const [snippetCopied, setSnippetCopied] = useState(false) + const { + enabled: flagEnabled, + isToggling, + ready: flagStateReady, + tags: flagTags, + toggle: toggleFlag, + } = useOnboardingFlag(environment, projectId, featureName) + + // Single-field PATCHes; the shell adopts the new names on its next load. const renameOrganisation = async (name: string) => { if (organisationId === null) { return @@ -92,8 +102,7 @@ const OnboardingFlow: FC = () => { toast('Couldn’t update your project name. Please try again.', 'danger') } } - // The flag and its snippet name must stay in lockstep, so this persists - // (delete + recreate). Optimistic, reverting on failure. + // Flag name and snippet stay in lockstep; persists via delete + recreate. const renameFeature = async (name: string) => { const previous = featureName setRenamedFeature(name) @@ -101,8 +110,7 @@ const OnboardingFlow: FC = () => { toast('Flag name updated') } else { setRenamedFeature(previous) - // Only surface an error for a genuine failure - a rename attempted before - // the flag query settles also returns false, and shouldn't alarm the user. + // Don't alarm on a rename fired before the flag query settles. if (flagReady) { toast('Couldn’t rename your flag. Please try again.', 'danger') } @@ -117,9 +125,7 @@ const OnboardingFlow: FC = () => { ) } - // Bootstrap failed (e.g. a plan org cap, or a network error). Without this the - // flow would render with an empty environment key and broken snippets, so show - // a recoverable message instead. A reload re-runs the idempotent bootstrap. + // Bootstrap failed (e.g. a plan org cap). Recoverable; a reload re-runs it. if (status === 'error') { return (
@@ -149,6 +155,28 @@ const OnboardingFlow: FC = () => { setInstallCopied(true)} + onCopyWire={() => setSnippetCopied(true)} + /> + + toggleFlag(next)} + togglingFlag={isToggling ? featureName : null} + togglesReady={flagStateReady} />