Skip to content
Draft
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
25 changes: 19 additions & 6 deletions studio/src/components/layout/onboarding-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
export interface OnboardingLayoutProps {
children?: React.ReactNode;
}
import { Logo } from '../logo';
import { Card, CardContent } from '../ui/card';
import { Stepper } from '../onboarding/stepper';
import { ONBOARDING_STEPS } from '../onboarding/onboarding-steps';
import { useOnboarding } from '@/hooks/use-onboarding';

export const OnboardingLayout = ({ children, title }: { children?: React.ReactNode; title?: string }) => {
const { currentStep } = useOnboarding();

export const OnboardingLayout = ({ children }: OnboardingLayoutProps) => {
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center bg-background font-sans antialiased">
<main className="w-full max-w-lg px-4">{children}</main>
<div className="flex min-h-screen w-full flex-col bg-background font-sans antialiased">
<header className="mx-auto flex w-full max-w-2xl items-center gap-3 px-6 py-6">
<Logo width={32} height={32} />
{title && <h1 className="text-lg font-semibold tracking-tight">{title}</h1>}
<Stepper steps={ONBOARDING_STEPS} currentStep={(currentStep ?? 1) - 1} className="ml-auto" />
</header>
<main className="w-full flex-1 px-6 pt-12">
<Card className="mx-auto w-full max-w-2xl">
<CardContent className="flex min-h-160 flex-col p-6">{children}</CardContent>
</Card>
</main>
</div>
);
};
3 changes: 3 additions & 0 deletions studio/src/components/onboarding/onboarding-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const OnboardingContainer = ({ children }: { children: React.ReactNode }) => {
return <div className="flex flex-1 flex-col items-center gap-4 text-center">{children}</div>;
};
65 changes: 65 additions & 0 deletions studio/src/components/onboarding/onboarding-navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ArrowLeftIcon, ArrowRightIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import { Link } from '../ui/link';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';

export const OnboardingNavigation = ({
backHref,
forward,
forwardLabel = 'Next',
onSkip,
}: {
backHref?: string;
forward: { href: string } | { onClick: () => void; isLoading?: boolean; disabled?: boolean };
forwardLabel?: string;
onSkip: () => void;
}) => {
return (
<div className="mt-auto flex w-full justify-between pt-8">
<div className="flex items-center gap-1">
<Button asChild variant="outline" onClick={onSkip}>
<Link href="/">Skip</Link>
</Button>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<InfoCircledIcon className="size-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>You can always get back to this wizard from the application. Safe to skip.</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
{backHref ? (
<Button className="group" asChild variant="outline">
<Link href={backHref}>
<ArrowLeftIcon className="mr-2 transition-transform group-hover:-translate-x-1" />
Back
</Link>
</Button>
) : (
<Button variant="outline" disabled>
<ArrowLeftIcon className="mr-2" />
Back
</Button>
)}
{'href' in forward ? (
<Button className="group" asChild>
<Link href={forward.href}>
{forwardLabel}
<ArrowRightIcon className="ml-2 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
) : (
<Button
className="group"
onClick={forward.onClick}
isLoading={forward.isLoading}
disabled={forward.isLoading || forward.disabled}
>
{forwardLabel}
<ArrowRightIcon className="ml-2 transition-transform group-hover:translate-x-1" />
</Button>
)}
</div>
</div>
);
};
2 changes: 2 additions & 0 deletions studio/src/components/onboarding/onboarding-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { useSessionStorage } from '@/hooks/use-session-storage';
type Onboarding = {
finishedAt?: Date;
federatedGraphsCount: number;
slack: boolean;
email: boolean;
};

