diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss index 9bee42832fe9..174a56a5d3b2 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss @@ -7,4 +7,13 @@ // Top padding gives the theme-toggle row room to breathe from the very edge; // sides + bottom keep the reading column off the edges. padding: 24px 24px 40px; + + // The terminal states are one short block, so centre them rather than + // leaving them stranded at the top of an otherwise empty page. + &--message { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 70vh; + } } diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 7f8f8acdcfc7..5ed5bbf5629e 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -1,6 +1,8 @@ import React, { FC, useState } from 'react' import { useHistory } from 'react-router-dom' import Button from 'components/base/forms/Button' +import EmptyState from 'components/EmptyState' +import Link from 'components/base/link' import Icon from 'components/icons/Icon' import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader' import ThemeToggle from 'components/ThemeToggle' @@ -178,7 +180,7 @@ const OnboardingFlow: FC = () => { if (status === 'creating') { return ( -
+
) @@ -187,12 +189,15 @@ const OnboardingFlow: FC = () => { // Bootstrap failed (e.g. a plan org cap). Recoverable; a reload re-runs it. if (status === 'error') { return ( -
-

We couldn’t set up your workspace

-

- Something went wrong creating your starter project. Please try again. -

- +
+ window.location.reload()}>Try again + } + />
) } diff --git a/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts new file mode 100644 index 000000000000..080af5a5c3b7 --- /dev/null +++ b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts @@ -0,0 +1,66 @@ +import { ProjectFlag, Tag } from 'common/types/responses' +import { + DEMO_FLAG_DESCRIPTION, + DEMO_FLAG_NAME, + ONBOARDING_TAG, + findDemoFlag, + findOnboardingTag, +} from 'components/pages/onboarding/bootstrap/demoFlag' + +const flag = ( + name: string, + { description = '', tags = [] }: { description?: string; tags?: number[] } = {}, +): ProjectFlag => ({ description, id: name.length, name, tags } as ProjectFlag) + +const onboardingTag = { id: 7, ...ONBOARDING_TAG } as Tag + +describe('findOnboardingTag', () => { + it('finds the tag a previous run created', () => { + expect( + findOnboardingTag([{ id: 3, label: 'Backend' } as Tag, onboardingTag]), + ).toBe(onboardingTag) + }) + + it('ignores a tag the customer labelled Onboarding themselves', () => { + const theirs = { + description: 'Flags behind our signup flow', + id: 9, + label: 'Onboarding', + } as Tag + expect(findOnboardingTag([theirs])).toBeUndefined() + }) +}) + +describe('findDemoFlag', () => { + it('finds a previous run by its tag, whatever it was renamed to', () => { + const renamed = flag('my_own_name', { tags: [onboardingTag.id] }) + expect( + findDemoFlag([flag('checkout_v2'), renamed], onboardingTag), + ).toBe(renamed) + }) + + it('prefers the tag over anything else when both are present', () => { + const tagged = flag('renamed_by_hand', { tags: [onboardingTag.id] }) + const named = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([named, tagged], onboardingTag)).toBe(tagged) + }) + + it('falls back to our description when the tag is gone', () => { + // The rename carries the description over, so it outlives the name. + const renamed = flag('my_own_name', { description: DEMO_FLAG_DESCRIPTION }) + expect(findDemoFlag([flag('checkout_v2'), renamed], undefined)).toBe(renamed) + }) + + it('falls back to the name for a flag seeded before we set a description', () => { + const legacy = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([flag('checkout_v2'), legacy], undefined)).toBe(legacy) + }) + + 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/bootstrap/bootstrapOnboarding.ts similarity index 74% rename from frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts rename to frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts index 3192bc96ba5e..c1ef8e11e842 100644 --- a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts @@ -16,24 +16,24 @@ import { ProjectSummary, Tag, } from 'common/types/responses' -import { SmartDefaults } from './useSmartDefaults' +import { + DEMO_FLAG_DESCRIPTION, + DEMO_FLAG_NAME, + ONBOARDING_TAG, + findDemoFlag, + findOnboardingTag, +} from './demoFlag' +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 = { @@ -133,52 +133,71 @@ async function ensureEnvironments( .unwrap() } -async function findOnboardingTag( +async function fetchOnboardingTag( store: Store, projectId: number, ): Promise { const tags = await store .dispatch(tagService.endpoints.getTags.initiate({ projectId })) .unwrap() - return tags?.find((t) => t.label === ONBOARDING_TAG.label) + return findOnboardingTag(tags ?? []) } -async function ensureFlag( +async function listFlags( store: Store, project: ProjectSummary, -): Promise { +): Promise { const flags = await store .dispatch( - projectFlagService.endpoints.getProjectFlags.initiate({ - project: `${project.id}`, - }), + projectFlagService.endpoints.getProjectFlags.initiate( + { project: `${project.id}` }, + { forceRefetch: true }, + ), ) .unwrap() - 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) + return flags?.results ?? [] +} + +async function ensureFlag( + store: Store, + project: ProjectSummary, +): Promise { + // Not fatal: without the tag we fall back to the flag's own description. + const onboardingTag = await fetchOnboardingTag(store, project.id).catch( + () => undefined, + ) + const existing = findDemoFlag(await listFlags(store, project), onboardingTag) if (existing) { return existing } - const isFirstFeature = !flags?.results?.length - const created = await store - .dispatch( - projectFlagService.endpoints.createProjectFlag.initiate({ - body: { - name: FLAG_NAME, - project: project.id, - type: 'STANDARD', - } as Req['createProjectFlag']['body'], - project_id: project.id, - }), - ) - .unwrap() - if (isFirstFeature) { + try { + const created = await store + .dispatch( + projectFlagService.endpoints.createProjectFlag.initiate({ + body: { + description: DEMO_FLAG_DESCRIPTION, + name: DEMO_FLAG_NAME, + project: project.id, + type: 'STANDARD', + } as Req['createProjectFlag']['body'], + project_id: project.id, + }), + ) + .unwrap() API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) + return created + } catch (e) { + // Names are unique per project, so a clash means our flag is already here + // and we failed to recognise it: someone edited its description, or removed + // the tag. Take it rather than dropping the user out of the tour. + const clash = (await listFlags(store, project)).find( + (f) => f.name === DEMO_FLAG_NAME, + ) + if (!clash) { + throw e + } + return clash } - return created } async function ensureOnboardingTag( @@ -188,7 +207,7 @@ async function ensureOnboardingTag( ): Promise { try { const tag = - (await findOnboardingTag(store, project.id)) ?? + (await fetchOnboardingTag(store, project.id)) ?? (await store .dispatch( tagService.endpoints.createTag.initiate({ @@ -221,13 +240,11 @@ export async function bootstrapOnboarding( const project = await ensureProject(store, organisation.id, input.defaults) const environment = await ensureEnvironments(store, project) const flag = await ensureFlag(store, project) - if (flag) { - await ensureOnboardingTag(store, project, flag) - } + await ensureOnboardingTag(store, project, flag) AppActions.refreshOrganisation() return { environment, - featureName: flag?.name ?? FLAG_NAME, + featureName: flag.name, 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/demoFlag.ts b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts new file mode 100644 index 000000000000..07018ffa158b --- /dev/null +++ b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts @@ -0,0 +1,34 @@ +import { ProjectFlag, Tag } from 'common/types/responses' + +export const DEMO_FLAG_NAME = 'show_demo_button' + +// Set on the flag we create, and carried over by the tour's rename, so it +// identifies our flag after the name has changed. Also tells anyone looking at +// their flag list why it is there. +export const DEMO_FLAG_DESCRIPTION = 'Created during onboarding' + +export const ONBOARDING_TAG = { + color: '#3cb371', + description: DEMO_FLAG_DESCRIPTION, + label: 'Onboarding', +} + +// Description too: a customer's own tag labelled Onboarding is not ours. +export const findOnboardingTag = (tags: Tag[]): Tag | undefined => + tags.find( + (t) => + t.label === ONBOARDING_TAG.label && + t.description === ONBOARDING_TAG.description, + ) + +// Our flag, in descending order of how much the signal is worth. The tour +// renames by delete and recreate, carrying the tags and description over, so +// the name is the one thing that doesn't survive it: it only identifies flags +// seeded before we set a description. +export const findDemoFlag = ( + flags: ProjectFlag[], + onboardingTag?: Tag, +): ProjectFlag | undefined => + (onboardingTag && flags.find((f) => f.tags?.includes(onboardingTag.id))) || + flags.find((f) => f.description === DEMO_FLAG_DESCRIPTION) || + flags.find((f) => f.name === DEMO_FLAG_NAME) diff --git a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts index 574ae4f0f2bd..445f97a2d940 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'