Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions frontend/common/hooks/__tests__/useLastEnv.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
31 changes: 31 additions & 0 deletions frontend/common/hooks/useLastEnv.ts
Original file line number Diff line number Diff line change
@@ -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<LastEnv | null>(null)

useEffect(() => {
if (typeof AsyncStorage === 'undefined') return
Promise.resolve(AsyncStorage.getItem('lastEnv')).then((raw) =>
setLastEnv(parseLastEnv(raw)),
)
Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle failed storage reads.

If AsyncStorage.getItem('lastEnv') rejects or throws, this effect creates an unhandled error. Set the fallback state to null when the read fails.

Proposed fix
-    Promise.resolve(AsyncStorage.getItem('lastEnv')).then((raw) =>
-      setLastEnv(parseLastEnv(raw)),
-    )
+    void Promise.resolve()
+      .then(() => AsyncStorage.getItem('lastEnv'))
+      .then((raw) => setLastEnv(parseLastEnv(raw)))
+      .catch(() => setLastEnv(null))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (typeof AsyncStorage === 'undefined') return
Promise.resolve(AsyncStorage.getItem('lastEnv')).then((raw) =>
setLastEnv(parseLastEnv(raw)),
)
useEffect(() => {
if (typeof AsyncStorage === 'undefined') return
void Promise.resolve()
.then(() => AsyncStorage.getItem('lastEnv'))
.then((raw) => setLastEnv(parseLastEnv(raw)))
.catch(() => setLastEnv(null))

}, [])
Comment on lines +20 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f '(usePageTracking|TopNavbar|useLastEnv)\.(ts|tsx)$' frontend |
  while IFS= read -r file; do
    ast-grep outline "$file" --items all
  done

rg -n -C 5 -g '*.ts' -g '*.tsx' \
  'AsyncStorage\.setItem\(|lastEnv|useLastEnv\s*\(|usePageTracking\s*\(' frontend

Repository: Flagsmith/flagsmith

Length of output: 15096


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== useLastEnv =="
cat -n frontend/common/hooks/useLastEnv.ts

echo
echo "== usePageTracking relevant section =="
sed -n '1,90p' frontend/common/hooks/usePageTracking.ts | cat -n

echo
echo "== TopNavbar full =="
cat -n frontend/web/components/navigation/navbars/TopNavbar.tsx

echo
echo "== App references =="
rg -n -C 4 -g '*.ts' -g '*.tsx' 'useLastEnv|lastEnv|getting-started|onboardingProjectId' frontend/web frontend/common

echo
echo "== Route context environment/project assignment areas =="
rg -n -C 3 -g '*.ts' -g '*.tsx' 'organisationId|environmentId|projectId|routeContext|setRouteContext' frontend/web frontend/common | head -n 220

Repository: Flagsmith/flagsmith

Length of output: 37713


Keep lastEnv changes in a single source of truth.

useLastEnv only reads storage when it mounts, while usePageTracking, EnvironmentAside, and Nav can write new lastEnv values. TopNavbar can then render the Getting Started link with a project that is no longer the last selected one. Move shared lastEnv state into common/store.ts and make writers update that state when they persist to storage.

Sources: Coding guidelines, Learnings


return lastEnv
}
11 changes: 10 additions & 1 deletion frontend/web/components/navigation/navbars/TopNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ 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
projectId?: number
}

