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..cf54789127 100644 --- a/studio/src/components/app-provider.tsx +++ b/studio/src/components/app-provider.tsx @@ -186,7 +186,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { if ( (router.pathname === '/' || router.pathname === '/login' || !currentOrg) && - router.pathname !== '/account/invitations' + router.pathname !== '/account/invitations' && + !router.pathname.startsWith('/onboarding') ) { const url = new URL(window.location.origin + router.basePath + router.asPath); const params = new URLSearchParams(url.search); diff --git a/studio/src/components/dashboard/workspace-provider.tsx b/studio/src/components/dashboard/workspace-provider.tsx index defa500b1e..ff216a9a5c 100644 --- a/studio/src/components/dashboard/workspace-provider.tsx +++ b/studio/src/components/dashboard/workspace-provider.tsx @@ -5,6 +5,7 @@ import { WorkspaceNamespace } from '@wundergraph/cosmo-connect/dist/platform/v1/ import { useRouter } from 'next/router'; import { useApplyParams } from '@/components/analytics/use-apply-params'; import { useLocalStorage } from '@/hooks/use-local-storage'; +import { useOnboardingNavigation } from '@/hooks/use-onboarding-navigation'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; const DEFAULT_NAMESPACE_NAME = 'default'; @@ -103,6 +104,8 @@ export function WorkspaceProvider({ children }: React.PropsWithChildren) { [namespace, namespaces, setStoredNamespace, applyParams], ); + useOnboardingNavigation(); + // Finally, render :) return ( (); const [isMigrating, setIsMigrating] = useState(false); const checkUserAccess = useCheckUserAccess(); + const { onboarding, enabled, currentStep } = useOnboarding(); useEffect(() => { if (isMigrationSuccess) { @@ -624,6 +632,24 @@ export const FederatedGraphsCards = ({ graphs, refetch }: { graphs?: FederatedGr } }, [isMigrationSuccess]); + if (enabled && onboarding && onboarding.federatedGraphsCount === 0) { + return currentStep !== undefined && !onboarding.finishedAt ? ( + } + title="Dive right back in" + description="Want to finish the onboarding and create your first federated graph?" + actions={Continue} + /> + ) : ( + } + title="Need help?" + description="Take a quick 5-minute tour to help you set up your first federated graph" + actions={Start here} + /> + ); + } + if (!graphs || graphs.length === 0) return ( { + return ( +
+
{children}
+
+ ); +}; diff --git a/studio/src/components/onboarding/onboarding-provider.tsx b/studio/src/components/onboarding/onboarding-provider.tsx new file mode 100644 index 0000000000..ac1c98c528 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-provider.tsx @@ -0,0 +1,86 @@ +import { + createContext, + type Dispatch, + useCallback, + useContext, + useMemo, + useState, + type SetStateAction, + type ReactNode, +} from 'react'; +import { PostHogFeatureFlagContext } from '../posthog-feature-flag-provider'; +import { useSessionStorage } from '@/hooks/use-session-storage'; + +type Onboarding = { + finishedAt?: Date; + federatedGraphsCount: number; +}; + +export interface OnboardingState { + enabled: boolean; + onboarding?: Onboarding; + setOnboarding: Dispatch>; + currentStep: number | undefined; + setStep: (step: number | undefined) => void; + skipped: boolean; + setSkipped: () => void; + resetSkipped: () => void; +} + +export const OnboardingContext = createContext({ + onboarding: undefined, + enabled: false, + setOnboarding: () => undefined, + currentStep: undefined, + setStep: () => undefined, + skipped: false, + setSkipped: () => undefined, + resetSkipped: () => undefined, +}); + +const ONBOARDING_V1_LAST_STEP = 4; + +export const OnboardingProvider = ({ children }: { children: ReactNode }) => { + const { onboarding: onboardingFlag, status: featureFlagStatus } = useContext(PostHogFeatureFlagContext); + const [onboarding, setOnboarding] = useState(undefined); + const [currentStep, setCurrentStep] = useSessionStorage('cosmo-onboarding-v1-step', undefined); + const [skipped, setSkippedValue] = useSessionStorage('cosmo-onboarding-v1-skipped', false); + + const setSkipped = useCallback(() => { + setSkippedValue(true); + }, [setSkippedValue]); + + const resetSkipped = useCallback(() => { + setSkippedValue(false); + }, [setSkippedValue]); + + const setStep = useCallback( + (step: number | undefined) => { + if (step === undefined) { + setCurrentStep(1); + resetSkipped(); + return; + } + + resetSkipped(); + setCurrentStep(Math.max(Math.min(step, ONBOARDING_V1_LAST_STEP), 0)); + }, + [setCurrentStep, resetSkipped], + ); + + const value = useMemo( + () => ({ + onboarding, + enabled: Boolean(onboardingFlag.enabled && featureFlagStatus === 'success' && onboardingFlag), + setOnboarding, + currentStep, + setStep, + setSkipped, + resetSkipped, + skipped, + }), + [onboarding, onboardingFlag, featureFlagStatus, currentStep, setStep, setSkipped, resetSkipped, skipped], + ); + + return {children}; +}; diff --git a/studio/src/components/onboarding/step-1.tsx b/studio/src/components/onboarding/step-1.tsx new file mode 100644 index 0000000000..3b4baff37c --- /dev/null +++ b/studio/src/components/onboarding/step-1.tsx @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { useOnboarding } from '@/hooks/use-onboarding'; +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'; + +export const Step1 = () => { + const router = useRouter(); + const { toast } = useToast(); + const organization = useCurrentOrganization(); + const { setStep, setSkipped, setOnboarding } = useOnboarding(); + + const { mutate, isPending } = useMutation(createOnboarding, { + onSuccess: (d) => { + if (d.response?.code !== EnumStatusCode.OK) { + toast({ + description: d.response?.details ?? 'We had issues with storing your data. Please try again.', + duration: 3000, + }); + return; + } + + setOnboarding({ + federatedGraphsCount: d.federatedGraphsCount, + finishedAt: d.finishedAt ? new Date(d.finishedAt) : undefined, + }); + router.push('/onboarding/2'); + }, + onError: (error) => { + toast({ + description: error.details.toString() ?? 'We had issues with storing your data. Please try again.', + duration: 3000, + }); + }, + }); + + useEffect(() => { + setStep(1); + }, [setStep]); + + return ( +
+