export interface OnboardingState {
Expand Down
11 changes: 11 additions & 0 deletions studio/src/components/onboarding/onboarding-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface StepperStep {
number: number;
label: string;
}

export const ONBOARDING_STEPS: StepperStep[] = [
{ number: 1, label: 'Information about you' },
{ number: 2, label: 'What is GraphQL Federation?' },
{ number: 3, label: 'Create your first graph' },
{ number: 4, label: 'Run your services' },
];
179 changes: 148 additions & 31 deletions studio/src/components/onboarding/step-1.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,58 @@
import { useEffect } from 'react';
import { Link } from '../ui/link';
import { Button } from '../ui/button';
import { useOnboarding } from '@/hooks/use-onboarding';
import { OnboardingContainer } from './onboarding-container';
import { OnboardingNavigation } from './onboarding-navigation';
import { useMutation } from '@connectrpc/connect-query';
import { createOnboarding } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery';
import { useRouter } from 'next/router';
import { useCurrentOrganization } from '@/hooks/use-current-organization';
import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb';
import { useToast } from '../ui/use-toast';
import { SubmitHandler, useZodForm } from '@/hooks/use-form';
import { Controller, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
import { emailSchema, organizationNameSchema } from '@/lib/schemas';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { Checkbox } from '../ui/checkbox';
import { Cross1Icon, PlusIcon } from '@radix-ui/react-icons';

const onboardingSchema = z.object({
organizationName: organizationNameSchema,
members: z.array(
z.object({
email: emailSchema.or(z.literal('')),
}),
),
channels: z.object({
slack: z.boolean(),
email: z.boolean(),
}),
});

type OnboardingFormValues = z.infer<typeof onboardingSchema>;

export const Step1 = () => {
const router = useRouter();
const { toast } = useToast();
const organization = useCurrentOrganization();
const { setStep, setSkipped, setOnboarding } = useOnboarding();
const { setStep, setSkipped, setOnboarding, onboarding } = useOnboarding();

const form = useZodForm<OnboardingFormValues>({
mode: 'onChange',
schema: onboardingSchema,
defaultValues: {
organizationName: organization?.name ?? '',
members: [{ email: '' }],
channels: { slack: onboarding?.slack ?? false, email: onboarding?.email ?? false },
},
});

const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'members',
});

const { mutate, isPending } = useMutation(createOnboarding, {
onSuccess: (d) => {
Expand All @@ -25,9 +64,13 @@ export const Step1 = () => {
return;
}

// TODO: read slack + email from CreateOnboarding response once proto is updated
const formValues = form.getValues();
setOnboarding({
federatedGraphsCount: d.federatedGraphsCount,
finishedAt: d.finishedAt ? new Date(d.finishedAt) : undefined,
slack: formValues.channels.slack,
email: formValues.channels.email,
});
router.push('/onboarding/2');
},
Expand All @@ -39,38 +82,112 @@ export const Step1 = () => {
},
});

const onSubmit: SubmitHandler<OnboardingFormValues> = (data) => {
const emails = data.members.map((m) => m.email).filter((e) => e.length > 0);

mutate({
organizationName: data.organizationName,
slack: data.channels.slack,
email: data.channels.email,
invititationEmails: emails,
});
};

useEffect(() => {
setStep(1);
}, [setStep]);

return (
<div className="flex flex-col items-center gap-4 text-center">
<h2 className="text-2xl font-semibold tracking-tight">Step 1</h2>
<div className="flex w-full justify-between">
<Button asChild variant="secondary" onClick={setSkipped}>
<Link href="/">Skip</Link>
</Button>
<div className="flex">
<Button className="mr-2" asChild disabled>
<Link href="#">Back</Link>
</Button>
<Button
onClick={() => {
// TODO: replace with real values in form
mutate({
organizationName: organization?.name ?? '',
slack: true,
email: false,
invititationEmails: [],
});
}}
isLoading={isPending}
disabled={isPending}
>
Next
</Button>
</div>
</div>
</div>
<OnboardingContainer>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-8 text-left">
<FormField
control={form.control}
name="organizationName"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormDescription>This is your organization name. You can always change it later.</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className="space-y-3 pt-4">
<FormLabel>Invite Members</FormLabel>
<FormDescription>Add team members by email.</FormDescription>
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id}>
<div className="flex items-center gap-2">
<Input placeholder="janedoe@example.com" {...form.register(`members.${index}.email`)} />
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon-sm" onClick={() => remove(index)}>
<Cross1Icon />
</Button>
)}
</div>
{form.formState.errors.members?.[index]?.email && (
<p className="mt-1 text-sm text-destructive">
{form.formState.errors.members[index].email.message}
</p>
)}
</div>
))}
</div>
<Button type="button" variant="outline" size="sm" onClick={() => append({ email: '' })}>
<PlusIcon className="mr-2" /> Add another
</Button>
</div>

<div className="space-y-3 pt-4">
<FormLabel>Preferred way for us to reach you?</FormLabel>
<FormDescription>If you get stuck with your Cosmo setup, we want to be able to help you.</FormDescription>
<div className="space-y-4">
<Controller
control={form.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-[0.8rem] text-muted-foreground">
We automatically create a Slack channel for you
</span>
</div>
</label>
)}
/>
<Controller
control={form.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-[0.8rem] text-muted-foreground">Receive updates via email</span>
</div>
</label>
)}
/>
</div>
</div>
</form>
</Form>

<OnboardingNavigation
onSkip={setSkipped}
forward={{
onClick: form.handleSubmit(onSubmit),
isLoading: isPending,
disabled: !form.formState.isValid,
}}
/>
</OnboardingContainer>
);
};
Loading
Loading