diff --git a/studio/README.md b/studio/README.md index b846fc97ee..ea59f1dd98 100644 --- a/studio/README.md +++ b/studio/README.md @@ -33,6 +33,22 @@ This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-opti We use [Connect](https://connect.build/) to unify the communication between all components of the cosmo platform. Connect is a framework build on top of [gRPC](https://grpc.io/) and simplify code-generation and reuse between `Studio` -> `Controlplane`. +## Source Maps (Firefox) + +Firefox does not handle webpack's default `eval-source-map` devtool correctly ([webpack#9267](https://github.com/webpack/webpack/issues/9267)). To get proper source maps in Firefox DevTools, set: + +```bash +NEXT_DEVTOOL=source-map pnpm dev +``` + +or with make: + +```bash +NEXT_DEVTOOL=source-map make start-studio +``` + +This generates separate `.map` files instead of eval-based inline maps. Note: incremental rebuilds will be slower. + ## Docker Info We want runtime envs for docker for each on prem customer. Therefore we have two files to achieve this. One is .env.docker that uses a placeholder env name and an entrypoint.sh script that replaces all placeholder env name with the correct one at runtime in the .next folder. This also requires us to SSR the studio. diff --git a/studio/next.config.mjs b/studio/next.config.mjs index a591ed459e..28b255d93a 100644 --- a/studio/next.config.mjs +++ b/studio/next.config.mjs @@ -103,7 +103,22 @@ const config = { }, // This is done to reduce the production build size // see: https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/ - webpack: (config, { webpack }) => { + webpack: (config, { webpack, dev, isServer }) => { + // Firefox doesn't handle eval-based source maps well (webpack/webpack#9267). + // Replace Next.js's default EvalSourceMapDevToolPlugin with SourceMapDevToolPlugin + // to generate proper source maps. Opt-in via NEXT_DEVTOOL=source-map. + if (dev && !isServer && process.env.NEXT_DEVTOOL === 'source-map') { + config.plugins = config.plugins.filter((plugin) => plugin.constructor.name !== 'EvalSourceMapDevToolPlugin'); + config.devtool = false; + config.plugins.push( + new webpack.SourceMapDevToolPlugin({ + filename: '[file].map', + module: true, + columns: true, + }), + ); + } + config.plugins.push( new webpack.DefinePlugin({ __SENTRY_TRACING__: !isSentryTracesEnabled, diff --git a/studio/src/components/app-provider.tsx b/studio/src/components/app-provider.tsx index 12a2f4f185..10682f0789 100644 --- a/studio/src/components/app-provider.tsx +++ b/studio/src/components/app-provider.tsx @@ -9,6 +9,8 @@ import { useCookieOrganization } from '@/hooks/use-cookie-organization'; import { setUser as setSentryUser } from '@sentry/nextjs'; import { OrganizationRole } from '@/lib/constants'; import { WorkspaceProvider } from '@/components/dashboard/workspace-provider'; +import { useFeatureFlags } from '@/components/feature-flag-provider'; +import { OnboardingProvider } from '@/components/onboarding/onboarding-provider'; const sessionQueryClient = new QueryClient(); @@ -107,7 +109,6 @@ const fetchSession = async () => { export const AppProvider = ({ children }: { children: ReactNode }) => { const router = useRouter(); const currentOrgSlug = router.query.organizationSlug; - // we store the current org slug in a cookie, so that we can redirect to the correct org after login // as well as being able to access the cookie on the server. const [cookieOrgSlug, setOrgSlugCookie] = useCookieOrganization(); @@ -121,6 +122,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { const queryClient = useQueryClient(); const [user, setUser] = useState(); + const { onboarding } = useFeatureFlags(); useEffect(() => { if (!router.isReady) return; @@ -184,16 +186,20 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setVerifiedOrganizationSlug(organization.slug); - if ( + const shouldRedirectToOrg = (router.pathname === '/' || router.pathname === '/login' || !currentOrg) && - router.pathname !== '/account/invitations' - ) { + router.pathname !== '/account/invitations'; + + // If onboarding is enabled, OnboardingProvider handles the redirect instead + if (onboarding.enabled) return; + + if (shouldRedirectToOrg) { const url = new URL(window.location.origin + router.basePath + router.asPath); const params = new URLSearchParams(url.search); router.replace(params.size !== 0 ? `/${organization.slug}?${params}` : `/${organization.slug}`); } } - }, [router, data, isFetching, error, cookieOrgSlug]); + }, [router, data, isFetching, error, cookieOrgSlug, onboarding.enabled]); useEffect(() => { if (!verifiedOrganizationSlug) { @@ -235,7 +241,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { return ( - {children} + + {children} + ); @@ -245,7 +253,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { - {children} + + {children} + diff --git a/studio/src/components/feature-flag-provider.tsx b/studio/src/components/feature-flag-provider.tsx new file mode 100644 index 0000000000..26799c6538 --- /dev/null +++ b/studio/src/components/feature-flag-provider.tsx @@ -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(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 ( +
+ +
+ ); + } + + return {children}; +}; diff --git a/studio/src/components/layout/title-layout.tsx b/studio/src/components/layout/title-layout.tsx index 59b70df014..1d680cc0fc 100644 --- a/studio/src/components/layout/title-layout.tsx +++ b/studio/src/components/layout/title-layout.tsx @@ -58,7 +58,7 @@ export const TitleLayout = ({

{title}

-

{subtitle}

+
{subtitle}
{items}
diff --git a/studio/src/components/onboarding/onboarding-form.tsx b/studio/src/components/onboarding/onboarding-form.tsx new file mode 100644 index 0000000000..983033bc84 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-form.tsx @@ -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; + +export function OnboardingForm() { + const router = useRouter(); + const org = useCurrentOrganization(); + + const { + register, + control, + handleSubmit, + reset, + formState: { isValid, errors }, + } = useZodForm({ + 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); + }; + + return ( +
+
+ +

+ This is your organization name. Feel free to keep it or change it. +

+ + {errors.organizationName && {errors.organizationName.message}} +
+ +
+ +

Add team members by email. You can always invite more later.

+
+ {fields.map((field, index) => ( +
+ + +
+ ))} + {fields.map((_, index) => + errors.members?.[index]?.email ? ( + + {errors.members[index].email.message} + + ) : null, + )} +
+ +
+ +
+ +

Choose how you'd like to receive notifications.

+
+ ( + + )} + /> + ( + + )} + /> +
+
+ +
+ + +
+
+ ); +} diff --git a/studio/src/components/onboarding/onboarding-layout.tsx b/studio/src/components/onboarding/onboarding-layout.tsx new file mode 100644 index 0000000000..0957f41f33 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-layout.tsx @@ -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', + , + ); +}; diff --git a/studio/src/components/onboarding/onboarding-provider.tsx b/studio/src/components/onboarding/onboarding-provider.tsx new file mode 100644 index 0000000000..a493e31266 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-provider.tsx @@ -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], + ); + + return children; +}; diff --git a/studio/src/components/onboarding/step-1-welcome.tsx b/studio/src/components/onboarding/step-1-welcome.tsx new file mode 100644 index 0000000000..5719a12e48 --- /dev/null +++ b/studio/src/components/onboarding/step-1-welcome.tsx @@ -0,0 +1,5 @@ +import { OnboardingForm } from '@/components/onboarding/onboarding-form'; + +export function Step1Welcome() { + return ; +} diff --git a/studio/src/components/onboarding/step-2-federation.tsx b/studio/src/components/onboarding/step-2-federation.tsx new file mode 100644 index 0000000000..a9c42d0853 --- /dev/null +++ b/studio/src/components/onboarding/step-2-federation.tsx @@ -0,0 +1,3 @@ +export function Step2Federation() { + return