Step 1

+
+ +
+ + +
+
+
+ ); +}; diff --git a/studio/src/components/onboarding/step-2.tsx b/studio/src/components/onboarding/step-2.tsx new file mode 100644 index 0000000000..a8e559873f --- /dev/null +++ b/studio/src/components/onboarding/step-2.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { useOnboarding } from '@/hooks/use-onboarding'; + +export const Step2 = () => { + const { setStep, setSkipped } = useOnboarding(); + + useEffect(() => { + setStep(2); + }, [setStep]); + + return ( +
+

Step 2

+
+ +
+ + +
+
+
+ ); +}; diff --git a/studio/src/components/onboarding/step-3.tsx b/studio/src/components/onboarding/step-3.tsx new file mode 100644 index 0000000000..be8e77443c --- /dev/null +++ b/studio/src/components/onboarding/step-3.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { useOnboarding } from '@/hooks/use-onboarding'; + +export const Step3 = () => { + const { setStep, setSkipped } = useOnboarding(); + + useEffect(() => { + setStep(3); + }, [setStep]); + + return ( +
+

Step 3

+
+ +
+ + +
+
+
+ ); +}; diff --git a/studio/src/components/onboarding/step-4.tsx b/studio/src/components/onboarding/step-4.tsx new file mode 100644 index 0000000000..65801e04de --- /dev/null +++ b/studio/src/components/onboarding/step-4.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { useOnboarding } from '@/hooks/use-onboarding'; +import { useMutation } from '@connectrpc/connect-query'; +import { finishOnboarding } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { useToast } from '../ui/use-toast'; +import { useRouter } from 'next/router'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; + +export const Step4 = () => { + const router = useRouter(); + const { toast } = useToast(); + const { setStep, setSkipped, setOnboarding } = useOnboarding(); + + useEffect(() => { + setStep(4); + }, [setStep]); + + const { mutate, isPending } = useMutation(finishOnboarding, { + onSuccess: (d) => { + if (d.response?.code !== EnumStatusCode.OK) { + toast({ + description: d.response?.details ?? 'We had issues with finishing the onboarding. Please try again.', + duration: 3000, + }); + return; + } + + setOnboarding((prev) => ({ + ...prev, + finishedAt: new Date(d.finishedAt), + federatedGraphsCount: d.federatedGraphsCount, + })); + + setStep(undefined); + router.push('/'); + }, + onError: (error) => { + toast({ + description: error.details.toString() ?? 'We had issues with finishing the onboarding. Please try again.', + duration: 3000, + }); + }, + }); + + return ( +
+

Step 4

+
+ +
+ + +
+
+
+ ); +}; diff --git a/studio/src/components/posthog-feature-flag-provider.tsx b/studio/src/components/posthog-feature-flag-provider.tsx new file mode 100644 index 0000000000..b067c7d09a --- /dev/null +++ b/studio/src/components/posthog-feature-flag-provider.tsx @@ -0,0 +1,58 @@ +import { ReactNode, createContext, useContext, useEffect, useReducer, useMemo } from 'react'; +import { useFeatureFlagEnabled } from 'posthog-js/react'; +import { Loader } from '@/components/ui/loader'; + +type PostHogFeatureFlagStatus = 'idle' | 'pending' | 'success'; + +interface PostHogFeatureFlagState { + status: PostHogFeatureFlagStatus; + onboarding: { + enabled: boolean; + }; +} + +type PostHogFeatureFlagAction = { type: 'LOADING' } | { type: 'LOADED'; onboardingEnabled: boolean }; + +function postHogFeatureFlagReducer( + _state: PostHogFeatureFlagState, + action: PostHogFeatureFlagAction, +): PostHogFeatureFlagState { + switch (action.type) { + case 'LOADING': + return { status: 'pending', onboarding: { enabled: false } }; + case 'LOADED': + return { status: 'success', onboarding: { enabled: action.onboardingEnabled } }; + } +} + +const initialState: PostHogFeatureFlagState = { + status: 'idle', + onboarding: { enabled: false }, +}; + +export const PostHogFeatureFlagContext = createContext(initialState); + +export const usePostHogFeatureFlags = () => useContext(PostHogFeatureFlagContext); + +export const PostHogFeatureFlagProvider = ({ children }: { children: ReactNode }) => { + const onboardingFlag = useFeatureFlagEnabled('cosmo-onboarding-v1'); + const [state, dispatch] = useReducer(postHogFeatureFlagReducer, 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/hooks/use-onboarding-navigation.ts b/studio/src/hooks/use-onboarding-navigation.ts new file mode 100644 index 0000000000..b907617c98 --- /dev/null +++ b/studio/src/hooks/use-onboarding-navigation.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useMemo } from 'react'; +import Router from 'next/router'; +import { useOnboarding } from './use-onboarding'; +import { useQuery } from '@connectrpc/connect-query'; +import { getOnboarding } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; + +/** + * Manages the initial navigation to onboarding wizard and evaluates + * the conditions based on feature flag and onboarding metadata + */ +export const useOnboardingNavigation = () => { + const { enabled, setOnboarding, skipped, currentStep } = useOnboarding(); + const { data, isError, isPending } = useQuery(getOnboarding); + const initialRedirect = useRef(false); + + const initialLoadSuccess = useMemo(() => { + if (isPending) return null; + if (isError || data?.response?.code !== EnumStatusCode.OK) return false; + return true; + }, [isPending, isError, data]); + + useEffect( + function syncOnboardingMetadata() { + if (initialLoadSuccess !== true || !data) { + return; + } + + setOnboarding({ + finishedAt: data.finishedAt ? new Date(data.finishedAt) : undefined, + federatedGraphsCount: data.federatedGraphsCount, + }); + }, + [initialLoadSuccess, data, setOnboarding], + ); + + useEffect( + function handleNavigationToOnboarding() { + // Wait for the onboarding metadata query to resolve + // Do not initiate redirect if we fail to fetch onboarding metadata. Fail silently in background. + if (initialLoadSuccess === null || !initialLoadSuccess) { + return; + } + + // If user has dissmissed/skipped the onboarding but, do not redirect + if (skipped) { + return; + } + + // If user has already finished the onboarding, don't redirect + if (data?.finishedAt || (data?.federatedGraphsCount ?? 0) > 0) { + return; + } + + // skip redirecting on subsequent re-runs of this functions + if (initialRedirect.current) { + return; + } + + const path = currentStep ? `/onboarding/${currentStep}` : `/onboarding/1`; + initialRedirect.current = true; + Router.replace(path); + }, + [data, enabled, initialLoadSuccess, skipped, currentStep], + ); +}; diff --git a/studio/src/hooks/use-onboarding.ts b/studio/src/hooks/use-onboarding.ts new file mode 100644 index 0000000000..667c46bdd5 --- /dev/null +++ b/studio/src/hooks/use-onboarding.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { OnboardingContext } from '@/components/onboarding/onboarding-provider'; + +export const useOnboarding = () => useContext(OnboardingContext); diff --git a/studio/src/hooks/use-session-storage.ts b/studio/src/hooks/use-session-storage.ts index a94d048324..398499f855 100644 --- a/studio/src/hooks/use-session-storage.ts +++ b/studio/src/hooks/use-session-storage.ts @@ -9,7 +9,7 @@ declare global { } } -type SetValue = Dispatch>; +export type SetValue = Dispatch>; export function useSessionStorage(key: string, initialValue: T): [T, SetValue] { // Get from session storage then 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/_app.tsx b/studio/src/pages/_app.tsx index e4310757c6..864f052d92 100644 --- a/studio/src/pages/_app.tsx +++ b/studio/src/pages/_app.tsx @@ -8,6 +8,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'; import { AppPropsWithLayout } from '@/lib/page'; import '@graphiql/plugin-explorer/dist/style.css'; import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'; +import { PostHogFeatureFlagProvider } from '@/components/posthog-feature-flag-provider'; import 'graphiql/graphiql.css'; import App, { AppContext, AppInitialProps } from 'next/app'; import 'react-date-range/dist/styles.css'; // main css file @@ -22,6 +23,7 @@ import posthog from 'posthog-js'; import { PostHogProvider } from 'posthog-js/react'; import { withErrorBoundary } from '@sentry/nextjs'; import { Footer } from '@/components/layout/footer'; +import { OnboardingProvider } from '@/components/onboarding/onboarding-provider'; const queryClient = new QueryClient(); @@ -38,6 +40,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 +73,16 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - {getLayout()} - - + + + + + + {getLayout()} + + + + diff --git a/studio/src/pages/onboarding/[step].tsx b/studio/src/pages/onboarding/[step].tsx new file mode 100644 index 0000000000..4cbc8c3c21 --- /dev/null +++ b/studio/src/pages/onboarding/[step].tsx @@ -0,0 +1,30 @@ +import { OnboardingLayout } from '@/components/layout/onboarding-layout'; +import { Step1 } from '@/components/onboarding/step-1'; +import { Step2 } from '@/components/onboarding/step-2'; +import { Step3 } from '@/components/onboarding/step-3'; +import { Step4 } from '@/components/onboarding/step-4'; +import { NextPageWithLayout } from '@/lib/page'; +import { useRouter } from 'next/router'; + +const OnboardingStep: NextPageWithLayout = () => { + const router = useRouter(); + const { step } = router.query; + + switch (step) { + case '0': + case '1': + return ; + case '2': + return ; + case '3': + return ; + case '4': + return ; + default: + return null; + } +}; + +OnboardingStep.getLayout = (page) => {page}; + +export default OnboardingStep; diff --git a/studio/src/pages/onboarding/index.tsx b/studio/src/pages/onboarding/index.tsx new file mode 100644 index 0000000000..a7d1ac7ae1 --- /dev/null +++ b/studio/src/pages/onboarding/index.tsx @@ -0,0 +1,23 @@ +import { useRef } from 'react'; +import { OnboardingLayout } from '@/components/layout/onboarding-layout'; +import { NextPageWithLayout } from '@/lib/page'; +import Router from 'next/router'; +import { useEffect } from 'react'; +import { Loader } from '@/components/ui/loader'; +import { useOnboarding } from '@/hooks/use-onboarding'; + +const OnboardingIndex: NextPageWithLayout = () => { + const { currentStep } = useOnboarding(); + const initialStep = useRef(currentStep); + + useEffect(() => { + // Only redirect user when they first enter + Router.replace(initialStep.current ? `/onboarding/${initialStep.current}` : '/onboarding/1'); + }, []); + + return ; +}; + +OnboardingIndex.getLayout = (page) => {page}; + +export default OnboardingIndex;