const TopNavbar: FC<TopNavType> = ({ 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 (
<React.Fragment>
<nav className='mt-2 mb-1 space flex-row hidden-xs-down'>
Expand All @@ -33,7 +42,7 @@ const TopNavbar: FC<TopNavType> = ({ activeProject, projectId }) => {
)}
<NavLink
activeClassName='active'
to={'/getting-started'}
to={gettingStartedTo}
className='d-flex gap-1 d-none d-md-flex text-end lh-1 align-items-center'
>
<span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -30,6 +31,7 @@ const OnboardingFlow: FC = () => {
environment,
environmentKey,
featureName: bootstrappedFeatureName,
hasDemoFlag,
organisationId,
organisationName,
projectId,
Expand Down Expand Up @@ -197,6 +199,17 @@ const OnboardingFlow: FC = () => {
)
}

// The project already had flags, so nothing was seeded to tour with.
if (!hasDemoFlag) {
return (
<OnboardingAlreadySetUp
projectName={projectDisplayName}
featuresHref={`/project/${projectId}/environment/${environmentKey}/features`}
onSkip={skipToApp}
/>
)
}

return (
<div className='onboarding-flow mx-auto d-flex flex-column gap-4'>
<div className='d-flex justify-content-end'>
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ProjectSummary } from 'common/types/responses'
import { selectOnboardingProject } from 'components/pages/onboarding/hooks/onboardingProject'

const project = (id: number): ProjectSummary =>
({ id, name: `project ${id}` } as ProjectSummary)

const projects = [project(10), project(20), project(30)]

describe('selectOnboardingProject', () => {
it('runs against the project named in the URL', () => {
expect(selectOnboardingProject(projects, 20)).toBe(projects[1])
})

it('matches the id as a string, which is how it arrives', () => {
expect(selectOnboardingProject(projects, '30')).toBe(projects[2])
})

it('ignores a project outside this organisation', () => {
// A link carried over from another org, or an edited URL.
expect(selectOnboardingProject(projects, 999)).toBe(projects[0])
})

it.each([null, undefined, ''])('falls back to the first with %p', (id) => {
expect(selectOnboardingProject(projects, id)).toBe(projects[0])
})

it('returns nothing for an empty org, so the caller creates one', () => {
expect(selectOnboardingProject([], 20)).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,29 @@ import {
ProjectSummary,
Tag,
} from 'common/types/responses'
import { selectOnboardingProject } from './onboardingProject'
import {
DEMO_FLAG_NAME,
ONBOARDING_TAG,
findDemoFlag,
shouldSeedDemoFlag,
} from './demoFlag'
import { SmartDefaults } from './useSmartDefaults'
import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore'
import API from 'project/api'
import Constants from 'common/constants'

type Store = ReturnType<typeof getStore>

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 = {
// From `?project=` on the entry URL. Ignored when it isn't in this org.
requestedProjectId?: string | null
defaults: SmartDefaults
existingOrg?: ExistingOrg
}
Expand All @@ -47,6 +49,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(
Expand All @@ -73,11 +78,12 @@ async function ensureProject(
store: Store,
organisationId: number,
defaults: SmartDefaults,
requestedProjectId?: string | null,
): Promise<ProjectSummary> {
const projects = await store
.dispatch(projectService.endpoints.getProjects.initiate({ organisationId }))
.unwrap()
const existing = projects?.[0]
const existing = selectOnboardingProject(projects ?? [], requestedProjectId)
if (existing) {
return existing
}
Expand Down Expand Up @@ -154,30 +160,28 @@ 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'],
project_id: project.id,
}),
)
.unwrap()
if (isFirstFeature) {
API.trackEvent(Constants.events.CREATE_FIRST_FEATURE)
}
API.trackEvent(Constants.events.CREATE_FIRST_FEATURE)
return created
}

Expand Down Expand Up @@ -218,7 +222,12 @@ export async function bootstrapOnboarding(
input: BootstrapInput,
): Promise<OnboardingBootstrap> {
const organisation = await ensureOrganisation(store, input)
const project = await ensureProject(store, organisation.id, input.defaults)
const project = await ensureProject(
store,
organisation.id,
input.defaults,
input.requestedProjectId,
)
const environment = await ensureEnvironments(store, project)
const flag = await ensureFlag(store, project)
if (flag) {
Expand All @@ -227,7 +236,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,
Expand Down
25 changes: 25 additions & 0 deletions frontend/web/components/pages/onboarding/hooks/demoFlag.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ProjectSummary } from 'common/types/responses'

// /getting-started is an entry point rather than a project-scoped page, so the
// project it should run against arrives as `?project=`. The URL is authoritative:
// an id that isn't in this organisation's list is ignored rather than trusted.
export const selectOnboardingProject = (
projects: ProjectSummary[],
requestedProjectId?: string | number | null,
): ProjectSummary | undefined => {
const requested =
!!requestedProjectId &&
projects.find((p) => `${p.id}` === `${requestedProjectId}`)
return requested || projects[0]
}
Loading
Loading