TODO: What is GraphQL Federation?

; +} diff --git a/studio/src/components/onboarding/step-3-create-graph.tsx b/studio/src/components/onboarding/step-3-create-graph.tsx new file mode 100644 index 0000000000..0155daedac --- /dev/null +++ b/studio/src/components/onboarding/step-3-create-graph.tsx @@ -0,0 +1,3 @@ +export function Step3CreateGraph() { + return

TODO: Create your first graph

; +} diff --git a/studio/src/components/onboarding/step-4-run-services.tsx b/studio/src/components/onboarding/step-4-run-services.tsx new file mode 100644 index 0000000000..5219b0fa66 --- /dev/null +++ b/studio/src/components/onboarding/step-4-run-services.tsx @@ -0,0 +1,3 @@ +export function Step4RunServices() { + return

TODO: Run your services

; +} diff --git a/studio/src/components/onboarding/stepper.tsx b/studio/src/components/onboarding/stepper.tsx new file mode 100644 index 0000000000..01a7335602 --- /dev/null +++ b/studio/src/components/onboarding/stepper.tsx @@ -0,0 +1,49 @@ +import { cn } from '@/lib/utils'; +import { CheckIcon } from '@radix-ui/react-icons'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +export interface StepperStep { + label: string; +} + +interface StepperProps { + steps: StepperStep[]; + currentStep: number; + className?: string; +} + +export function Stepper({ steps, currentStep, className }: StepperProps) { + return ( + +
+ {steps.map((step, index) => { + const isCompleted = index < currentStep; + const isCurrent = index === currentStep; + + return ( +
+ {index > 0 && ( +
+ )} + + +
+ {isCompleted ? : index + 1} +
+
+ {step.label} +
+
+ ); + })} +
+ + ); +} diff --git a/studio/src/lib/form-schemas.ts b/studio/src/lib/form-schemas.ts new file mode 100644 index 0000000000..21889dfd7b --- /dev/null +++ b/studio/src/lib/form-schemas.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const organizationNameSchema = z + .string() + .min(1, { message: 'Organization name must be a minimum of 1 character' }) + .max(24, { message: 'Organization name must be maximum 24 characters' }); + +export const organizationSlugSchema = z + .string() + .toLowerCase() + .regex( + new RegExp('^[a-z0-9]+(?:-[a-z0-9]+)*$'), + 'Slug should start and end with an alphanumeric character. Spaces and special characters other that hyphen not allowed.', + ) + .min(3, { message: 'Organization slug must be a minimum of 3 characters' }) + .max(24, { message: 'Organization slug must be maximum 24 characters' }) + .refine((value) => !['login', 'signup', 'create', 'account'].includes(value), 'This slug is a reserved keyword'); + +export const emailSchema = z.string().email('Invalid email address'); diff --git a/studio/src/lib/track.ts b/studio/src/lib/track.ts index 95aacad37a..16b7aaf67b 100644 --- a/studio/src/lib/track.ts +++ b/studio/src/lib/track.ts @@ -2,7 +2,6 @@ // Reo, PostHog import posthog from 'posthog-js'; -import PostHogClient from './posthog'; declare global { interface Window { @@ -38,6 +37,20 @@ const identify = ({ return; } + // We allow PostHog tracking for any environment, if the key is provided + if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + // Identify with PostHog + // We use the id posthog sets to identify the user. This way we do not lose cross domain tracking. + posthog.identify(posthog.get_distinct_id(), { + id, + email, + organizationId, + organizationName, + organizationSlug, + plan, + }); + } + if (process.env.NODE_ENV !== 'production') { return; } @@ -47,18 +60,6 @@ const identify = ({ username: email, type: 'email', }); - - // Identify with PostHog - // We use the id posthog sets to identify the user. This way we do not lose cross domain tracking. - const posthog = PostHogClient(); - posthog.identify(posthog.get_distinct_id(), { - id, - email, - organizationId, - organizationName, - organizationSlug, - plan, - }); }; export { resetTracking, identify }; diff --git a/studio/src/pages/[organizationSlug]/members.tsx b/studio/src/pages/[organizationSlug]/members.tsx index 73a0a9139a..c55341c676 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/form-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..7b72ec98b0 100644 --- a/studio/src/pages/[organizationSlug]/settings.tsx +++ b/studio/src/pages/[organizationSlug]/settings.tsx @@ -36,6 +36,7 @@ import { useIsAdmin } from '@/hooks/use-is-admin'; import { useIsCreator } from '@/hooks/use-is-creator'; import { useUser } from '@/hooks/use-user'; import { calURL, docsBaseURL, scimBaseURL } from '@/lib/constants'; +import { organizationNameSchema, organizationSlugSchema } from '@/lib/form-schemas'; import { NextPageWithLayout } from '@/lib/page'; import { MinusCircledIcon, PlusIcon } from '@radix-ui/react-icons'; import { useQuery, useMutation } from '@connectrpc/connect-query'; @@ -70,24 +71,8 @@ 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' }), - organizationSlug: z - .string() - .toLowerCase() - .regex( - new RegExp('^[a-z0-9]+(?:-[a-z0-9]+)*$'), - 'Slug should start and end with an alphanumeric character. Spaces and special characters other that hyphen not allowed.', - ) - .min(3, { - message: 'Organization slug must be a minimum of 3 characters', - }) - .max(24, { message: 'Organization slug must be maximum 24 characters' }) - .refine((value) => !['login', 'signup', 'create', 'account'].includes(value), 'This slug is a reserved keyword'), + organizationName: organizationNameSchema, + organizationSlug: organizationSlugSchema, }); type OrganizationDetailsInput = z.infer; diff --git a/studio/src/pages/_app.tsx b/studio/src/pages/_app.tsx index e4310757c6..72569db5ff 100644 --- a/studio/src/pages/_app.tsx +++ b/studio/src/pages/_app.tsx @@ -22,6 +22,7 @@ import posthog from 'posthog-js'; import { PostHogProvider } from 'posthog-js/react'; import { withErrorBoundary } from '@sentry/nextjs'; import { Footer } from '@/components/layout/footer'; +import { FeatureFlagProvider } from '@/components/feature-flag-provider'; const queryClient = new QueryClient(); @@ -38,6 +39,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { }, []); useEffect(() => { + if (posthog.__loaded) return; + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: '/ingest', loaded: (ph) => { @@ -69,12 +72,14 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - {getLayout()} - - + + + + + {getLayout()} + + + diff --git a/studio/src/pages/onboarding/index.tsx b/studio/src/pages/onboarding/index.tsx new file mode 100644 index 0000000000..c5427bd2fc --- /dev/null +++ b/studio/src/pages/onboarding/index.tsx @@ -0,0 +1,28 @@ +import { Step1Welcome } from '@/components/onboarding/step-1-welcome'; +import { Step2Federation } from '@/components/onboarding/step-2-federation'; +import { Step3CreateGraph } from '@/components/onboarding/step-3-create-graph'; +import { Step4RunServices } from '@/components/onboarding/step-4-run-services'; +import { getOnboardingLayout } from '@/components/onboarding/onboarding-layout'; +import type { NextPageWithLayout } from '@/lib/page'; + +const OnboardingPage: NextPageWithLayout = () => { + const step: number = 0; + + switch (step) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 0: + default: + return ; + } +}; + +OnboardingPage.getLayout = (page) => { + return getOnboardingLayout(page, 0); +}; + +export default OnboardingPage;