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 (
+
+
+
+ Skip
+
+
+
+
+
+ You can always get back to this wizard from the application. Safe to skip.
+
+
+
+ {backHref ? (
+
+
+
+ Back
+
+
+ ) : (
+
+
+ Back
+
+ )}
+ {'href' in forward ? (
+
+
+ {forwardLabel}
+
+
+
+ ) : (
+
+ {forwardLabel}
+
+
+ )}
+
+
+ );
+};
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
-
-
- Skip
-
-
-
- Back
-
- {
- // TODO: replace with real values in form
- mutate({
- organizationName: organization?.name ?? '',
- slack: true,
- email: false,
- invititationEmails: [],
- });
- }}
- isLoading={isPending}
- disabled={isPending}
- >
- Next
-
-
-
-
+
+
+
+
+
+
);
};
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
-
-
- Skip
-
-
-
- Back
-
-
- Next
-
-
+
+ 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
-
-
- Skip
-
-
-
- Back
-
-
- Next
-
-
-
-
+
+
);
};
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
-
-
- Skip
-
-
-
- Back
-
- {
- mutate({});
- }}
- isLoading={isPending}
- disabled={isPending}
- >
- Finish
-
-
-
-
+
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 && (
+
+ )}
+
+
+ router.push(`/onboarding/${step.number}`) : undefined}
+ className={cn(
+ 'flex h-6 w-6 items-center justify-center rounded-full border-2 text-xs font-medium transition-all duration-300',
+ isCompleted && 'border-primary bg-primary text-primary-foreground',
+ isCurrent && 'border-primary bg-background text-primary',
+ !isCompleted && !isCurrent && 'border-muted bg-background text-muted-foreground',
+ canNavigate ? 'cursor-pointer hover:opacity-80' : 'cursor-default',
+ )}
+ >
+ {isCompleted ? : step.number}
+
+
+ {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)',