-
Notifications
You must be signed in to change notification settings - Fork 233
feat: onboarding wizard #1 #2704
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
053f0d8
83b7fe1
07fc8b9
e21cf96
542dfdb
1a14f26
5eee9e7
f7156f8
8bc3a48
0da102a
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,55 @@ | ||
| import { ReactNode, createContext, useContext, useEffect, useReducer, useMemo } from 'react'; | ||
| import { useFeatureFlagEnabled } from 'posthog-js/react'; | ||
| import { Loader } from '@/components/ui/loader'; | ||
|
|
||
| type FeatureFlagStatus = 'idle' | 'pending' | 'success'; | ||
|
|
||
| interface FeatureFlagState { | ||
| status: FeatureFlagStatus; | ||
| onboarding: { | ||
| enabled: boolean; | ||
| }; | ||
| } | ||
|
|
||
| type FeatureFlagAction = { type: 'LOADING' } | { type: 'LOADED'; onboardingEnabled: boolean }; | ||
|
|
||
| function featureFlagReducer(_state: FeatureFlagState, action: FeatureFlagAction): FeatureFlagState { | ||
| switch (action.type) { | ||
| case 'LOADING': | ||
| return { status: 'pending', onboarding: { enabled: false } }; | ||
| case 'LOADED': | ||
| return { status: 'success', onboarding: { enabled: action.onboardingEnabled } }; | ||
| } | ||
| } | ||
|
|
||
| const initialState: FeatureFlagState = { | ||
| status: 'idle', | ||
| onboarding: { enabled: false }, | ||
| }; | ||
|
|
||
| const FeatureFlagContext = createContext<FeatureFlagState>(initialState); | ||
|
|
||
| export const useFeatureFlags = () => useContext(FeatureFlagContext); | ||
|
|
||
| export const FeatureFlagProvider = ({ children }: { children: ReactNode }) => { | ||
| const onboardingFlag = useFeatureFlagEnabled('cosmo-onboarding-v1'); | ||
| const [state, dispatch] = useReducer(featureFlagReducer, initialState); | ||
|
|
||
| useEffect(() => { | ||
| if (onboardingFlag === undefined) { | ||
| dispatch({ type: 'LOADING' }); | ||
| } else { | ||
| dispatch({ type: 'LOADED', onboardingEnabled: onboardingFlag }); | ||
| } | ||
| }, [onboardingFlag]); | ||
|
|
||
| if (state.status !== 'success') { | ||
| return ( | ||
| <div className="fixed inset-0 flex items-center justify-center bg-background"> | ||
| <Loader /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return <FeatureFlagContext.Provider value={state}>{children}</FeatureFlagContext.Provider>; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,143 @@ | ||||||||||||||||||||||||
| import { Button } from '@/components/ui/button'; | ||||||||||||||||||||||||
| import { Checkbox } from '@/components/ui/checkbox'; | ||||||||||||||||||||||||
| import { Input } from '@/components/ui/input'; | ||||||||||||||||||||||||
| import { useZodForm } from '@/hooks/use-form'; | ||||||||||||||||||||||||
| import { emailSchema, organizationNameSchema } from '@/lib/form-schemas'; | ||||||||||||||||||||||||
| import { useCurrentOrganization } from '@/hooks/use-current-organization'; | ||||||||||||||||||||||||
| import { Cross1Icon, PlusIcon } from '@radix-ui/react-icons'; | ||||||||||||||||||||||||
| import { useRouter } from 'next/router'; | ||||||||||||||||||||||||
| import { useEffect } from 'react'; | ||||||||||||||||||||||||
| import { Controller, useFieldArray } from 'react-hook-form'; | ||||||||||||||||||||||||
| import { z } from 'zod'; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const onboardingSchema = z.object({ | ||||||||||||||||||||||||
| organizationName: organizationNameSchema, | ||||||||||||||||||||||||
| members: z.array( | ||||||||||||||||||||||||
| z.object({ | ||||||||||||||||||||||||
| email: emailSchema, | ||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||
| channels: z.object({ | ||||||||||||||||||||||||
| slack: z.boolean(), | ||||||||||||||||||||||||
| email: z.boolean(), | ||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| type OnboardingFormValues = z.infer<typeof onboardingSchema>; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export function OnboardingForm() { | ||||||||||||||||||||||||
| const router = useRouter(); | ||||||||||||||||||||||||
| const org = useCurrentOrganization(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||
| register, | ||||||||||||||||||||||||
| control, | ||||||||||||||||||||||||
| handleSubmit, | ||||||||||||||||||||||||
| reset, | ||||||||||||||||||||||||
| formState: { isValid, errors }, | ||||||||||||||||||||||||
| } = useZodForm<OnboardingFormValues>({ | ||||||||||||||||||||||||
| mode: 'onChange', | ||||||||||||||||||||||||
| schema: onboardingSchema, | ||||||||||||||||||||||||
| defaultValues: { | ||||||||||||||||||||||||
| organizationName: '', | ||||||||||||||||||||||||
| members: [], | ||||||||||||||||||||||||
| channels: { slack: true, email: false }, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||
| if (org?.name) { | ||||||||||||||||||||||||
| reset((prev) => ({ ...prev, organizationName: org.name })); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }, [org?.name, reset]); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const { fields, append, remove } = useFieldArray({ | ||||||||||||||||||||||||
| control, | ||||||||||||||||||||||||
| name: 'members', | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const onSubmit = (data: OnboardingFormValues) => { | ||||||||||||||||||||||||
| // TODO: wire up submission | ||||||||||||||||||||||||
| console.log(data); | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
Comment on lines
+59
to
+62
Contributor
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. Remove PII from client-side logging before merge.
Proposed fix- const onSubmit = (data: OnboardingFormValues) => {
+ const onSubmit = (data: OnboardingFormValues): void => {
// TODO: wire up submission
- console.log(data);
+ // Avoid logging form payloads containing member emails.
+ if (process.env.NODE_ENV === 'development') {
+ console.debug('Onboarding form submitted');
+ }
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||
| <form className="space-y-8" onSubmit={handleSubmit(onSubmit)}> | ||||||||||||||||||||||||
| <div className="space-y-2"> | ||||||||||||||||||||||||
| <label className="text-sm font-medium">Organization Name</label> | ||||||||||||||||||||||||
| <p className="text-sm text-muted-foreground"> | ||||||||||||||||||||||||
| This is your organization name. Feel free to keep it or change it. | ||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||
| <Input placeholder="Acme Inc." className="max-w-md" {...register('organizationName')} /> | ||||||||||||||||||||||||
| {errors.organizationName && <span className="text-sm text-destructive">{errors.organizationName.message}</span>} | ||||||||||||||||||||||||
|
Comment on lines
+67
to
+72
Contributor
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. Associate labels with inputs for screen-reader accessibility. The organization input and dynamic member email inputs are not programmatically labeled, which reduces accessibility. Proposed fix-import { useEffect } from 'react';
+import { useEffect } from 'react';
...
- <label className="text-sm font-medium">Organization Name</label>
+ <label htmlFor="organizationName" className="text-sm font-medium">Organization Name</label>
...
- <Input placeholder="Acme Inc." className="max-w-md" {...register('organizationName')} />
+ <Input id="organizationName" placeholder="Acme Inc." className="max-w-md" {...register('organizationName')} />
...
- <Input placeholder="janedoe@example.com" className="max-w-md" {...register(`members.${index}.email`)} />
+ <Input
+ aria-label={`Member email ${index + 1}`}
+ placeholder="janedoe@example.com"
+ className="max-w-md"
+ {...register(`members.${index}.email`)}
+ />Also applies to: 79-82 🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| <div className="space-y-2"> | ||||||||||||||||||||||||
| <label className="text-sm font-medium">Invite Members</label> | ||||||||||||||||||||||||
| <p className="text-sm text-muted-foreground">Add team members by email. You can always invite more later.</p> | ||||||||||||||||||||||||
| <div className="space-y-2"> | ||||||||||||||||||||||||
| {fields.map((field, index) => ( | ||||||||||||||||||||||||
| <div key={field.id} className="flex items-center gap-2"> | ||||||||||||||||||||||||
| <Input placeholder="janedoe@example.com" className="max-w-md" {...register(`members.${index}.email`)} /> | ||||||||||||||||||||||||
| <Button type="button" variant="ghost" size="icon-sm" onClick={() => remove(index)}> | ||||||||||||||||||||||||
| <Cross1Icon /> | ||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||
| {fields.map((_, index) => | ||||||||||||||||||||||||
| errors.members?.[index]?.email ? ( | ||||||||||||||||||||||||
| <span key={index} className="text-sm text-destructive"> | ||||||||||||||||||||||||
| {errors.members[index].email.message} | ||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||
| ) : null, | ||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| <Button type="button" variant="outline" size="sm" onClick={() => append({ email: '' })}> | ||||||||||||||||||||||||
| <PlusIcon className="mr-2" /> Add another | ||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| <div className="space-y-2"> | ||||||||||||||||||||||||
| <label className="text-sm font-medium">Communication Channels</label> | ||||||||||||||||||||||||
| <p className="text-sm text-muted-foreground">Choose how you'd like to receive notifications.</p> | ||||||||||||||||||||||||
| <div className="space-y-3"> | ||||||||||||||||||||||||
| <Controller | ||||||||||||||||||||||||
| control={control} | ||||||||||||||||||||||||
| name="channels.slack" | ||||||||||||||||||||||||
| render={({ field }) => ( | ||||||||||||||||||||||||
| <label className="flex items-start gap-3"> | ||||||||||||||||||||||||
| <Checkbox checked={field.value} onCheckedChange={(checked) => field.onChange(checked === true)} /> | ||||||||||||||||||||||||
| <div className="flex flex-col gap-y-1"> | ||||||||||||||||||||||||
| <span className="text-sm font-medium leading-none">Slack</span> | ||||||||||||||||||||||||
| <span className="text-sm text-muted-foreground">Get notified in your Slack workspace</span> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| </label> | ||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||
| <Controller | ||||||||||||||||||||||||
| control={control} | ||||||||||||||||||||||||
| name="channels.email" | ||||||||||||||||||||||||
| render={({ field }) => ( | ||||||||||||||||||||||||
| <label className="flex items-start gap-3"> | ||||||||||||||||||||||||
| <Checkbox checked={field.value} onCheckedChange={(checked) => field.onChange(checked === true)} /> | ||||||||||||||||||||||||
| <div className="flex flex-col gap-y-1"> | ||||||||||||||||||||||||
| <span className="text-sm font-medium leading-none">Email</span> | ||||||||||||||||||||||||
| <span className="text-sm text-muted-foreground">Receive updates via email</span> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| </label> | ||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| <div className="flex items-center gap-2"> | ||||||||||||||||||||||||
| <Button type="button" variant="outline" onClick={() => router.push(`/${org?.slug}/graphs`)}> | ||||||||||||||||||||||||
| Skip | ||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||
|
Comment on lines
+134
to
+136
Contributor
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. Guard against undefined organization slug before navigation. If 🛡️ Proposed fix- <Button type="button" variant="outline" onClick={() => router.push(`/${org?.slug}/graphs`)}>
+ <Button type="button" variant="outline" disabled={!org?.slug} onClick={() => router.push(`/${org?.slug}/graphs`)}>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| <Button type="submit" disabled={!isValid}> | ||||||||||||||||||||||||
| Continue | ||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||
| </form> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { getDashboardLayout } from '@/components/layout/dashboard-layout'; | ||
| import { Stepper, type StepperStep } from './stepper'; | ||
|
|
||
| const ONBOARDING_STEPS: StepperStep[] = [ | ||
| { label: 'Settings' }, | ||
| { label: 'What is GraphQL Federation?' }, | ||
| { label: 'Create your first graph' }, | ||
| { label: 'Run your services' }, | ||
| ]; | ||
|
|
||
| export const getOnboardingLayout = (page: React.ReactNode, currentStep: number) => { | ||
| return getDashboardLayout( | ||
| page, | ||
| 'Onboarding', | ||
| <Stepper steps={ONBOARDING_STEPS} currentStep={currentStep} className="pt-2" />, | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { ReactNode, useEffect } from 'react'; | ||
| import Router from 'next/router'; | ||
| import { useFeatureFlags } from '@/components/feature-flag-provider'; | ||
|
|
||
| const ONBOARDING_PATH_PREFIX = '/onboarding'; | ||
|
|
||
| export const OnboardingProvider = ({ children }: { children: ReactNode }) => { | ||
| const { onboarding } = useFeatureFlags(); | ||
|
|
||
| useEffect( | ||
| function handleRedirectInOnboardingProvider() { | ||
| if (!onboarding.enabled) return; | ||
| if (Router.pathname.startsWith(ONBOARDING_PATH_PREFIX)) return; | ||
|
|
||
| Router.replace('/onboarding'); | ||
| }, | ||
| [onboarding.enabled], | ||
| ); | ||
|
Comment on lines
+10
to
+18
Contributor
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Find publicPaths definition in app-provider to confirm the paths that should be excluded
rg -n "publicPaths" --type=ts -C3Repository: wundergraph/cosmo Length of output: 1242 🏁 Script executed: # Check the actual onboarding-provider.tsx file
cat -n studio/src/components/onboarding/onboarding-provider.tsxRepository: wundergraph/cosmo Length of output: 802 🏁 Script executed: # Find where ONBOARDING_PATH_PREFIX is defined
rg -n "ONBOARDING_PATH_PREFIX" --type=ts -B2 -A2Repository: wundergraph/cosmo Length of output: 1054 🏁 Script executed: # Check how onboarding feature flag is defined and used
rg -n "useFeatureFlags\|onboarding\.enabled" --type=ts -C2 | head -50Repository: wundergraph/cosmo Length of output: 43 🏁 Script executed: # Find where OnboardingProvider is imported/used
rg -n "OnboardingProvider" --type=tsx -B2 -A2Repository: wundergraph/cosmo Length of output: 88 🏁 Script executed: # Check _app.tsx or main layout to see component tree ordering
fd -e tsx -e ts | grep -E "(_app|_document|app\.|layout\.)" | head -20Repository: wundergraph/cosmo Length of output: 597 🏁 Script executed: # Look at feature flag provider to understand when onboarding flag is set
cat -n studio/src/components/feature-flag-provider.tsx | head -50Repository: wundergraph/cosmo Length of output: 2043 🏁 Script executed: # Search for OnboardingProvider usage with correct file type
rg -n "OnboardingProvider" --type ts -B2 -A2Repository: wundergraph/cosmo Length of output: 2726 🏁 Script executed: # Read _app.tsx to see provider ordering
cat -n studio/src/pages/_app.tsx | head -100Repository: wundergraph/cosmo Length of output: 4206 Redirect intercepts login/signup paths when onboarding flag is enabled. This effect redirects all non-onboarding paths to Add checks to exclude public paths: Proposed fix+const PUBLIC_PATHS = ['/login', '/signup'];
+
export const OnboardingProvider = ({ children }: { children: ReactNode }) => {
const { onboarding } = useFeatureFlags();
useEffect(
function handleRedirectInOnboardingProvider() {
if (!onboarding.enabled) return;
if (Router.pathname.startsWith(ONBOARDING_PATH_PREFIX)) return;
+ if (PUBLIC_PATHS.includes(Router.pathname)) return;
+ if (Router.pathname.startsWith('/api/')) return;
Router.replace('/onboarding');
},
[onboarding.enabled],
);
return children;
};🤖 Prompt for AI Agents |
||
|
|
||
| return children; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { OnboardingForm } from '@/components/onboarding/onboarding-form'; | ||
|
|
||
| export function Step1Welcome() { | ||
| return <OnboardingForm />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function Step2Federation() { | ||
| return <p>TODO: What is GraphQL Federation?</p>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function Step3CreateGraph() { | ||
| return <p>TODO: Create your first graph</p>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function Step4RunServices() { | ||
| return <p>TODO: Run your services</p>; | ||
| } |
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.
No timeout or fallback for feature flag loading.
If PostHog fails to initialize (ad blocker, network error, slow connection),
onboardingFlagmay remainundefinedindefinitely, leaving users stuck on the loading screen (lines 46-52).Consider adding a timeout that falls back to a default state:
🛡️ Proposed fix with timeout fallback
export const FeatureFlagProvider = ({ children }: { children: ReactNode }) => { const onboardingFlag = useFeatureFlagEnabled('cosmo-onboarding-v1'); const [state, dispatch] = useReducer(featureFlagReducer, initialState); useEffect(() => { if (onboardingFlag === undefined) { dispatch({ type: 'LOADING' }); } else { dispatch({ type: 'LOADED', onboardingEnabled: onboardingFlag }); } }, [onboardingFlag]); + // Fallback timeout if PostHog fails to load + useEffect(() => { + if (state.status === 'success') return; + + const timeout = setTimeout(() => { + if (state.status !== 'success') { + dispatch({ type: 'LOADED', onboardingEnabled: false }); + } + }, 5000); + + return () => clearTimeout(timeout); + }, [state.status]); + if (state.status !== 'success') {🤖 Prompt for AI Agents