diff --git a/studio/src/components/layout/onboarding-layout.tsx b/studio/src/components/layout/onboarding-layout.tsx index 4d7742232c..dcfb896da2 100644 --- a/studio/src/components/layout/onboarding-layout.tsx +++ b/studio/src/components/layout/onboarding-layout.tsx @@ -4,7 +4,15 @@ 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 }) => { +export const OnboardingLayout = ({ + children, + title, + bare = false, +}: { + children?: React.ReactNode; + title?: string; + bare?: boolean; +}) => { const { currentStep } = useOnboarding(); return ( @@ -15,9 +23,13 @@ export const OnboardingLayout = ({ children, title }: { children?: React.ReactNo
- - {children} - + {bare ? ( +
{children}
+ ) : ( + + {children} + + )}
); diff --git a/studio/src/components/onboarding/federation-animation.tsx b/studio/src/components/onboarding/federation-animation.tsx new file mode 100644 index 0000000000..7ad8d161c3 --- /dev/null +++ b/studio/src/components/onboarding/federation-animation.tsx @@ -0,0 +1,760 @@ +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { Logo } from '../logo'; + +// --- SVG animation layout --- +const VB_W = 700; +const VB_H = 300; +const CARD_R = 8; +const HEADER_H = 24; +const FIELD_SIZE = 9; +const FIELD_LINE_H = 13; +const MONO_FONT = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'; +const EASE_OUT: [number, number, number, number] = [0.25, 0.46, 0.45, 0.94]; + +const PRODUCT_FIELDS = [ + 'id: ID!', + 'title: String!', + 'description: String', + 'price: Price!', + 'category: ProductCategory', +]; +const REVIEW_FIELDS = [ + 'id: ID!', + 'author: String!', + 'email: String', + 'createdOn: Int!', + 'contents: String', + 'rating: Int!', +]; +const COMPOSED_PRODUCT_FIELDS = [...PRODUCT_FIELDS, 'reviews: [Review]']; + +// Card sizes +const SIDE_W = 190; +const INNER_PAD = 8; +const INNER_HEADER_H = 18; +const INNER_R = 5; +const INNER_FIELDS_H = (fields: string[]) => fields.length * FIELD_LINE_H + 8; +const INNER_H = (fields: string[]) => INNER_HEADER_H + INNER_FIELDS_H(fields); +const OUTER_H = (fields: string[]) => HEADER_H + INNER_PAD + INNER_H(fields) + INNER_PAD; +const PRODUCTS_H = OUTER_H(PRODUCT_FIELDS); +const REVIEWS_H = OUTER_H(REVIEW_FIELDS); +const CENTER_W = 170; +const SUPER_H = OUTER_H(COMPOSED_PRODUCT_FIELDS); + +// All cards share the same vertical center for straight horizontal lines +const CENTER_Y = VB_H / 2; + +// Positions (products left, supergraph center, reviews right) +const PRODUCTS_X = 15; +const PRODUCTS_Y = CENTER_Y - PRODUCTS_H / 2; +const REVIEWS_X = VB_W - SIDE_W - 15; +const REVIEWS_Y = CENTER_Y - REVIEWS_H / 2; +const SUPER_X = VB_W / 2 - CENTER_W / 2; +const SUPER_Y = CENTER_Y - SUPER_H / 2; + +// Straight horizontal lines connecting cards +const LEFT_LINE = `M ${PRODUCTS_X + SIDE_W} ${CENTER_Y} L ${SUPER_X} ${CENTER_Y}`; +const RIGHT_LINE = `M ${SUPER_X + CENTER_W} ${CENTER_Y} L ${REVIEWS_X} ${CENTER_Y}`; + +// Expanded supergraph layout (with Review type added below Product) +const REVIEW_INNER_H = INNER_H(REVIEW_FIELDS); +const EXPANDED_SUPER_H = SUPER_H + INNER_PAD + REVIEW_INNER_H; +const EXPANDED_SUPER_Y = Math.max(2, (VB_H - EXPANDED_SUPER_H) / 2); + +// Finished animation timing offsets (seconds after isFinished triggers) +const FINISH_TEXT_DURATION = 0.6; +const FINISH_DASH_DELAY = 0.3; +const FINISH_DASH_DURATION = 0.6; +const FINISH_BOX_DELAY = 0.7; +const FINISH_BOX_DURATION = 0.5; +const EXPAND_DELAY = 1.2; +const EXPAND_DURATION = 0.5; + +// --- Helpers --- + +const ease = (duration: number, delay = 0) => ({ duration, delay, ease: EASE_OUT }); + +// --- Shared SVG building blocks --- + +/** Generates staggered clip paths for a type label + per-field text wipe */ +const TypeBoxClipDefs = ({ + prefix, + fields, + ix, + iy, + iw, + visible, + duration, + baseDelay, + stagger, +}: { + prefix: string; + fields: string[]; + ix: number; + iy: number; + iw: number; + visible: boolean; + duration: number; + baseDelay: number; + stagger: number; +}) => ( + <> + + + + {fields.map((_, i) => ( + + + + ))} + +); + +/** Renders field text lines, each wrapped in its own clip path for staggered wipe */ +const ClippedFields = ({ + clipPrefix, + fields, + ix, + iy, + textOpacity, + duration, + delay, +}: { + clipPrefix: string; + fields: string[]; + ix: number; + iy: number; + textOpacity: number; + duration: number; + delay: number; +}) => ( + <> + {fields.map((field, i) => ( + + + {field} + + + ))} + +); + +// --- Node components --- + +const SubgraphNode = ({ + x, + y, + w, + h, + name, + typeName, + fields, + delay, + isComposed, + isFinished, +}: { + x: number; + y: number; + w: number; + h: number; + name: string; + typeName: string; + fields: string[]; + delay: number; + isComposed: boolean; + isFinished: boolean; +}) => { + const cx = x + w / 2; + const cy = y + h / 2; + const contentDelay = delay + 0.35; + + // Inner type box position + const ix = x + INNER_PAD; + const iy = y + HEADER_H + INNER_PAD; + const iw = w - INNER_PAD * 2; + const ih = INNER_H(fields); + const clipId = `field-clip-${name}`; + + // Clip timing switches between wipe-in (normal) and wipe-out (finished) + const clipDuration = isFinished ? FINISH_TEXT_DURATION * 0.6 : 0.4; + const clipBaseDelay = isFinished ? 0 : delay + 1.0; + const clipStagger = isFinished ? 0.06 : 0.08; + + return ( + + + {/* Subgraph title clip */} + + + + + + + {/* Outer subgraph card — shrinks to center when finished */} + + + {/* Subgraph name header — clipped for wipe */} + + + {name} + + + {/* Header separator — expands from center, collapses back on finish */} + + {/* Inner type box */} + + {/* Type name label — clipped for wipe */} + + + {typeName} + + + {/* Inner header separator */} + + + + + ); +}; + +const SupergraphNode = ({ + x, + y, + w, + h, + delay, + isComposed, + isFinished, +}: { + x: number; + y: number; + w: number; + h: number; + delay: number; + isComposed: boolean; + isFinished: boolean; +}) => { + const cx = x + w / 2; + const cy = y + h / 2; + const contentDelay = delay + 0.35; + + // Inner Product type box + const ix = x + INNER_PAD; + const iy = y + HEADER_H + INNER_PAD; + const iw = w - INNER_PAD * 2; + const ih = INNER_H(COMPOSED_PRODUCT_FIELDS); + + // Review type box (below Product, appears on finish) + const reviewIy = iy + ih + INNER_PAD; + const reviewIh = REVIEW_INNER_H; + const reviewCx = ix + iw / 2; + const reviewCy = reviewIy + reviewIh / 2; + const reviewContentDelay = EXPAND_DELAY + EXPAND_DURATION; + + // Y shift for the whole group when expanding + const groupShiftY = EXPANDED_SUPER_Y - y; + + return ( + + {/* Outer box — expands height on finish */} + + + {/* Logo + title — always visible */} + +
+ +
+
+ + supergraph + + + {/* Header separator */} + + + {/* Inner Product type box */} + + + {/* Product type label */} + + Product + + + {/* Inner header separator */} + + + {/* Shimmer gradient for skeleton bars */} + + + + + + + + + + + + + {/* Skeleton bars — show first, then crossfade to text when composed */} + {COMPOSED_PRODUCT_FIELDS.map((_, i) => { + const skeletonWidths = [40, 55, 70, 45, 80, 65]; + const fieldY = iy + INNER_HEADER_H + 4 + i * FIELD_LINE_H + FIELD_SIZE / 2; + return ( + + ); + })} + + {/* Fields — appear after skeletons fade, reviews field highlighted */} + {COMPOSED_PRODUCT_FIELDS.map((field, i) => { + const isReviewsField = field.startsWith('reviews'); + return ( + + {field} + + ); + })} + + {/* ── Review type box (appears after subgraph boxes disappear) ── */} + + + + + + {/* Review inner box — zooms in from center */} + + + {/* Review type label — clipped for wipe */} + + + Review + + + + {/* Review inner header separator — expands from center */} + + + +
+ ); +}; + +const AnimatedCurve = ({ + d, + delay, + isComposed, + isFinished, + reverse, + clipId, +}: { + d: string; + delay: number; + isComposed: boolean; + isFinished: boolean; + reverse?: boolean; + clipId: string; +}) => ( + + + +); + +export const FederationAnimation = ({ status }: { status: 'pending' | 'ok' | 'fail' | 'error' }) => { + const isComposed = status === 'ok'; + const [isFinished, setIsFinished] = useState(false); + + useEffect(() => { + if (!isComposed) { + setIsFinished(false); + return; + } + + const timer = setTimeout(() => { + setIsFinished(true); + }, 2500); + + return () => clearTimeout(timer); + }, [isComposed]); + + // Line clip regions — shrink from supergraph toward subgraph + const leftLineX1 = PRODUCTS_X + SIDE_W; + const leftLineX2 = SUPER_X; + const leftLineW = leftLineX2 - leftLineX1; + + const rightLineX1 = SUPER_X + CENTER_W; + const rightLineX2 = REVIEWS_X; + const rightLineW = rightLineX2 - rightLineX1; + + return ( +
+ + {/* Clip definitions for dashed line shrink */} + + {/* Left line clip: grows from products toward supergraph, shrinks back on finish */} + + + + {/* Right line clip: grows from reviews toward supergraph, shrinks back on finish */} + + + + + + + + + + + + +
+ ); +}; diff --git a/studio/src/components/onboarding/metrics-monitor.tsx b/studio/src/components/onboarding/metrics-monitor.tsx new file mode 100644 index 0000000000..8a2416a7dd --- /dev/null +++ b/studio/src/components/onboarding/metrics-monitor.tsx @@ -0,0 +1,314 @@ +import { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import type { OnboardingStatus } from './status-icon'; + +// --- Layout --- +const VB_W = 700; +const VB_H = 200; +const MID_Y = VB_H / 2; +const PAD_X = 20; +const LINE_W = VB_W - PAD_X * 2; + +// --- Colors --- +const MUTED = 'hsl(var(--muted-foreground))'; +const PRIMARY = 'hsl(var(--primary))'; +const DESTRUCTIVE = 'hsl(var(--destructive))'; + +// --- Paths --- +const NUM_POINTS = 12; + +function buildPoints(flat: boolean): [number, number][] { + const points: [number, number][] = []; + const startX = PAD_X; + const endX = PAD_X + LINE_W; + const cx = startX + LINE_W / 2; + + if (flat) { + for (let i = 0; i < NUM_POINTS; i++) { + points.push([startX + (i * LINE_W) / (NUM_POINTS - 1), MID_Y]); + } + } else { + points.push([startX, MID_Y]); + points.push([cx - 60, MID_Y]); + points.push([cx - 30, MID_Y]); + points.push([cx - 18, MID_Y - 14]); + points.push([cx - 8, MID_Y]); + points.push([cx, MID_Y - 70]); + points.push([cx + 8, MID_Y + 30]); + points.push([cx + 18, MID_Y]); + points.push([cx + 32, MID_Y - 18]); + points.push([cx + 50, MID_Y]); + points.push([cx + 80, MID_Y]); + points.push([endX, MID_Y]); + } + + return points; +} + +function pointsToPath(points: [number, number][]): string { + const [first, ...rest] = points; + return `M ${first[0]} ${first[1]} ` + rest.map(([x, y]) => `L ${x} ${y}`).join(' '); +} + +/** Normalized cumulative distance for each point (0→1) */ +function cumulativeProgress(points: [number, number][]): number[] { + let total = 0; + const dists = [0]; + for (let i = 1; i < points.length; i++) { + const dx = points[i][0] - points[i - 1][0]; + const dy = points[i][1] - points[i - 1][1]; + total += Math.sqrt(dx * dx + dy * dy); + dists.push(total); + } + return dists.map((d) => d / total); +} + +const FLAT_POINTS = buildPoints(true); +const BEAT_POINTS = buildPoints(false); +const FLAT_PATH = pointsToPath(FLAT_POINTS); +const BEAT_PATH = pointsToPath(BEAT_POINTS); +const FLAT_PROGRESS = cumulativeProgress(FLAT_POINTS); +const BEAT_PROGRESS = cumulativeProgress(BEAT_POINTS); + +// --- Animation timing --- +const SCAN_DURATION = 3; +const ALIVE_DURATION = 2.2; +// Beam trail length as fraction of path +const SCAN_TRAIL = 0.35; +const ALIVE_TRAIL = 0.45; + +type MonitorState = 'scanning' | 'alive' | 'failed'; + +function statusToState(status: OnboardingStatus): MonitorState { + switch (status) { + case 'pending': + return 'scanning'; + case 'ok': + return 'alive'; + case 'fail': + case 'error': + return 'failed'; + } +} + +export const MetricsMonitor = ({ status }: { status: OnboardingStatus }) => { + const state = statusToState(status); + + const isAlive = state === 'alive'; + const isFailed = state === 'failed'; + const shouldAnimate = !isFailed; + + const points = isAlive ? BEAT_POINTS : FLAT_POINTS; + const path = isAlive ? BEAT_PATH : FLAT_PATH; + const progress = isAlive ? BEAT_PROGRESS : FLAT_PROGRESS; + const color = isAlive ? PRIMARY : isFailed ? DESTRUCTIVE : MUTED; + + const trail = isAlive ? ALIVE_TRAIL : SCAN_TRAIL; + const drawDuration = isAlive ? ALIVE_DURATION : SCAN_DURATION; + const drawFraction = 1 / (1 + trail); // portion of cycle where dot moves (rest is tail exit) + const cycleDuration = drawDuration; + + // Dot keyframes synced to stroke reveal + const dotKeyframes = useMemo(() => { + const xs: number[] = []; + const ys: number[] = []; + const times: number[] = []; + + // Dot goes 0→1 in drawFraction, holds at end while tail exits + for (let i = 0; i < points.length; i++) { + xs.push(points[i][0]); + ys.push(points[i][1]); + times.push(progress[i] * drawFraction); + } + // Hold at end while tail exits + xs.push(points[points.length - 1][0]); + ys.push(points[points.length - 1][1]); + times.push(1); + + // Gradient x1 (trailing edge, transparent) and x2 (leading edge, opaque) + const trailPx = trail * LINE_W; + const gx1: number[] = []; + const gx2: number[] = []; + for (let i = 0; i < xs.length; i++) { + gx2.push(xs[i]); + gx1.push(xs[i] - trailPx); + } + + return { xs, ys, times, gx1, gx2 }; + }, [points, progress, drawFraction, trail]); + const lineOpacity = isAlive ? 1 : 0.4; + + return ( +
+ + + + + + + + + + + + + {/* Beam gradient: transparent at trailing edge → solid at leading edge */} + + + + + + + {/* Background grid lines */} + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + {shouldAnimate ? ( + + {/* Beam trail sweeps L→R with gradient fade */} + + + {/* Leading dot */} + { + const p = progress[i]; + if (p < 0.65) return 0.7; + return 0.7 * (1 - (p - 0.65) / 0.35); + }) + .concat([0]), + }} + transition={{ + cx: { + duration: cycleDuration, + times: dotKeyframes.times, + ease: 'linear', + repeat: Infinity, + }, + cy: { + duration: cycleDuration, + times: dotKeyframes.times, + ease: 'linear', + repeat: Infinity, + }, + opacity: { + duration: cycleDuration, + times: dotKeyframes.times, + ease: 'linear', + repeat: Infinity, + }, + }} + /> + + {/* Pulsing ring radiating from dot (alive only) */} + {isAlive && ( + + )} + + ) : ( + + )} + +
+ ); +}; diff --git a/studio/src/components/onboarding/onboarding-navigation.tsx b/studio/src/components/onboarding/onboarding-navigation.tsx index 0f0ead164b..953d7dcf71 100644 --- a/studio/src/components/onboarding/onboarding-navigation.tsx +++ b/studio/src/components/onboarding/onboarding-navigation.tsx @@ -1,4 +1,5 @@ import { ArrowLeftIcon, ArrowRightIcon, InfoCircledIcon } from '@radix-ui/react-icons'; +import { cn } from '@/lib/utils'; import { Link } from '../ui/link'; import { Button } from '../ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; @@ -8,14 +9,16 @@ export const OnboardingNavigation = ({ forward, forwardLabel = 'Next', onSkip, + className, }: { backHref?: string; forward: { href: string } | { onClick: () => void; isLoading?: boolean; disabled?: boolean }; forwardLabel?: string; onSkip: () => void; + className?: string; }) => { return ( -
+
+

