From 7d8aa3275f30c7e56a464949a8149a41b00eda81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Fri, 27 Mar 2026 12:39:46 +0100 Subject: [PATCH 01/10] feat: allow PostHog tracking on localhost if key is provided --- studio/src/lib/track.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/studio/src/lib/track.ts b/studio/src/lib/track.ts index 95aacad37a..a0affa66f3 100644 --- a/studio/src/lib/track.ts +++ b/studio/src/lib/track.ts @@ -38,6 +38,21 @@ 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. + const posthog = PostHogClient(); + posthog.identify(posthog.get_distinct_id(), { + id, + email, + organizationId, + organizationName, + organizationSlug, + plan, + }); + } + if (process.env.NODE_ENV !== 'production') { return; } @@ -47,18 +62,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 }; From ffe1617ac676eeaa8ace47d4c57d3fd0fd869dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Fri, 27 Mar 2026 16:17:47 +0100 Subject: [PATCH 02/10] fix: avoid loading PostHog twice --- studio/src/lib/track.ts | 2 -- studio/src/pages/_app.tsx | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/src/lib/track.ts b/studio/src/lib/track.ts index a0affa66f3..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 { @@ -42,7 +41,6 @@ const identify = ({ 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. - const posthog = PostHogClient(); posthog.identify(posthog.get_distinct_id(), { id, email, diff --git a/studio/src/pages/_app.tsx b/studio/src/pages/_app.tsx index e4310757c6..c67939fcd1 100644 --- a/studio/src/pages/_app.tsx +++ b/studio/src/pages/_app.tsx @@ -38,6 +38,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { }, []); useEffect(() => { + if (posthog.__loaded) return; + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: '/ingest', loaded: (ph) => { From 84ee9e2f502b1716b57ba914c8997905080a90e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Sun, 29 Mar 2026 10:16:08 +0200 Subject: [PATCH 03/10] fix: source maps for firefox based browser --- studio/README.md | 16 ++++++++++++++++ studio/next.config.mjs | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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, From 9996a9def7c8dad3ad7fcde76f3b3fd49df22b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Tue, 31 Mar 2026 14:37:20 +0200 Subject: [PATCH 04/10] feat: scaffold routes for onboarding --- studio/src/components/app-provider.tsx | 3 +- .../components/layout/onboarding-layout.tsx | 11 +++++++ studio/src/components/onboarding/step-1.tsx | 3 ++ studio/src/components/onboarding/step-2.tsx | 3 ++ studio/src/components/onboarding/step-3.tsx | 3 ++ studio/src/components/onboarding/step-4.tsx | 3 ++ studio/src/pages/onboarding/[step].tsx | 30 +++++++++++++++++++ studio/src/pages/onboarding/index.tsx | 18 +++++++++++ 8 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 studio/src/components/layout/onboarding-layout.tsx create mode 100644 studio/src/components/onboarding/step-1.tsx create mode 100644 studio/src/components/onboarding/step-2.tsx create mode 100644 studio/src/components/onboarding/step-3.tsx create mode 100644 studio/src/components/onboarding/step-4.tsx create mode 100644 studio/src/pages/onboarding/[step].tsx create mode 100644 studio/src/pages/onboarding/index.tsx 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/layout/onboarding-layout.tsx b/studio/src/components/layout/onboarding-layout.tsx new file mode 100644 index 0000000000..6fecc42d4c --- /dev/null +++ b/studio/src/components/layout/onboarding-layout.tsx @@ -0,0 +1,11 @@ +export interface OnboardingLayoutProps { + children?: React.ReactNode; +} + +export const OnboardingLayout = ({ children }: OnboardingLayoutProps) => { + 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..e75b8c4d85 --- /dev/null +++ b/studio/src/components/onboarding/step-1.tsx @@ -0,0 +1,3 @@ +export const Step1 = () => { + 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..c859d71b99 --- /dev/null +++ b/studio/src/components/onboarding/step-2.tsx @@ -0,0 +1,3 @@ +export const Step2 = () => { + 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..d17de6cab8 --- /dev/null +++ b/studio/src/components/onboarding/step-3.tsx @@ -0,0 +1,3 @@ +export const Step3 = () => { + 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..ae2a807980 --- /dev/null +++ b/studio/src/components/onboarding/step-4.tsx @@ -0,0 +1,3 @@ +export const Step4 = () => { + return

Step 4

; +}; 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..6cd0893127 --- /dev/null +++ b/studio/src/pages/onboarding/index.tsx @@ -0,0 +1,18 @@ +import { OnboardingLayout } from '@/components/layout/onboarding-layout'; +import { NextPageWithLayout } from '@/lib/page'; +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +const OnboardingIndex: NextPageWithLayout = () => { + const router = useRouter(); + + useEffect(() => { + router.replace('/onboarding/1'); + }, [router]); + + return null; +}; + +OnboardingIndex.getLayout = (page) => {page}; + +export default OnboardingIndex; From afbec430c4261940da0c93a100d899d21b188fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Fri, 27 Mar 2026 12:50:42 +0100 Subject: [PATCH 05/10] feat: read feature flags, store in context --- .../posthog-feature-flag-provider.tsx | 58 +++++++++++++++++++ studio/src/pages/_app.tsx | 15 +++-- 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 studio/src/components/posthog-feature-flag-provider.tsx 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..895cce7be6 --- /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 }, +}; + +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/pages/_app.tsx b/studio/src/pages/_app.tsx index c67939fcd1..ecf661c14e 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 @@ -71,12 +72,14 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - {getLayout()} - - + + + + + {getLayout()} + + + From c7aad803581a4157e79a7eead6c78f59ef49119b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Tue, 31 Mar 2026 21:29:49 +0200 Subject: [PATCH 06/10] feat: fetch initial onboarding state + redirect --- .../dashboard/workspace-provider.tsx | 3 + .../onboarding/onboarding-provider.tsx | 44 +++++++++++++ .../posthog-feature-flag-provider.tsx | 2 +- studio/src/hooks/use-onboarding-navigation.ts | 66 +++++++++++++++++++ studio/src/hooks/use-onboarding.ts | 4 ++ studio/src/pages/_app.tsx | 15 +++-- 6 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 studio/src/components/onboarding/onboarding-provider.tsx create mode 100644 studio/src/hooks/use-onboarding-navigation.ts create mode 100644 studio/src/hooks/use-onboarding.ts 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 ( >; +} + +export const OnboardingContext = createContext({ + onboarding: undefined, + enabled: false, + setOnboarding: () => undefined, +}); + +export const OnboardingProvider = ({ children }: { children: ReactNode }) => { + const { onboarding: onboardingFlag, status: featureFlagStatus } = useContext(PostHogFeatureFlagContext); + const [onboarding, setOnboarding] = useState(undefined); + + const value = useMemo( + () => ({ + onboarding, + enabled: Boolean(onboardingFlag.enabled && featureFlagStatus === 'success' && onboardingFlag), + setOnboarding, + }), + [onboarding, onboardingFlag, featureFlagStatus], + ); + + return {children}; +}; diff --git a/studio/src/components/posthog-feature-flag-provider.tsx b/studio/src/components/posthog-feature-flag-provider.tsx index 895cce7be6..b067c7d09a 100644 --- a/studio/src/components/posthog-feature-flag-provider.tsx +++ b/studio/src/components/posthog-feature-flag-provider.tsx @@ -30,7 +30,7 @@ const initialState: PostHogFeatureFlagState = { onboarding: { enabled: false }, }; -const PostHogFeatureFlagContext = createContext(initialState); +export const PostHogFeatureFlagContext = createContext(initialState); export const usePostHogFeatureFlags = () => useContext(PostHogFeatureFlagContext); diff --git a/studio/src/hooks/use-onboarding-navigation.ts b/studio/src/hooks/use-onboarding-navigation.ts new file mode 100644 index 0000000000..3d817b20a9 --- /dev/null +++ b/studio/src/hooks/use-onboarding-navigation.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } 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, onboarding, setOnboarding } = useOnboarding(); + const { data, isError, isPending, error } = useQuery(getOnboarding); + const [initialLoadSuccess, setInitialLoadSuccess] = useState(false); + + useEffect( + function initialOnboardingFetch() { + if (isPending) { + return; + } + + if (isError || data?.response?.code !== EnumStatusCode.OK) { + setInitialLoadSuccess(false); + return; + } + + setInitialLoadSuccess(true); + setOnboarding({ + step: Number(data.step ?? 0), + finishedAt: data.finishedAt ? new Date(data.finishedAt) : undefined, + federatedGraphsCount: data.federatedGraphsCount, + }); + }, + [data, isError, isPending, setOnboarding, error], + ); + + useEffect( + function handleNavigationToOnboarding() { + // Redirect user back if onboarding metadata failed + if (!initialLoadSuccess && Router.pathname.startsWith('/onboarding')) { + Router.replace('/'); + return; + } + + // Do not initiate redirect if we fail to fetch onboarding metadata. Fail silently in background. + if (!initialLoadSuccess) { + return; + } + + // Do not initiate redirect if the user is not eligible for onboarding + if (!onboarding && !enabled) { + return; + } + + // Do not initiate redirect if user has already finished the onboarding + if (onboarding?.finishedAt) { + return; + } + + const path = onboarding ? `/onboarding/${onboarding.step}` : '/onboarding'; + Router.replace(path); + }, + [onboarding, enabled, initialLoadSuccess], + ); +}; 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/pages/_app.tsx b/studio/src/pages/_app.tsx index ecf661c14e..864f052d92 100644 --- a/studio/src/pages/_app.tsx +++ b/studio/src/pages/_app.tsx @@ -23,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(); @@ -73,12 +74,14 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - {getLayout()} - - + + + + + {getLayout()} + + + From 067c2ad1327fa230a1363b44a5363b0638ed1f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Wed, 1 Apr 2026 09:51:44 +0200 Subject: [PATCH 07/10] feat: navigate between steps + dismiss --- .../components/layout/onboarding-layout.tsx | 4 +- .../onboarding/onboarding-provider.tsx | 46 ++++++++++++++++- studio/src/components/onboarding/step-1.tsx | 30 ++++++++++- studio/src/components/onboarding/step-2.tsx | 30 ++++++++++- studio/src/components/onboarding/step-3.tsx | 30 ++++++++++- studio/src/components/onboarding/step-4.tsx | 30 ++++++++++- studio/src/hooks/use-onboarding-navigation.ts | 50 +++++++++---------- studio/src/hooks/use-session-storage.ts | 2 +- studio/src/pages/onboarding/index.tsx | 15 ++++-- 9 files changed, 198 insertions(+), 39 deletions(-) diff --git a/studio/src/components/layout/onboarding-layout.tsx b/studio/src/components/layout/onboarding-layout.tsx index 6fecc42d4c..59932b0147 100644 --- a/studio/src/components/layout/onboarding-layout.tsx +++ b/studio/src/components/layout/onboarding-layout.tsx @@ -4,8 +4,8 @@ export interface OnboardingLayoutProps { export const OnboardingLayout = ({ children }: OnboardingLayoutProps) => { return ( -
-
{children}
+
+
{children}
); }; diff --git a/studio/src/components/onboarding/onboarding-provider.tsx b/studio/src/components/onboarding/onboarding-provider.tsx index 289a277f42..ac1c98c528 100644 --- a/studio/src/components/onboarding/onboarding-provider.tsx +++ b/studio/src/components/onboarding/onboarding-provider.tsx @@ -1,6 +1,7 @@ import { createContext, type Dispatch, + useCallback, useContext, useMemo, useState, @@ -8,9 +9,9 @@ import { type ReactNode, } from 'react'; import { PostHogFeatureFlagContext } from '../posthog-feature-flag-provider'; +import { useSessionStorage } from '@/hooks/use-session-storage'; type Onboarding = { - step: number; finishedAt?: Date; federatedGraphsCount: number; }; @@ -19,25 +20,66 @@ 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], + [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 index e75b8c4d85..f7589650d4 100644 --- a/studio/src/components/onboarding/step-1.tsx +++ b/studio/src/components/onboarding/step-1.tsx @@ -1,3 +1,31 @@ +import { useEffect } from 'react'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { useOnboarding } from '@/hooks/use-onboarding'; + export const Step1 = () => { - return

Step 1

; + const { setStep, setSkipped } = useOnboarding(); + + 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 index c859d71b99..a8e559873f 100644 --- a/studio/src/components/onboarding/step-2.tsx +++ b/studio/src/components/onboarding/step-2.tsx @@ -1,3 +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 = () => { - return

Step 2

; + 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 index d17de6cab8..be8e77443c 100644 --- a/studio/src/components/onboarding/step-3.tsx +++ b/studio/src/components/onboarding/step-3.tsx @@ -1,3 +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 = () => { - return

Step 3

; + 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 index ae2a807980..a66c4110b4 100644 --- a/studio/src/components/onboarding/step-4.tsx +++ b/studio/src/components/onboarding/step-4.tsx @@ -1,3 +1,31 @@ +import { useEffect } from 'react'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { useOnboarding } from '@/hooks/use-onboarding'; + export const Step4 = () => { - return

Step 4

; + const { setStep, setSkipped } = useOnboarding(); + + useEffect(() => { + setStep(3); + }, [setStep]); + + return ( +
+

Step 4

+
+ +
+ + +
+
+
+ ); }; diff --git a/studio/src/hooks/use-onboarding-navigation.ts b/studio/src/hooks/use-onboarding-navigation.ts index 3d817b20a9..b907617c98 100644 --- a/studio/src/hooks/use-onboarding-navigation.ts +++ b/studio/src/hooks/use-onboarding-navigation.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useMemo } from 'react'; import Router from 'next/router'; import { useOnboarding } from './use-onboarding'; import { useQuery } from '@connectrpc/connect-query'; @@ -10,57 +10,57 @@ import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb * the conditions based on feature flag and onboarding metadata */ export const useOnboardingNavigation = () => { - const { enabled, onboarding, setOnboarding } = useOnboarding(); - const { data, isError, isPending, error } = useQuery(getOnboarding); - const [initialLoadSuccess, setInitialLoadSuccess] = useState(false); + const { enabled, setOnboarding, skipped, currentStep } = useOnboarding(); + const { data, isError, isPending } = useQuery(getOnboarding); + const initialRedirect = useRef(false); - useEffect( - function initialOnboardingFetch() { - if (isPending) { - return; - } + const initialLoadSuccess = useMemo(() => { + if (isPending) return null; + if (isError || data?.response?.code !== EnumStatusCode.OK) return false; + return true; + }, [isPending, isError, data]); - if (isError || data?.response?.code !== EnumStatusCode.OK) { - setInitialLoadSuccess(false); + useEffect( + function syncOnboardingMetadata() { + if (initialLoadSuccess !== true || !data) { return; } - setInitialLoadSuccess(true); setOnboarding({ - step: Number(data.step ?? 0), finishedAt: data.finishedAt ? new Date(data.finishedAt) : undefined, federatedGraphsCount: data.federatedGraphsCount, }); }, - [data, isError, isPending, setOnboarding, error], + [initialLoadSuccess, data, setOnboarding], ); useEffect( function handleNavigationToOnboarding() { - // Redirect user back if onboarding metadata failed - if (!initialLoadSuccess && Router.pathname.startsWith('/onboarding')) { - Router.replace('/'); + // 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; } - // Do not initiate redirect if we fail to fetch onboarding metadata. Fail silently in background. - if (!initialLoadSuccess) { + // If user has dissmissed/skipped the onboarding but, do not redirect + if (skipped) { return; } - // Do not initiate redirect if the user is not eligible for onboarding - if (!onboarding && !enabled) { + // If user has already finished the onboarding, don't redirect + if (data?.finishedAt || (data?.federatedGraphsCount ?? 0) > 0) { return; } - // Do not initiate redirect if user has already finished the onboarding - if (onboarding?.finishedAt) { + // skip redirecting on subsequent re-runs of this functions + if (initialRedirect.current) { return; } - const path = onboarding ? `/onboarding/${onboarding.step}` : '/onboarding'; + const path = currentStep ? `/onboarding/${currentStep}` : `/onboarding/1`; + initialRedirect.current = true; Router.replace(path); }, - [onboarding, enabled, initialLoadSuccess], + [data, enabled, initialLoadSuccess, skipped, currentStep], ); }; 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/pages/onboarding/index.tsx b/studio/src/pages/onboarding/index.tsx index 6cd0893127..a7d1ac7ae1 100644 --- a/studio/src/pages/onboarding/index.tsx +++ b/studio/src/pages/onboarding/index.tsx @@ -1,16 +1,21 @@ +import { useRef } from 'react'; import { OnboardingLayout } from '@/components/layout/onboarding-layout'; import { NextPageWithLayout } from '@/lib/page'; -import { useRouter } from 'next/router'; +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 router = useRouter(); + const { currentStep } = useOnboarding(); + const initialStep = useRef(currentStep); useEffect(() => { - router.replace('/onboarding/1'); - }, [router]); + // Only redirect user when they first enter + Router.replace(initialStep.current ? `/onboarding/${initialStep.current}` : '/onboarding/1'); + }, []); - return null; + return ; }; OnboardingIndex.getLayout = (page) => {page}; From a00a853f5052c13ae0ca0752cf27e04964cf8713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Wed, 1 Apr 2026 10:29:24 +0200 Subject: [PATCH 08/10] feat: new empty state for enabled onboarding --- .../src/components/federatedgraphs-cards.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/studio/src/components/federatedgraphs-cards.tsx b/studio/src/components/federatedgraphs-cards.tsx index 0a54a35be5..be53e79f8a 100644 --- a/studio/src/components/federatedgraphs-cards.tsx +++ b/studio/src/components/federatedgraphs-cards.tsx @@ -5,7 +5,13 @@ import { docsBaseURL } from '@/lib/constants'; import { formatMetric } from '@/lib/format-metric'; import { useChartData } from '@/lib/insights-helpers'; import { cn } from '@/lib/utils'; -import { ChevronDoubleRightIcon, CommandLineIcon, DocumentArrowDownIcon } from '@heroicons/react/24/outline'; +import { + BoltIcon, + BookmarkIcon, + ChevronDoubleRightIcon, + CommandLineIcon, + DocumentArrowDownIcon, +} from '@heroicons/react/24/outline'; import { Component2Icon } from '@radix-ui/react-icons'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { migrateFromApollo } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; @@ -38,6 +44,7 @@ import { useMutation } from '@connectrpc/connect-query'; import { useCheckUserAccess } from '@/hooks/use-check-user-access'; import { useWorkspace } from '@/hooks/use-workspace'; import { useCurrentOrganization } from '@/hooks/use-current-organization'; +import { useOnboarding } from '@/hooks/use-onboarding'; // this is required to render a blank line with LineChart const fallbackData = [ @@ -616,6 +623,7 @@ export const FederatedGraphsCards = ({ graphs, refetch }: { graphs?: FederatedGr const [token, setToken] = useState(); 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 ( Date: Wed, 1 Apr 2026 10:13:02 +0200 Subject: [PATCH 09/10] feat: create/update onboarding on first step --- studio/src/components/onboarding/step-1.tsx | 51 +++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/studio/src/components/onboarding/step-1.tsx b/studio/src/components/onboarding/step-1.tsx index f7589650d4..3b4baff37c 100644 --- a/studio/src/components/onboarding/step-1.tsx +++ b/studio/src/components/onboarding/step-1.tsx @@ -2,9 +2,42 @@ 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 { setStep, setSkipped } = useOnboarding(); + 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); @@ -21,8 +54,20 @@ export const Step1 = () => { -
From 73b489b1db061eb33ce5628dcfcee681356f5421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Syn=C3=A1=C4=8Dek?= Date: Wed, 1 Apr 2026 10:55:15 +0200 Subject: [PATCH 10/10] feat: finish onboarding at step 4 --- studio/src/components/onboarding/step-4.tsx | 48 +++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/studio/src/components/onboarding/step-4.tsx b/studio/src/components/onboarding/step-4.tsx index a66c4110b4..65801e04de 100644 --- a/studio/src/components/onboarding/step-4.tsx +++ b/studio/src/components/onboarding/step-4.tsx @@ -2,14 +2,48 @@ 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 { setStep, setSkipped } = useOnboarding(); + const router = useRouter(); + const { toast } = useToast(); + const { setStep, setSkipped, setOnboarding } = useOnboarding(); useEffect(() => { - setStep(3); + 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

@@ -21,8 +55,14 @@ export const Step4 = () => { -