-
Notifications
You must be signed in to change notification settings - Fork 556
feat(onboarding): run the tour against the project named in the URL #8229
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| }) | ||
| }) |
| 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
+20
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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*\(' frontendRepository: 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 220Repository: Flagsmith/flagsmith Length of output: 37713 Keep
Sources: Coding guidelines, Learnings |
||
|
|
||
| return lastEnv | ||
| } | ||
| 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 |
|---|---|---|
| @@ -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] | ||
| } |
There was a problem hiding this comment.
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 tonullwhen the read fails.Proposed fix
📝 Committable suggestion