diff --git a/studio/src/components/layout/onboarding-layout.tsx b/studio/src/components/layout/onboarding-layout.tsx index 59932b0147..4d7742232c 100644 --- a/studio/src/components/layout/onboarding-layout.tsx +++ b/studio/src/components/layout/onboarding-layout.tsx @@ -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 ( -
-
{children}
+
+
+ + {title &&

{title}

} + +
+
+ + {children} + +
); }; diff --git a/studio/src/components/onboarding/onboarding-container.tsx b/studio/src/components/onboarding/onboarding-container.tsx new file mode 100644 index 0000000000..acdf36cc10 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-container.tsx @@ -0,0 +1,3 @@ +export const OnboardingContainer = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; diff --git a/studio/src/components/onboarding/onboarding-navigation.tsx b/studio/src/components/onboarding/onboarding-navigation.tsx new file mode 100644 index 0000000000..7f936e7552 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-navigation.tsx @@ -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 ( +
+
+ + + + + + You can always get back to this wizard from the application. Safe to skip. + +
+
+ {backHref ? ( + + ) : ( + + )} + {'href' in forward ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/studio/src/components/onboarding/onboarding-provider.tsx b/studio/src/components/onboarding/onboarding-provider.tsx index ac1c98c528..f7b677f552 100644 --- a/studio/src/components/onboarding/onboarding-provider.tsx +++ b/studio/src/components/onboarding/onboarding-provider.tsx @@ -14,6 +14,8 @@ import { useSessionStorage } from '@/hooks/use-session-storage'; type Onboarding = { finishedAt?: Date; federatedGraphsCount: number; + slack: boolean; + email: boolean; }; export interface OnboardingState { diff --git a/studio/src/components/onboarding/onboarding-steps.ts b/studio/src/components/onboarding/onboarding-steps.ts new file mode 100644 index 0000000000..cd09ed87ad --- /dev/null +++ b/studio/src/components/onboarding/onboarding-steps.ts @@ -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' }, +]; diff --git a/studio/src/components/onboarding/step-1.tsx b/studio/src/components/onboarding/step-1.tsx index 3b4baff37c..d858ee8283 100644 --- a/studio/src/components/onboarding/step-1.tsx +++ b/studio/src/components/onboarding/step-1.tsx @@ -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; 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({ + 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) => { @@ -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'); }, @@ -39,38 +82,112 @@ export const Step1 = () => { }, }); + const onSubmit: SubmitHandler = (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 ( -
-

Step 1

-
- -
- - -
-
-
+ +
+ + ( + + Organization Name + This is your organization name. You can always change it later. + + + + + + )} + /> + +
+ Invite Members + Add team members by email. +
+ {fields.map((field, index) => ( +
+
+ + {fields.length > 1 && ( + + )} +
+ {form.formState.errors.members?.[index]?.email && ( +

+ {form.formState.errors.members[index].email.message} +

+ )} +
+ ))} +
+ +
+ +
+ Preferred way for us to reach you? + If you get stuck with your Cosmo setup, we want to be able to help you. +
+ ( + + )} + /> + ( + + )} + /> +
+
+ + + + +
); }; diff --git a/studio/src/components/onboarding/step-2.tsx b/studio/src/components/onboarding/step-2.tsx index a8e559873f..59d690ae62 100644 --- a/studio/src/components/onboarding/step-2.tsx +++ b/studio/src/components/onboarding/step-2.tsx @@ -1,7 +1,26 @@ 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 { ActivityLogIcon, CheckCircledIcon, Component1Icon, RocketIcon } from '@radix-ui/react-icons'; + +const FeatureCard = ({ + icon, + title, + children, +}: { + icon: React.ReactNode; + title: string; + children: React.ReactNode; +}) => { + return ( +
+
{icon}
+

{title}

+

{children}

+
+ ); +}; export const Step2 = () => { const { setStep, setSkipped } = useOnboarding(); @@ -11,21 +30,32 @@ export const Step2 = () => { }, [setStep]); return ( -
-

Step 2

-
- -
- - -
+ +

A quick look at what Cosmo does and why it matters for your team.

+ +
+ } title="Many services. One graph."> + GraphQL Federation lets separate services feel like one API. With Cosmo, developers get one place to query + data instead of stitching together calls across many backends. + + + } title="Teams move without bottlenecks."> + Each team can own and evolve its part of the graph on its own schedule. Cosmo is built so service teams can + ship independently while platform teams keep visibility and control. + + + } title="Changes stay safe."> + Cosmo helps catch unsafe schema changes before they reach production. That means teams can iterate faster + without surprising the apps and clients that rely on the graph. + + + } title="See what's happening."> + Built-in metrics and OpenTelemetry tracing show how requests move through the graph and its services. When + something slows down or fails, you can find it quickly and improve with confidence. +
-
+ + + ); }; diff --git a/studio/src/components/onboarding/step-3.tsx b/studio/src/components/onboarding/step-3.tsx index be8e77443c..5fe5e91114 100644 --- a/studio/src/components/onboarding/step-3.tsx +++ b/studio/src/components/onboarding/step-3.tsx @@ -1,7 +1,7 @@ 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'; export const Step3 = () => { const { setStep, setSkipped } = useOnboarding(); @@ -11,21 +11,9 @@ export const Step3 = () => { }, [setStep]); return ( -
+

