diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 7f8f8acdcfc7..92ca1744f564 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -1,6 +1,7 @@ import React, { FC, useState } from 'react' import { useHistory } from 'react-router-dom' import Button from 'components/base/forms/Button' +import Link from 'components/base/link' import Icon from 'components/icons/Icon' import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader' import ThemeToggle from 'components/ThemeToggle' @@ -30,6 +31,7 @@ const OnboardingFlow: FC = () => { environment, environmentKey, featureName: bootstrappedFeatureName, + hasOnboardingFlag, organisationId, organisationName, projectId, @@ -197,6 +199,27 @@ const OnboardingFlow: FC = () => { ) } + // ensureFlag seeded nothing, so there is no flag to tour with. + if (!hasOnboardingFlag) { + return ( +
+

You’re already set up

+

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

+
+ + + View flags in {projectDisplayName} + +
+
+ ) + } + return (
diff --git a/frontend/web/components/pages/onboarding/bootstrap/__tests__/onboardingFlag.test.ts b/frontend/web/components/pages/onboarding/bootstrap/__tests__/onboardingFlag.test.ts new file mode 100644 index 000000000000..a2841366fda5 --- /dev/null +++ b/frontend/web/components/pages/onboarding/bootstrap/__tests__/onboardingFlag.test.ts @@ -0,0 +1,53 @@ +import { ProjectFlag, Tag } from 'common/types/responses' +import { + ONBOARDING_FLAG_NAME, + findOnboardingFlag, + shouldSeedOnboardingFlag, +} from 'components/pages/onboarding/bootstrap/onboardingFlag' + +const flag = (name: string, tags: number[] = []): ProjectFlag => + ({ id: name.length, name, tags } as ProjectFlag) + +const onboardingTag = { id: 7, label: 'Onboarding' } as Tag + +describe('shouldSeedOnboardingFlag', () => { + it('seeds into an empty project', () => { + expect(shouldSeedOnboardingFlag([])).toBe(true) + }) + + it('seeds nothing once the project has flags of its own', () => { + expect(shouldSeedOnboardingFlag([flag('checkout_v2')])).toBe(false) + }) +}) + +describe('findOnboardingFlag', () => { + it('finds a previous run by its tag, whatever it was renamed to', () => { + const renamed = flag('my_own_name', [onboardingTag.id]) + expect( + findOnboardingFlag([flag('checkout_v2'), renamed], onboardingTag), + ).toBe(renamed) + }) + + it('prefers the tag over the name when both are present', () => { + const tagged = flag('renamed_by_hand', [onboardingTag.id]) + const named = flag(ONBOARDING_FLAG_NAME) + expect(findOnboardingFlag([named, tagged], onboardingTag)).toBe(tagged) + }) + + it('falls back to the name when the tag is missing', () => { + const seeded = flag(ONBOARDING_FLAG_NAME) + expect(findOnboardingFlag([flag('checkout_v2'), seeded], undefined)).toBe( + seeded, + ) + }) + + it('finds nothing in a project that never ran the tour', () => { + expect( + findOnboardingFlag([flag('checkout_v2')], onboardingTag), + ).toBeUndefined() + }) + + it('finds nothing in an empty project', () => { + expect(findOnboardingFlag([], onboardingTag)).toBeUndefined() + }) +}) diff --git a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts similarity index 90% rename from frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts rename to frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts index 3192bc96ba5e..aa0ed4ddd107 100644 --- a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts @@ -16,24 +16,23 @@ import { ProjectSummary, Tag, } from 'common/types/responses' -import { SmartDefaults } from './useSmartDefaults' +import { + ONBOARDING_FLAG_NAME, + ONBOARDING_TAG, + findOnboardingFlag, + shouldSeedOnboardingFlag, +} from './onboardingFlag' +import { SmartDefaults } from 'components/pages/onboarding/hooks/useSmartDefaults' import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore' import API from 'project/api' 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,8 @@ export type OnboardingBootstrap = { project: ProjectSummary environment: Environment featureName: string + // False when the project already had flags, so nothing was seeded. + hasOnboardingFlag: boolean } async function ensureOrganisation( @@ -154,20 +155,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 = findOnboardingFlag(results, onboardingTag) if (existing) { return existing } - const isFirstFeature = !flags?.results?.length + if (!shouldSeedOnboardingFlag(results)) { + return undefined + } const created = await store .dispatch( projectFlagService.endpoints.createProjectFlag.initiate({ body: { - name: FLAG_NAME, + name: ONBOARDING_FLAG_NAME, project: project.id, type: 'STANDARD', } as Req['createProjectFlag']['body'], @@ -175,9 +176,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 +226,8 @@ export async function bootstrapOnboarding( AppActions.refreshOrganisation() return { environment, - featureName: flag?.name ?? FLAG_NAME, + featureName: flag?.name ?? ONBOARDING_FLAG_NAME, + hasOnboardingFlag: !!flag, organisationId: organisation.id, organisationName: organisation.name, project, diff --git a/frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts b/frontend/web/components/pages/onboarding/bootstrap/createOrganisationViaAccountStore.ts similarity index 100% rename from frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts rename to frontend/web/components/pages/onboarding/bootstrap/createOrganisationViaAccountStore.ts diff --git a/frontend/web/components/pages/onboarding/bootstrap/onboardingFlag.ts b/frontend/web/components/pages/onboarding/bootstrap/onboardingFlag.ts new file mode 100644 index 000000000000..f664c6729292 --- /dev/null +++ b/frontend/web/components/pages/onboarding/bootstrap/onboardingFlag.ts @@ -0,0 +1,23 @@ +import { ProjectFlag, Tag } from 'common/types/responses' + +export const ONBOARDING_FLAG_NAME = 'show_demo_button' + +export const ONBOARDING_TAG = { + color: '#3cb371', + description: 'Created during onboarding', + label: 'Onboarding', +} + +// A previous run's flag. Tag first: renaming is a delete and recreate, so the +// name alone is not reliable. +export const findOnboardingFlag = ( + flags: ProjectFlag[], + onboardingTag?: Tag, +): ProjectFlag | undefined => + (onboardingTag && flags.find((f) => f.tags?.includes(onboardingTag.id))) || + flags.find((f) => f.name === ONBOARDING_FLAG_NAME) + +// Only seed into an empty project: features are project-level, so an unwanted +// flag shows up in every environment, production included. +export const shouldSeedOnboardingFlag = (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..86c6495a0003 100644 --- a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts +++ b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts @@ -5,7 +5,7 @@ import useSelectedOrganisation from 'common/hooks/useSelectedOrganisation' import { useGetProfileQuery } from 'common/services/useProfile' import { Environment } from 'common/types/responses' import { useSmartDefaults } from './useSmartDefaults' -import { bootstrapOnboarding } from './bootstrapOnboarding' +import { bootstrapOnboarding } from 'components/pages/onboarding/bootstrap/bootstrapOnboarding' export type OnboardingResourcesStatus = 'creating' | 'ready' | 'error' @@ -15,6 +15,7 @@ export type OnboardingResources = { organisationName: string projectName: string featureName: string + hasOnboardingFlag: 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 [hasOnboardingFlag, setHasOnboardingFlag] = 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) + setHasOnboardingFlag(res.hasOnboardingFlag) setStatus('ready') }) .catch((e) => { @@ -91,6 +94,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { environmentKey, error, featureName, + hasOnboardingFlag, organisationId, organisationName, projectId,