From 3f8c70c4ff8c87e796eedc00bdfe276aff2a8b62 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 4 Aug 2026 10:46:00 -0300 Subject: [PATCH 1/2] fix(onboarding): don't seed a demo flag into a project that has flags Visiting /getting-started created show_demo_button in whichever project came back first, whether or not the customer had ever onboarded. Features are project-level, so it appeared in every environment of that project, including production, alongside a new Onboarding tag. The Getting Started nav link is ungated, so any customer could trigger this by clicking it. ensureFlag already computed isFirstFeature for analytics; it now also decides whether to create anything. An empty project still gets the demo flag, an established one gets nothing. That leaves the tour with no flag to teach with, so the page says so and points at the project's own flags instead of walking someone through connecting a project that is already connected. Copy and treatment of that state are provisional. Co-Authored-By: Claude Opus 5 (1M context) --- .../OnboardingFlow/OnboardingFlow.tsx | 13 ++++++ .../hooks/__tests__/demoFlag.test.ts | 45 +++++++++++++++++++ .../onboarding/hooks/bootstrapOnboarding.ts | 35 ++++++++------- .../pages/onboarding/hooks/demoFlag.ts | 25 +++++++++++ .../hooks/useEnsureOnboardingResources.ts | 4 ++ .../OnboardingAlreadySetUp.tsx | 32 +++++++++++++ .../onboarding-already-set-up/index.ts | 2 + 7 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 frontend/web/components/pages/onboarding/hooks/__tests__/demoFlag.test.ts create mode 100644 frontend/web/components/pages/onboarding/hooks/demoFlag.ts create mode 100644 frontend/web/components/pages/onboarding/onboarding-already-set-up/OnboardingAlreadySetUp.tsx create mode 100644 frontend/web/components/pages/onboarding/onboarding-already-set-up/index.ts diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 7f8f8acdcfc7..810495052f41 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -17,6 +17,7 @@ import { useOnboardingConnection } from 'components/pages/onboarding/hooks/useOn import { useUpdateOrganisationMutation } from 'common/services/useOrganisation' import { useUpdateProjectMutation } from 'common/services/useProject' import API from 'project/api' +import OnboardingAlreadySetUp from 'components/pages/onboarding/onboarding-already-set-up' import Constants from 'common/constants' import './OnboardingFlow.scss' @@ -30,6 +31,7 @@ const OnboardingFlow: FC = () => { environment, environmentKey, featureName: bootstrappedFeatureName, + hasDemoFlag, organisationId, organisationName, projectId, @@ -197,6 +199,17 @@ const OnboardingFlow: FC = () => { ) } + // The project already had flags, so nothing was seeded to tour with. + if (!hasDemoFlag) { + return ( + + ) + } + return (
diff --git a/frontend/web/components/pages/onboarding/hooks/__tests__/demoFlag.test.ts b/frontend/web/components/pages/onboarding/hooks/__tests__/demoFlag.test.ts new file mode 100644 index 000000000000..64f290bfc18d --- /dev/null +++ b/frontend/web/components/pages/onboarding/hooks/__tests__/demoFlag.test.ts @@ -0,0 +1,45 @@ +import { ProjectFlag, Tag } from 'common/types/responses' +import { + DEMO_FLAG_NAME, + findDemoFlag, + shouldSeedDemoFlag, +} from 'components/pages/onboarding/hooks/demoFlag' + +const flag = (name: string, tags: number[] = []): ProjectFlag => + ({ id: name.length, name, tags } as ProjectFlag) + +const onboardingTag = { id: 7, label: 'Onboarding' } as Tag + +describe('shouldSeedDemoFlag', () => { + it('seeds into an empty project', () => { + expect(shouldSeedDemoFlag([])).toBe(true) + }) + + it('seeds nothing once the project has flags of its own', () => { + // The customer's list. A flag they did not ask for would appear in every + // environment, production included. + expect(shouldSeedDemoFlag([flag('checkout_v2')])).toBe(false) + }) +}) + +describe('findDemoFlag', () => { + it('finds a previous run by its tag, whatever it was renamed to', () => { + const renamed = flag('my_own_name', [onboardingTag.id]) + expect(findDemoFlag([flag('checkout_v2'), renamed], onboardingTag)).toBe( + renamed, + ) + }) + + it('falls back to the name when the tag is missing', () => { + const demo = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([flag('checkout_v2'), demo], undefined)).toBe(demo) + }) + + it('finds nothing in a project that never ran the tour', () => { + expect(findDemoFlag([flag('checkout_v2')], onboardingTag)).toBeUndefined() + }) + + it('finds nothing in an empty project', () => { + expect(findDemoFlag([], onboardingTag)).toBeUndefined() + }) +}) diff --git a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts index 3192bc96ba5e..394f7be4c793 100644 --- a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts @@ -16,6 +16,12 @@ import { ProjectSummary, Tag, } from 'common/types/responses' +import { + DEMO_FLAG_NAME, + ONBOARDING_TAG, + findDemoFlag, + shouldSeedDemoFlag, +} from './demoFlag' import { SmartDefaults } from './useSmartDefaults' import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore' import API from 'project/api' @@ -23,17 +29,10 @@ import Constants from 'common/constants' type Store = ReturnType -const FLAG_NAME = 'show_demo_button' const DEFAULT_ORG_NAME = 'My organisation' const DEFAULT_PROJECT_NAME = 'My first project' const DEV_ENVIRONMENT_NAME = 'Development' const PROD_ENVIRONMENT_NAME = 'Production' -const ONBOARDING_TAG = { - color: '#3cb371', - description: 'Created during onboarding', - label: 'Onboarding', -} - type ExistingOrg = { id: number; name: string } export type BootstrapInput = { @@ -47,6 +46,9 @@ export type OnboardingBootstrap = { project: ProjectSummary environment: Environment featureName: string + // False when the project already had flags, so we seeded nothing and the tour + // has no flag of its own to teach with. + hasDemoFlag: boolean } async function ensureOrganisation( @@ -154,20 +156,20 @@ async function ensureFlag( }), ) .unwrap() + const results = flags?.results ?? [] const onboardingTag = await findOnboardingTag(store, project.id) - const existing = - (onboardingTag && - flags?.results?.find((f) => f.tags?.includes(onboardingTag.id))) || - flags?.results?.find((f) => f.name === FLAG_NAME) + const existing = findDemoFlag(results, onboardingTag) if (existing) { return existing } - const isFirstFeature = !flags?.results?.length + if (!shouldSeedDemoFlag(results)) { + return undefined + } const created = await store .dispatch( projectFlagService.endpoints.createProjectFlag.initiate({ body: { - name: FLAG_NAME, + name: DEMO_FLAG_NAME, project: project.id, type: 'STANDARD', } as Req['createProjectFlag']['body'], @@ -175,9 +177,7 @@ async function ensureFlag( }), ) .unwrap() - if (isFirstFeature) { - API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) - } + API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) return created } @@ -227,7 +227,8 @@ export async function bootstrapOnboarding( AppActions.refreshOrganisation() return { environment, - featureName: flag?.name ?? FLAG_NAME, + featureName: flag?.name ?? DEMO_FLAG_NAME, + hasDemoFlag: !!flag, organisationId: organisation.id, organisationName: organisation.name, project, diff --git a/frontend/web/components/pages/onboarding/hooks/demoFlag.ts b/frontend/web/components/pages/onboarding/hooks/demoFlag.ts new file mode 100644 index 000000000000..4f58deccb885 --- /dev/null +++ b/frontend/web/components/pages/onboarding/hooks/demoFlag.ts @@ -0,0 +1,25 @@ +import { ProjectFlag, Tag } from 'common/types/responses' + +export const DEMO_FLAG_NAME = 'show_demo_button' + +export const ONBOARDING_TAG = { + color: '#3cb371', + description: 'Created during onboarding', + label: 'Onboarding', +} + +// The demo flag from a previous run: tagged first, since the user is free to +// rename it, and a rename is a delete and recreate so the name alone is not +// reliable. +export const findDemoFlag = ( + flags: ProjectFlag[], + onboardingTag?: Tag, +): ProjectFlag | undefined => + (onboardingTag && flags.find((f) => f.tags?.includes(onboardingTag.id))) || + flags.find((f) => f.name === DEMO_FLAG_NAME) + +// Only seed into an empty project. An established project's flag list belongs to +// the customer, and a flag they did not ask for turns up in every environment, +// production included, because features are project-level. +export const shouldSeedDemoFlag = (flags: ProjectFlag[]): boolean => + !flags.length diff --git a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts index 574ae4f0f2bd..77d071d86ba7 100644 --- a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts +++ b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts @@ -15,6 +15,7 @@ export type OnboardingResources = { organisationName: string projectName: string featureName: string + hasDemoFlag: boolean caseSensitive: boolean environment: Environment | null environmentKey: string @@ -46,6 +47,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { const [organisationName, setOrganisationName] = useState('') const [projectName, setProjectName] = useState('') const [featureName, setFeatureName] = useState('') + const [hasDemoFlag, setHasDemoFlag] = useState(true) // Whether the project enforces lower-case feature names; drives the same name // normalisation the create-feature modal applies (see the header). const [caseSensitive, setCaseSensitive] = useState(false) @@ -77,6 +79,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { setEnvironment(res.environment) setEnvironmentKey(res.environment.api_key) setFeatureName(res.featureName) + setHasDemoFlag(res.hasDemoFlag) setStatus('ready') }) .catch((e) => { @@ -91,6 +94,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { environmentKey, error, featureName, + hasDemoFlag, organisationId, organisationName, projectId, diff --git a/frontend/web/components/pages/onboarding/onboarding-already-set-up/OnboardingAlreadySetUp.tsx b/frontend/web/components/pages/onboarding/onboarding-already-set-up/OnboardingAlreadySetUp.tsx new file mode 100644 index 000000000000..8a40cafc10fe --- /dev/null +++ b/frontend/web/components/pages/onboarding/onboarding-already-set-up/OnboardingAlreadySetUp.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react' +import Link from 'components/base/link' +import Button from 'components/base/forms/Button' + +export type OnboardingAlreadySetUpProps = { + projectName: string + // Where to send them to carry on with their own flags. + featuresHref: string + onSkip: () => void +} + +// Shown when the tour has nothing to teach with: the project already has flags, +// so we seed no demo flag (see ensureFlag) and there is no point walking someone +// through connecting a project that is already connected. +const OnboardingAlreadySetUp: FC = ({ + featuresHref, + onSkip, + projectName, +}) => ( +
+

You're already set up

+

+ {projectName} already has flags, so we haven't added a demo one. +

+
+ + View flags in {projectName} +
+
+) + +export default OnboardingAlreadySetUp diff --git a/frontend/web/components/pages/onboarding/onboarding-already-set-up/index.ts b/frontend/web/components/pages/onboarding/onboarding-already-set-up/index.ts new file mode 100644 index 000000000000..4dbd3ae2123e --- /dev/null +++ b/frontend/web/components/pages/onboarding/onboarding-already-set-up/index.ts @@ -0,0 +1,2 @@ +export { default } from './OnboardingAlreadySetUp' +export type { OnboardingAlreadySetUpProps } from './OnboardingAlreadySetUp' From eb26894f47059627dc79e574cc7d066fdfa81d2c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 5 Aug 2026 13:35:19 -0300 Subject: [PATCH 2/2] feat(onboarding): run the tour against the project named in the URL /getting-started carries no project, so the tour took the org's first project. For an established customer that is arbitrary: you can be inside project B, click Getting Started, and see project A. The entry URL now names it. The nav link carries the project you are in, or the one you were last in, and the page runs against that. An id that is not in this organisation's list is ignored rather than trusted, and a bare /getting-started (a fresh signup) still falls back to the first project or creates one. A query param rather than a nested route because /getting-started is an entry point, not a project-scoped page: it has to work before any project exists. A route like /project/:id/environment/:key/getting-started is the better long-term shape, but it needs an entry that provisions and redirects, and a decision about whether the tour keeps its chromeless layout. Environment choice is unchanged, still Development or the first one. Sending someone to wire up production during a tour would be worse than the problem. Suggested by @Zaimwa9. Co-Authored-By: Claude Opus 5 (1M context) --- .../common/hooks/__tests__/useLastEnv.test.ts | 16 ++++++++++ frontend/common/hooks/useLastEnv.ts | 31 +++++++++++++++++++ .../navigation/navbars/TopNavbar.tsx | 11 ++++++- .../hooks/__tests__/onboardingProject.test.ts | 30 ++++++++++++++++++ .../onboarding/hooks/bootstrapOnboarding.ts | 13 ++++++-- .../onboarding/hooks/onboardingProject.ts | 14 +++++++++ .../hooks/useEnsureOnboardingResources.ts | 22 +++++++++++-- 7 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 frontend/common/hooks/__tests__/useLastEnv.test.ts create mode 100644 frontend/common/hooks/useLastEnv.ts create mode 100644 frontend/web/components/pages/onboarding/hooks/__tests__/onboardingProject.test.ts create mode 100644 frontend/web/components/pages/onboarding/hooks/onboardingProject.ts diff --git a/frontend/common/hooks/__tests__/useLastEnv.test.ts b/frontend/common/hooks/__tests__/useLastEnv.test.ts new file mode 100644 index 000000000000..4d65658a5cf3 --- /dev/null +++ b/frontend/common/hooks/__tests__/useLastEnv.test.ts @@ -0,0 +1,16 @@ +import { parseLastEnv } from 'common/hooks/useLastEnv' + +describe('parseLastEnv', () => { + it('reads what usePageTracking wrote', () => { + const raw = JSON.stringify({ environmentId: 'abc', orgId: 1, projectId: 2 }) + expect(parseLastEnv(raw)).toEqual({ + environmentId: 'abc', + orgId: 1, + projectId: 2, + }) + }) + + it.each([null, '', 'not json', '{'])('survives %p', (raw) => { + expect(parseLastEnv(raw)).toBeNull() + }) +}) diff --git a/frontend/common/hooks/useLastEnv.ts b/frontend/common/hooks/useLastEnv.ts new file mode 100644 index 000000000000..c6455a057047 --- /dev/null +++ b/frontend/common/hooks/useLastEnv.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' + +// `lastEnv` is written by usePageTracking as you browse, and read by App and Nav +// to restore where you were. environmentId is the api_key, since routes use it. +export type LastEnv = { + orgId?: number | string + projectId?: number | string + environmentId?: string +} + +export const parseLastEnv = (raw: string | null): LastEnv | null => { + if (!raw) return null + try { + return JSON.parse(raw) + } catch { + return null + } +} + +export const useLastEnv = (): LastEnv | null => { + const [lastEnv, setLastEnv] = useState(null) + + useEffect(() => { + if (typeof AsyncStorage === 'undefined') return + Promise.resolve(AsyncStorage.getItem('lastEnv')).then((raw) => + setLastEnv(parseLastEnv(raw)), + ) + }, []) + + return lastEnv +} diff --git a/frontend/web/components/navigation/navbars/TopNavbar.tsx b/frontend/web/components/navigation/navbars/TopNavbar.tsx index 060b22bf5e32..b9ec039caccb 100644 --- a/frontend/web/components/navigation/navbars/TopNavbar.tsx +++ b/frontend/web/components/navigation/navbars/TopNavbar.tsx @@ -8,6 +8,7 @@ import Headway from 'components/Headway' import { Project } from 'common/types/responses' import AccountDropdown from 'components/navigation/AccountDropdown' import ThemeToggle from 'components/ThemeToggle' +import { useLastEnv } from 'common/hooks/useLastEnv' type TopNavType = { activeProject: Project | undefined @@ -15,6 +16,14 @@ type TopNavType = { } const TopNavbar: FC = ({ activeProject, projectId }) => { + // Name the project the tour should run against: the one you're in, else the + // one you were last in. Without it the page falls back to the org's first. + const lastEnv = useLastEnv() + const onboardingProjectId = projectId ?? lastEnv?.projectId + const gettingStartedTo = onboardingProjectId + ? `/getting-started?project=${onboardingProjectId}` + : '/getting-started' + return (