Step 3

-
- -
- - -
-
-
+ + ); }; diff --git a/studio/src/components/onboarding/step-4.tsx b/studio/src/components/onboarding/step-4.tsx index 65801e04de..0beb2a21c3 100644 --- a/studio/src/components/onboarding/step-4.tsx +++ b/studio/src/components/onboarding/step-4.tsx @@ -1,7 +1,7 @@ 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 { finishOnboarding } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; import { useToast } from '../ui/use-toast'; @@ -31,6 +31,8 @@ export const Step4 = () => { ...prev, finishedAt: new Date(d.finishedAt), federatedGraphsCount: d.federatedGraphsCount, + slack: Boolean(prev?.slack), + email: Boolean(prev?.email), })); setStep(undefined); @@ -45,27 +47,17 @@ export const Step4 = () => { }); return ( -
+

Step 4

-
- -
- - -
-
-
+ mutate({}), + isLoading: isPending, + }} + forwardLabel="Finish" + /> + ); }; diff --git a/studio/src/components/onboarding/stepper.tsx b/studio/src/components/onboarding/stepper.tsx new file mode 100644 index 0000000000..9af006caa5 --- /dev/null +++ b/studio/src/components/onboarding/stepper.tsx @@ -0,0 +1,63 @@ +import { cn } from '@/lib/utils'; +import { CheckIcon } from '@radix-ui/react-icons'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { useRouter } from 'next/router'; +import type { StepperStep } from './onboarding-steps'; + +export function Stepper({ + steps, + currentStep, + className, +}: { + steps: StepperStep[]; + currentStep: number; + className?: string; +}) { + const router = useRouter(); + + return ( + +
+ {steps.map((step, index) => { + const isCompleted = index < currentStep; + const isCurrent = index === currentStep; + const canNavigate = isCompleted && !isCurrent; + + return ( +
+ {index > 0 && ( +
+
+
+ )} + + + + + {step.label} + +
+ ); + })} +
+ + ); +} diff --git a/studio/src/hooks/use-onboarding-navigation.ts b/studio/src/hooks/use-onboarding-navigation.ts index b907617c98..65534a4b62 100644 --- a/studio/src/hooks/use-onboarding-navigation.ts +++ b/studio/src/hooks/use-onboarding-navigation.ts @@ -29,6 +29,8 @@ export const useOnboardingNavigation = () => { setOnboarding({ finishedAt: data.finishedAt ? new Date(data.finishedAt) : undefined, federatedGraphsCount: data.federatedGraphsCount, + slack: data.slack, + email: data.email, }); }, [initialLoadSuccess, data, setOnboarding], diff --git a/studio/src/lib/schemas.ts b/studio/src/lib/schemas.ts new file mode 100644 index 0000000000..43bda403af --- /dev/null +++ b/studio/src/lib/schemas.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const emailSchema = z.string().email(); + +export const organizationNameSchema = z + .string() + .trim() + .min(3, { message: 'Organization name must be a minimum of 3 characters' }) + .max(32, { message: 'Organization name must be maximum 32 characters' }); diff --git a/studio/src/pages/[organizationSlug]/members.tsx b/studio/src/pages/[organizationSlug]/members.tsx index 73a0a9139a..100d981374 100644 --- a/studio/src/pages/[organizationSlug]/members.tsx +++ b/studio/src/pages/[organizationSlug]/members.tsx @@ -47,6 +47,7 @@ import { useRouter } from 'next/router'; import { useState } from 'react'; import { useDebounce } from 'use-debounce'; import { z } from 'zod'; +import { emailSchema } from '@/lib/schemas'; import { usePaginationParams } from '@/hooks/use-pagination-params'; import { UpdateMemberGroupDialog } from '@/components/members/update-member-group-dialog'; import { useIsAdmin } from '@/hooks/use-is-admin'; @@ -54,7 +55,7 @@ import { formatDateTime } from '@/lib/format-date'; import { MultiGroupSelect } from '@/components/multi-group-select'; const emailInputSchema = z.object({ - email: z.string().email(), + email: emailSchema, groups: z.array(z.string().uuid()).min(1), }); diff --git a/studio/src/pages/[organizationSlug]/settings.tsx b/studio/src/pages/[organizationSlug]/settings.tsx index 429fc4beeb..0fe7f94163 100644 --- a/studio/src/pages/[organizationSlug]/settings.tsx +++ b/studio/src/pages/[organizationSlug]/settings.tsx @@ -60,6 +60,7 @@ import { useRouter } from 'next/router'; import { Dispatch, SetStateAction, useContext, useEffect, useState } from 'react'; import { FaMagic } from 'react-icons/fa'; import { z } from 'zod'; +import { organizationNameSchema } from '@/lib/schemas'; import { DeleteOrganization } from '@/components/settings/delete-organization'; import { RestoreOrganization } from '@/components/settings/restore-organization'; @@ -70,12 +71,7 @@ const OrganizationDetails = () => { const sessionQueryClient = useContext(SessionClientContext); const schema = z.object({ - organizationName: z - .string() - .min(1, { - message: 'Organization name must be a minimum of 1 character', - }) - .max(24, { message: 'Organization name must be maximum 24 characters' }), + organizationName: organizationNameSchema, organizationSlug: z .string() .toLowerCase() diff --git a/studio/src/pages/onboarding/[step].tsx b/studio/src/pages/onboarding/[step].tsx index 4cbc8c3c21..f5c496301c 100644 --- a/studio/src/pages/onboarding/[step].tsx +++ b/studio/src/pages/onboarding/[step].tsx @@ -1,4 +1,5 @@ import { OnboardingLayout } from '@/components/layout/onboarding-layout'; +import { ONBOARDING_STEPS } from '@/components/onboarding/onboarding-steps'; import { Step1 } from '@/components/onboarding/step-1'; import { Step2 } from '@/components/onboarding/step-2'; import { Step3 } from '@/components/onboarding/step-3'; @@ -8,23 +9,40 @@ import { useRouter } from 'next/router'; const OnboardingStep: NextPageWithLayout = () => { const router = useRouter(); - const { step } = router.query; + const stepNumber = Number(router.query.step); + const title = ONBOARDING_STEPS[stepNumber - 1]?.label; - switch (step) { - case '0': - case '1': - return ; - case '2': - return ; - case '3': - return ; - case '4': - return ; + switch (stepNumber) { + case 0: + case 1: + return ( + + + + ); + case 2: + return ( + + + + ); + case 3: + return ( + + + + ); + case 4: + return ( + + + + ); default: return null; } }; -OnboardingStep.getLayout = (page) => {page}; +OnboardingStep.getLayout = (page) => page; export default OnboardingStep; diff --git a/studio/tailwind.config.js b/studio/tailwind.config.js index 6dd94d02ae..c151a91bf7 100644 --- a/studio/tailwind.config.js +++ b/studio/tailwind.config.js @@ -92,6 +92,9 @@ export default { 950: 'hsl(var(--gray-950))', }, }, + spacing: { + 160: '40rem', + }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)',