+ ); + } +}; export const Step2 = () => { const { setStep, setSkipped } = useOnboarding(); + const router = useRouter(); + const [polling, dispatch] = useReducer(pollingReducer, { active: true, epoch: 0 }); + + const restartPolling = useCallback(() => dispatch({ type: 'RESTART' }), []); useEffect(() => { setStep(2); }, [setStep]); + useEffect(() => { + const timer = setTimeout(() => dispatch({ type: 'TIMEOUT' }), 5 * 60 * 1000); + return () => clearTimeout(timer); + }, [polling.epoch]); + + const { data, isError } = useQuery( + getFederatedGraphByName, + { name: 'demo', namespace: 'default' }, + { + refetchInterval: polling.active ? (query) => (isDemoGraphReady(query.state.data) ? false : 5_000) : false, + }, + ); + + const status = getDemoGraphStatus({ data, isPolling: polling.active, isError }); + return ( -

Step 2

- +
+
+ + 1 + +

+ Install the{' '} + + wgc CLI + {' '} + if you haven't already. Ensure Docker is installed as well. +

+
+ +
+ + 2 + +
+

Make sure you're logged in.

+ +
+
+ +
+ + 3 + +
+

Create a demo federated graph with sample subgraphs.

+ + + CLI + Manual + + +
+

+ Run this command to scaffold everything automatically. +

+ +
+
+ +
+

+ Clone the onboarding repository, create a federated graph and publish the plugins individually. +

+ + + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + router.push('/onboarding/3'), + disabled: status !== 'ok', + }} + />
); }; diff --git a/studio/src/components/onboarding/step-3.tsx b/studio/src/components/onboarding/step-3.tsx index 3aa6678a99..1c389f75d5 100644 --- a/studio/src/components/onboarding/step-3.tsx +++ b/studio/src/components/onboarding/step-3.tsx @@ -1,17 +1,194 @@ -import { useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { useEffect, useMemo, useReducer, useState } from 'react'; import { useOnboarding } from '@/hooks/use-onboarding'; +import { useFireworks } from '@/hooks/use-fireworks'; 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 { StatusIcon, type OnboardingStatus } from './status-icon'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { + finishOnboarding, + getFederatedGraphByName, + getRouters, +} from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { GetFederatedGraphByNameResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { useCurrentOrganization } from '@/hooks/use-current-organization'; import { useToast } from '../ui/use-toast'; -import { useRouter } from 'next/router'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs'; +import { CLI } from '../ui/cli'; +import { Kbd } from '../ui/kbd'; +import { CheckCircledIcon } from '@radix-ui/react-icons'; +import { Button } from '../ui/button'; +import { MetricsMonitor } from './metrics-monitor'; +import { StepFinished } from './step-finished'; + +const DEFAULT_ROUTING_URL = 'http://localhost:3002'; + +const DEMO_QUERY = `query GetProductWithReviews($id: ID!) { + product(id: $id) { + id + title + price { + currency + amount + } + reviews { + id + author + rating + contents + } + } +}`; +const DEMO_VARIABLES = '{"id":"product-1"}'; + +const stripWhitespace = (query: string): string => query.replace(/\s+/g, ' ').trim(); + +const buildCurlCommand = (routingUrl: string) => + `curl -s -X POST ${routingUrl} -H 'Content-Type: application/json' -d '{"query":"${stripWhitespace(DEMO_QUERY)}","variables":${DEMO_VARIABLES}}'`; + +function pollingReducer( + state: { + routerTimedOut: boolean; + metricsTimedOut: boolean; + metricsEpoch: number; + }, + action: { type: 'ROUTER_TIMEOUT' | 'METRICS_TIMEOUT' | 'RESTART_METRICS' }, +) { + switch (action.type) { + case 'ROUTER_TIMEOUT': + return { ...state, routerTimedOut: true }; + case 'METRICS_TIMEOUT': + return { ...state, metricsTimedOut: true }; + case 'RESTART_METRICS': + return { ...state, metricsTimedOut: false, metricsEpoch: state.metricsEpoch + 1 }; + } +} + +function hasMetricsToday(data: GetFederatedGraphByNameResponse | undefined): boolean { + if (!data?.graph) return false; + const now = new Date(); + return data.graph.requestSeries.some((s) => { + if (s.totalRequests <= 0) return false; + const d = new Date(s.timestamp); + return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); + }); +} + +function getMetricsStatus({ + hasMetrics, + hasGraph, + isPolling, + hasPolled, +}: { + hasMetrics: boolean; + hasGraph: boolean; + isPolling: boolean; + hasPolled: boolean; +}): OnboardingStatus { + if (hasMetrics) return 'ok'; + if (!hasGraph) return isPolling || !hasPolled ? 'pending' : 'fail'; + return isPolling || !hasPolled ? 'pending' : 'fail'; +} + +const MetricsStatusText = ({ status, onRetry }: { status: OnboardingStatus; onRetry: () => void }) => { + switch (status) { + case 'pending': + return ( +

+ Waiting for metrics to arrive. This may take a few minutes after sending a query. +

+ ); + case 'ok': + return Metrics received — your graph is reporting live traffic.; + case 'error': + case 'fail': + return ( +

+ Metrics not detected. Make sure you sent a query and the router is running.{' '} + +

+ ); + } +}; export const Step3 = () => { - const router = useRouter(); + const [isFinished, setIsFinished] = useState(false); const { toast } = useToast(); const { setStep, setSkipped, setOnboarding } = useOnboarding(); + const currentOrg = useCurrentOrganization(); + + const [polling, dispatch] = useReducer(pollingReducer, { + routerTimedOut: false, + metricsTimedOut: false, + metricsEpoch: 0, + }); + + const restartMetricsPolling = () => dispatch({ type: 'RESTART_METRICS' }); + + const { data: routersData } = useQuery( + getRouters, + { fedGraphName: 'demo', namespace: 'default' }, + { + refetchInterval: polling.routerTimedOut + ? false + : (query) => ((query.state.data?.routers?.length ?? 0) > 0 ? false : 5_000), + }, + ); + + const hasActiveRouter = (routersData?.routers?.length ?? 0) > 0; + const routerPolling = !hasActiveRouter && !polling.routerTimedOut; + + const { data: graphData } = useQuery( + getFederatedGraphByName, + { name: 'demo', namespace: 'default', includeMetrics: true }, + { + refetchInterval: + !hasActiveRouter || polling.metricsTimedOut + ? false + : (query) => (hasMetricsToday(query.state.data) ? false : 10_000), + }, + ); + + const hasMetrics = hasMetricsToday(graphData); + const metricsPolling = hasActiveRouter && !hasMetrics && !polling.metricsTimedOut; + const routingUrl = graphData?.graph?.routingURL || DEFAULT_ROUTING_URL; + const curlCommand = useMemo(() => buildCurlCommand(routingUrl), [routingUrl]); + const port = useMemo(() => { + try { + return new URL(routingUrl).port || '3002'; + } catch { + return '3002'; + } + }, [routingUrl]); + + const metricsStatus = getMetricsStatus({ + hasMetrics, + hasGraph: !!graphData?.graph, + isPolling: metricsPolling, + hasPolled: hasActiveRouter, + }); + + useFireworks(metricsStatus === 'ok'); + + useEffect(() => { + if (!routerPolling) return; + const timer = setTimeout(() => dispatch({ type: 'ROUTER_TIMEOUT' }), 5 * 60 * 1000); + return () => clearTimeout(timer); + }, [routerPolling]); + + useEffect(() => { + if (!metricsPolling) return; + const timer = setTimeout(() => dispatch({ type: 'METRICS_TIMEOUT' }), 5 * 60 * 1000); + return () => clearTimeout(timer); + }, [metricsPolling, polling.metricsEpoch]); + + useEffect(() => { + setStep(3); + }, [setStep]); useEffect(() => { setStep(3); @@ -35,8 +212,7 @@ export const Step3 = () => { email: Boolean(prev?.email), })); - setStep(undefined); - router.push('/'); + setIsFinished(true); }, onError: (error) => { toast({ @@ -47,14 +223,164 @@ export const Step3 = () => { }); return ( - -

Step 3

- mutate({}), isLoading: isPending }} - forwardLabel="Finish" - /> -
+
+ +
+ +
+
+

Run your services

+

+ Start the router and send your first query to see live traffic in action. +

+
+ +
+ + 1 + +
+
+

Start the router

+ {hasActiveRouter ? ( + + + Connected + + ) : routerPolling ? ( + + + + + + Waiting… + + ) : ( + + + Not detected + + )} +
+ + + CLI + Manual + + +
+

+ If you ran{' '} + npx wgc demo in the + previous step, the router is already running. Otherwise, re-run the command: +

+ +
+
+ +
+

+ Generate a router token and start the router with Docker. +

+ + +
+
+
+
+
+ +
+ + 2 + +
+

Send a test query

+ + + CLI + cURL + Playground + + +

+ While npx wgc demo is + running, press r in the terminal to send a test query. +

+
+ +
+ +
+
+ +

+ Open the{' '} + + Playground + {' '} + to explore the schema and run queries interactively. +

+
+
+
+
+ +
+
+ +
+ +
+
+ + +
+
+ + mutate({}), + isLoading: isPending, + disabled: metricsStatus !== 'ok', + }} + forwardLabel="Finish" + /> +
+
+
+ +
+
+
); }; diff --git a/studio/src/components/onboarding/step-finished.tsx b/studio/src/components/onboarding/step-finished.tsx new file mode 100644 index 0000000000..570e8933d8 --- /dev/null +++ b/studio/src/components/onboarding/step-finished.tsx @@ -0,0 +1,291 @@ +import { useRouter } from 'next/router'; +import { z } from 'zod'; +import { useFieldArray } from 'react-hook-form'; +import { ArrowRightIcon, Cross1Icon, ExternalLinkIcon, PlusIcon } from '@radix-ui/react-icons'; +import { BookOpenIcon, UserPlusIcon } from '@heroicons/react/24/outline'; +import { MdArrowOutward } from 'react-icons/md'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { + inviteUsers, + getOrganizationGroups, +} from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { SubmitHandler, useZodForm } from '@/hooks/use-form'; +import { cn } from '@/lib/utils'; +import { docsBaseURL } from '@/lib/constants'; +import { OnboardingContainer } from './onboarding-container'; +import { Form, FormField, FormControl, FormItem, FormMessage, FormLabel, FormDescription } from '../ui/form'; +import { Input } from '../ui/input'; +import { Button } from '../ui/button'; +import { useToast } from '../ui/use-toast'; +import { useOnboarding } from '@/hooks/use-onboarding'; + +const MAXIMUM_BATCH_SIZE = 5; + +const emailSchema = z.string().email(); +const inviteSchema = z.object({ + members: z + .array( + z.object({ + email: emailSchema.or(z.literal('')), + }), + ) + .refine((rows) => rows.some((r) => r.email.trim().length > 0), { + message: 'Enter at least one email', + }) + .refine((rows) => rows.length <= MAXIMUM_BATCH_SIZE, { + message: `Maximum ${MAXIMUM_BATCH_SIZE} members per invitation`, + }), +}); + +type InviteFormValues = z.infer; + +const DocumentationLinkItem = ({ title, description, href }: { title: string; description: string; href: string }) => ( +
  • + +
    + {title} + {description} +
    + +
    +
  • +); + +const HubPromoLink = () => ( + +
    +
    +
    + Discover Hub + + New + +
    + + A smarter way to design schemas, collaborate, and govern changes — all in one place. + +
    + +
    +
    +); + +export function StepFinished() { + const router = useRouter(); + const { toast } = useToast(); + const { setStep } = useOnboarding(); + + const handleFinish = () => { + setStep(undefined); + router.push('/'); + }; + + const form = useZodForm({ + mode: 'onSubmit', + schema: inviteSchema, + defaultValues: { members: [{ email: '' }] }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'members', + }); + + const watchedMembers = form.watch('members'); + const hasValidEmail = watchedMembers.some((m) => emailSchema.safeParse(m.email).success); + + const { data: groupsData } = useQuery(getOrganizationGroups); + const viewerGroupId = groupsData?.groups?.find((g) => g.name.toLowerCase() === 'viewer')?.groupId; + + const { mutate, isPending } = useMutation(inviteUsers); + + const onSubmit: SubmitHandler = (data) => { + const emails = data.members.map((m) => m.email.trim()).filter((e) => e.length > 0); + mutate( + { emails, groups: viewerGroupId ? [viewerGroupId] : [] }, + { + onSuccess: (d) => { + if (d.response?.code !== EnumStatusCode.OK) { + toast({ + description: d.response?.details ?? 'Could not invite members. Please try again.', + duration: 3000, + }); + return; + } + + if (d.invitationErrors.length > 0) { + const failed = d.invitationErrors.map((e) => e.email).join(', '); + toast({ + description: `Some invitations failed: ${failed}`, + duration: 5000, + }); + return; + } + + toast({ + description: `Invited ${emails.length} ${emails.length === 1 ? 'member' : 'members'}.`, + duration: 3000, + }); + form.reset({ members: [{ email: '' }] }); + }, + onError: () => { + toast({ + description: 'Could not invite members. Please try again.', + duration: 3000, + }); + }, + }, + ); + }; + + return ( + +
    +
    +

    You're all set!

    +

    + Your graph is live. Invite your teammates and explore the docs to keep going. +

    +
    + +
    +
    +

    + + Invite your team +

    +

    + Add teammates by email so they can join your organization. +

    +
    +
    + +
    + {fields.map((field, index) => ( + ( + +
    + + + +
    1 ? 'w-10' : 'w-0', + )} + > +
    1 ? 'opacity-100 delay-150' : 'opacity-0', + )} + > + +
    +
    +
    + +
    + )} + /> + ))} +
    + {form.formState.errors.members?.root?.message && ( +

    {form.formState.errors.members.root.message}

    + )} +
    + + + + + + + {fields.length >= MAXIMUM_BATCH_SIZE && ( + You can invite more members later + )} + + {hasValidEmail && !viewerGroupId ? ( + + + + + + + Unable to load organization data. Please refresh the page. + + ) : ( + + )} +
    +
    + +
    + +
    +
    +

    + + Further reading +

    +

    Jump into the docs to go deeper.

    +
    +
      + + + +
    + +
    + +
    + +
    +
    +
    + ); +} diff --git a/studio/src/hooks/use-fireworks.ts b/studio/src/hooks/use-fireworks.ts index 47d4567a1d..708f2cebc7 100644 --- a/studio/src/hooks/use-fireworks.ts +++ b/studio/src/hooks/use-fireworks.ts @@ -1,6 +1,12 @@ import confetti from 'canvas-confetti'; import React from 'react'; +// Use main-thread rendering instead of the default Worker-backed confetti(). +// The default export uses main.toString() to inject itself into a Blob Worker, +// which breaks when bundlers (Next.js/webpack) transform the code. +// See: https://github.com/catdad/canvas-confetti/issues/166 +const fire = confetti.create(undefined, { resize: true }); + const fireworks = () => { const duration = 2 * 1000; const animationEnd = Date.now() + duration; @@ -19,13 +25,13 @@ const fireworks = () => { const particleCount = 50 * (timeLeft / duration); // since particles fall down, start a bit higher than random - confetti( + fire( Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, }), ); - confetti( + fire( Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, diff --git a/studio/src/pages/onboarding/[step].tsx b/studio/src/pages/onboarding/[step].tsx index 8beec4d58c..d2065682c7 100644 --- a/studio/src/pages/onboarding/[step].tsx +++ b/studio/src/pages/onboarding/[step].tsx @@ -37,7 +37,7 @@ const OnboardingStep: NextPageWithLayout = () => { ); case 3: return ( - + );