From c20104284507275aeb096b7d30a0d19baba86269 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Mon, 4 May 2026 15:33:11 -0300 Subject: [PATCH 01/11] Add boilerplate for wizard steps --- .../components/ConfigureSSO/ConfigureSSO.tsx | 28 +- .../ConfigureSSO/ConfigureSSOContext.tsx | 65 ++++ .../src/components/ConfigureSSO/constants.ts | 36 ++ .../ConfigureSSO/steps/ConfigureStep.tsx | 21 ++ .../ConfigureSSO/steps/ProvideEmailStep.tsx | 73 ++++ .../ConfigureSSO/steps/StepLayout.tsx | 67 ++++ .../steps/TestConfigurationStep.tsx | 30 ++ .../ConfigureSSO/steps/VerifyDomainStep.tsx | 44 +++ .../components/ConfigureSSO/steps/index.ts | 5 + .../ProfileCard/ProfileCardContent.tsx | 26 +- packages/ui/src/elements/Wizard/Wizard.tsx | 321 ++++++++++++++++++ .../ui/src/elements/Wizard/WizardContext.tsx | 95 ++++++ packages/ui/src/elements/Wizard/index.ts | 3 + packages/ui/src/elements/Wizard/types.ts | 122 +++++++ 14 files changed, 927 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/constants.ts create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/index.ts create mode 100644 packages/ui/src/elements/Wizard/Wizard.tsx create mode 100644 packages/ui/src/elements/Wizard/WizardContext.tsx create mode 100644 packages/ui/src/elements/Wizard/index.ts create mode 100644 packages/ui/src/elements/Wizard/types.ts diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 4b744a8a8d0..2eaad35802e 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -8,9 +8,13 @@ import { ApplicationLogo } from '@/elements/ApplicationLogo'; import { withCardStateProvider } from '@/elements/contexts'; import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; import { ProfileCard } from '@/elements/ProfileCard'; +import { Wizard } from '@/elements/Wizard'; import { BoxIcon } from '@/icons'; import { Route, Switch } from '@/router'; +import { ConfigureSSOFlowProvider, useConfigureSSOFlow } from './ConfigureSSOContext'; +import { CONFIGURE_SSO_STEPS } from './constants'; + const ConfigureSSOInternal = () => { return ( @@ -89,12 +93,34 @@ const AuthenticatedContent = withCoreUserGuard(() => { routes={[]} contentRef={contentRef} /> - + + + ); }); +const ConfigureSSOWizardPanel = ({ contentRef }: { contentRef: React.RefObject }) => { + const data = useConfigureSSOFlow(); + + return ( + + + + + + + + ); +}; + const OrganizationSidebarSubtitle = () => { const { organization } = useOrganization(); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx new file mode 100644 index 00000000000..6954e2268f8 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -0,0 +1,65 @@ +import { useUser } from '@clerk/shared/react/index'; +import React from 'react'; + +/** + * Shared form state for the ConfigureSSO wizard. Lives outside the + * Wizard's own context so that: + * - it persists across step navigations (each step is its own + * ``, mounted/unmounted on navigation) + * - `shouldSkip` predicates on `WizardStep` can read it as plain data + * via ``. + */ +export interface ConfigureSSOData { + email: string; + /** + * Domain id returned by the API after the email is submitted. + * Empty until the first step succeeds. + */ + domainId: string; + /** + * `true` if the domain returned by the API is already verified at the + * time the user submits their email — the "Verify domain" step is + * skipped in that case. + */ + domainAlreadyVerified: boolean; +} + +export interface ConfigureSSOContextValue extends ConfigureSSOData { + setEmail: (email: string) => void; + setDomainId: (id: string) => void; +} + +const ConfigureSSOFlowContext = React.createContext(null); +ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext'; + +export const ConfigureSSOFlowProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { + const [domainId, setDomainId] = React.useState(''); + + const { user } = useUser(); + + // user is guaranteed to be defined because we're using the withCoreUserGuard HOC + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [email, setEmail] = React.useState(user!.primaryEmailAddress?.emailAddress ?? ''); + const domainAlreadyVerified = user?.primaryEmailAddress?.verification.status === 'verified'; + + const value = React.useMemo( + () => ({ + email, + domainId, + domainAlreadyVerified, + setEmail, + setDomainId, + }), + [email, domainId, domainAlreadyVerified], + ); + + return {children}; +}; + +export const useConfigureSSOFlow = (): ConfigureSSOContextValue => { + const ctx = React.useContext(ConfigureSSOFlowContext); + if (!ctx) { + throw new Error('useConfigureSSOFlow called outside .'); + } + return ctx; +}; diff --git a/packages/ui/src/components/ConfigureSSO/constants.ts b/packages/ui/src/components/ConfigureSSO/constants.ts new file mode 100644 index 00000000000..89caec9f536 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/constants.ts @@ -0,0 +1,36 @@ +import type { WizardStep } from '@/elements/Wizard'; + +import type { ConfigureSSOData } from './ConfigureSSOContext'; +import { Configure, ProvideEmail, TestStep, VerifyDomain } from './steps'; + +export const CONFIGURE_SSO_STEPS: ReadonlyArray> = [ + { + id: 'provide-email', + path: 'provide-email', + label: 'Provide email', + Component: ProvideEmail, + // Skip this step when the user already has an email address + shouldSkip: data => data.email !== '', + }, + { + id: 'verify-domain', + path: 'verify-domain', + label: 'Verify domain', + Component: VerifyDomain, + isOptional: true, + // Skip this step when the primary email address domain is already verified + shouldSkip: data => data.domainAlreadyVerified, + }, + { + id: 'configure', + path: 'configure', + label: 'Configure', + Component: Configure, + }, + { + id: 'test', + path: 'test', + label: 'Test', + Component: TestStep, + }, +]; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx new file mode 100644 index 00000000000..aa3b7a4862d --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx @@ -0,0 +1,21 @@ +import { Text } from '@/customizables'; +import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; + +import { StepLayout } from './StepLayout'; + +export const Configure = (): JSX.Element => { + const { goNext } = useWizard(); + + useRegisterContinueAction({ + handler: () => goNext(), + }); + + return ( + + Configuration form goes here. + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx new file mode 100644 index 00000000000..8fcbbd61eb0 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -0,0 +1,73 @@ +import { Col, Flex, Heading, Icon, Input, Text } from '@/customizables'; +import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; +import { Email } from '@/icons'; + +import { useConfigureSSOFlow } from '../ConfigureSSOContext'; +import { StepLayout } from './StepLayout'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// TODO -> Conditionally render this step based on the user's email address +// If the user already has an email address, skip this step +// If the instance doesn't support email addresses, skip this step +// If the user doesn't have an email address, render this step +export const ProvideEmail = (): JSX.Element => { + const { email, setEmail } = useConfigureSSOFlow(); + const { goNext } = useWizard(); + + const isValid = EMAIL_RE.test(email.trim()); + + useRegisterContinueAction({ + handler: () => { + if (!isValid) { + return; + } + + // TODO -> Call API to add email address to user + + return goNext(); + }, + isDisabled: !isValid, + }); + + return ( + + ({ + flex: 1, + gap: theme.space.$4, + paddingBlock: theme.space.$8, + })} + > + ({ color: theme.colors.$colorMutedForeground })} + /> + ({ gap: theme.space.$1, alignItems: 'center', textAlign: 'center' })}> + We need your email + ({ color: theme.colors.$colorMutedForeground })} + > + In order to start we will need your email address + + + setEmail(e.currentTarget.value)} + sx={theme => ({ maxWidth: theme.sizes.$60, width: '100%' })} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx new file mode 100644 index 00000000000..65a18e0bd39 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { Col, Flex, Heading, Text } from '@/customizables'; +import { Wizard } from '@/elements/Wizard'; + +interface StepLayoutProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + /** + * If true, renders the "Step X / Y" badge on the title row. + * Defaults to true. + */ + showStepIndicator?: boolean; + children: React.ReactNode; +} + +/** + * Renders the title row (with the Wizard's Step X/Y badge) on top, a divider, and the step body + * underneath. Each individual step file owns the body content. + */ +export const StepLayout = ({ title, subtitle, showStepIndicator = true, children }: StepLayoutProps): JSX.Element => { + return ( + + ({ + gap: theme.space.$4, + padding: `${theme.space.$5} ${theme.space.$6}`, + })} + > + ({ gap: theme.space.$1, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} + > + {title} + + {subtitle ? ( + ({ color: theme.colors.$colorMutedForeground })} + > + {subtitle} + + ) : null} + + {showStepIndicator ? : null} + + ({ + flex: 1, + padding: theme.space.$6, + overflowY: 'auto', + })} + > + {children} + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx new file mode 100644 index 00000000000..fc1d1e02848 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -0,0 +1,30 @@ +import { Col, Text } from '@/customizables'; + +import { StepLayout } from './StepLayout'; + +export const TestConfigurationStep = (): JSX.Element => { + return ( + + ({ + gap: theme.space.$4, + maxWidth: theme.sizes.$160, + marginInline: 'auto', + paddingBlock: theme.space.$8, + })} + > + ({ color: theme.colors.$colorMutedForeground })} + > + Test step UI goes here. The shared “Continue” button is hidden on the last step; use a step-local + primary action to finish. + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx new file mode 100644 index 00000000000..aefa604ce29 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -0,0 +1,44 @@ +import { Col, Text } from '@/customizables'; +import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; + +import { useConfigureSSOFlow } from '../ConfigureSSOContext'; +import { StepLayout } from './StepLayout'; + +export const VerifyDomain = (): JSX.Element => { + const { email } = useConfigureSSOFlow(); + const { goNext } = useWizard(); + + useRegisterContinueAction({ + handler: () => goNext(), + }); + + const domain = email.split('@')[1] ?? ''; + + return ( + + ({ + gap: theme.space.$3, + maxWidth: theme.sizes.$160, + marginInline: 'auto', + paddingBlock: theme.space.$8, + })} + > + ({ color: theme.colors.$colorMutedForeground })} + > + Verification form goes here + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts new file mode 100644 index 00000000000..be95e6e985b --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -0,0 +1,5 @@ +export { Configure } from './ConfigureStep'; +export { ProvideEmail } from './ProvideEmailStep'; +export { StepLayout } from './StepLayout'; +export { TestConfigurationStep as TestStep } from './TestConfigurationStep'; +export { VerifyDomain } from './VerifyDomainStep'; diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx index f9b075c43f0..27e55db9511 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx @@ -7,9 +7,15 @@ import { common, mqu } from '../../styledSystem'; type ProfileCardContentProps = React.PropsWithChildren<{ contentRef?: React.RefObject; scrollBoxId?: string; + /** + * Disables the default padding on the inner scroll container. Useful for + * flows like the SSO configuration wizard whose internal sections (header, + * body, footer) already provide their own spacing. + */ + disablePadding?: boolean; }>; export const ProfileCardContent = (props: ProfileCardContentProps) => { - const { contentRef, children, scrollBoxId } = props; + const { contentRef, children, scrollBoxId, disablePadding = false } = props; const router = useRouter(); const scrollPosRef = React.useRef(0); @@ -53,13 +59,17 @@ export const ProfileCardContent = (props: ProfileCardContentProps) => { sx={theme => ({ flex: `1`, scrollbarGutter: 'stable', - paddingTop: theme.space.$7, - paddingBottom: theme.space.$7, - paddingInlineStart: theme.space.$8, - paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter - [mqu.sm]: { - padding: `${theme.space.$8} ${theme.space.$5}`, - }, + ...(disablePadding + ? { padding: 0 } + : { + paddingTop: theme.space.$7, + paddingBottom: theme.space.$7, + paddingInlineStart: theme.space.$8, + paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter + [mqu.sm]: { + padding: `${theme.space.$8} ${theme.space.$5}`, + }, + }), ...common.maxHeightScroller(theme), })} ref={contentRef} diff --git a/packages/ui/src/elements/Wizard/Wizard.tsx b/packages/ui/src/elements/Wizard/Wizard.tsx new file mode 100644 index 00000000000..87443d2d15b --- /dev/null +++ b/packages/ui/src/elements/Wizard/Wizard.tsx @@ -0,0 +1,321 @@ +import React from 'react'; + +import { Badge, Button, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables'; +import { CaretLeft, CaretRight } from '@/icons'; +import { Route, Switch, useRouter } from '@/router'; + +import type { WizardStep } from './types'; +import { useWizard, WizardProvider } from './WizardContext'; + +interface WizardRootProps { + steps: ReadonlyArray>; + /** + * Optional data passed to each step's `shouldSkip` predicate. When the + * referenced shape changes, the active step list is recomputed. + */ + data?: TData; + children: React.ReactNode; +} + +const Root = (props: WizardRootProps): JSX.Element => { + const { steps, data, children } = props; + const router = useRouter(); + + const activeSteps = React.useMemo(() => steps.filter(step => !step.shouldSkip?.(data as TData)), [steps, data]); + + // Detect the current step based on the URL. We iterate from the last + // (most specific) step back to the second one — the first step is the + // index route and always wins as a fallback. + const currentStep = React.useMemo | undefined>(() => { + if (activeSteps.length === 0) { + return undefined; + } + for (let i = activeSteps.length - 1; i >= 1; i--) { + if (router.matches(activeSteps[i].path)) { + return activeSteps[i]; + } + } + return activeSteps[0]; + }, [activeSteps, router]); + + const navigateToStep = React.useCallback( + (step: WizardStep | undefined) => { + if (!step) { + return; + } + const isFirst = activeSteps[0]?.id === step.id; + // First step is the index route, so navigate to the wizard base. + // Subsequent steps live at `/`. + return router.navigate(isFirst ? './' : step.path); + }, + [activeSteps, router], + ); + + const goNext = React.useCallback(() => { + if (!currentStep) { + return; + } + const idx = activeSteps.findIndex(s => s.id === currentStep.id); + return navigateToStep(activeSteps[idx + 1]); + }, [activeSteps, currentStep, navigateToStep]); + + const goPrev = React.useCallback(() => { + if (!currentStep) { + return; + } + const idx = activeSteps.findIndex(s => s.id === currentStep.id); + return navigateToStep(activeSteps[idx - 1]); + }, [activeSteps, currentStep, navigateToStep]); + + const goToStep = React.useCallback( + (id: string) => navigateToStep(activeSteps.find(s => s.id === id)), + [activeSteps, navigateToStep], + ); + + return ( + + {children} + + ); +}; + +/** + * Renders the active step component via the SDK router. The first + * active step is mounted as the index route; the rest get `path` routes + * matching their `WizardStep.path`. + */ +const Content = (): JSX.Element => { + const { activeSteps } = useWizard(); + return ( + + {activeSteps.map((step, i) => { + const StepComponent = step.Component; + return ( + + + + ); + })} + + ); +}; + +/** + * Numbered breadcrumb of all active steps. Completed and current steps + * are clickable for backwards navigation; future steps are disabled. + */ +const Header = (): JSX.Element => { + const { activeSteps, currentIndex, goToStep } = useWizard(); + const { t } = useLocalizations(); + + return ( + ({ + gap: theme.space.$2, + padding: `${theme.space.$4} ${theme.space.$6}`, + borderBottomWidth: theme.borderWidths.$normal, + borderBottomStyle: theme.borderStyles.$solid, + borderBottomColor: theme.colors.$borderAlpha100, + flexWrap: 'wrap', + })} + > + {activeSteps.map((step, idx) => { + const isCurrent = idx === currentIndex; + const isCompleted = idx < currentIndex; + const isReachable = idx <= currentIndex; + const label = typeof step.label === 'string' ? step.label : t(step.label); + + return ( + + + {idx < activeSteps.length - 1 && ( + ({ color: theme.colors.$colorMutedForeground })} + /> + )} + + ); + })} + + ); +}; + +/** + * Compact "Step X / Y" badge for a step to show next to its own title. + * Reads the count from context so it stays in sync with skipped steps. + */ +const StepIndicator = (): JSX.Element | null => { + const { currentIndex, totalSteps } = useWizard(); + + if (currentIndex < 0) { + return null; + } + + return ( + + + ({ fontSize: t.fontSizes.$xs })} + > + Step {currentIndex + 1}/{totalSteps} + + + + ); +}; + +interface FooterProps { + /** + * Override label for the Previous button. + */ + previousLabel?: string; + /** + * Override label for the Continue button (also overridable per step + * via `setContinueAction({ label })`). + */ + continueLabel?: string; + /** + * Hides the Previous button entirely (e.g. on the first step you may + * still want to keep it disabled rather than hidden — that is the + * default). + */ + hidePrevious?: boolean; +} + +/** + * Shared Previous / Continue footer. Continue dispatches to the + * currently registered step `ContinueAction` if any; otherwise it + * simply advances to the next step. + */ +const Footer = (props: FooterProps): JSX.Element => { + const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false } = props; + const { isFirstStep, isLastStep, goPrev, goNext, continueAction } = useWizard(); + const { t } = useLocalizations(); + + const continueLabelToShow = + typeof continueAction?.label === 'string' + ? continueAction.label + : continueAction?.label + ? t(continueAction.label) + : continueLabel; + + const handleContinue = () => { + if (continueAction?.handler) { + void continueAction.handler(); + return; + } + void goNext(); + }; + + return ( + ({ + gap: theme.space.$2, + padding: `${theme.space.$3} ${theme.space.$6}`, + borderTopWidth: theme.borderWidths.$normal, + borderTopStyle: theme.borderStyles.$solid, + borderTopColor: theme.colors.$borderAlpha100, + })} + > + {!hidePrevious && ( + + )} + + + ); +}; + +export const Wizard = { + Root, + Header, + Content, + Footer, + StepIndicator, +}; diff --git a/packages/ui/src/elements/Wizard/WizardContext.tsx b/packages/ui/src/elements/Wizard/WizardContext.tsx new file mode 100644 index 00000000000..e8f49a215fc --- /dev/null +++ b/packages/ui/src/elements/Wizard/WizardContext.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import type { ContinueAction, WizardContextValue, WizardStep } from './types'; + +const WizardContext = React.createContext | null>(null); +WizardContext.displayName = 'WizardContext'; + +export function useWizard(): WizardContextValue { + const ctx = React.useContext(WizardContext); + if (!ctx) { + throw new Error('useWizard called outside of .'); + } + return ctx as WizardContextValue; +} + +interface WizardProviderProps { + activeSteps: WizardStep[]; + currentStep: WizardStep | undefined; + goNext: WizardContextValue['goNext']; + goPrev: WizardContextValue['goPrev']; + goToStep: WizardContextValue['goToStep']; + children: React.ReactNode; +} + +export function WizardProvider(props: WizardProviderProps): JSX.Element { + const { activeSteps, currentStep, goNext, goPrev, goToStep, children } = props; + + const [continueAction, setContinueAction] = React.useState(undefined); + + // Reset the registered continue action whenever the active step changes, + // so that a stale handler from a previous step never lingers. + React.useEffect(() => { + setContinueAction(undefined); + }, [currentStep?.id]); + + const value = React.useMemo>(() => { + const currentIndex = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1; + return { + activeSteps, + currentStep, + currentIndex, + totalSteps: activeSteps.length, + isFirstStep: currentIndex === 0, + isLastStep: currentIndex === activeSteps.length - 1, + goNext, + goPrev, + goToStep, + continueAction, + setContinueAction, + }; + }, [activeSteps, currentStep, goNext, goPrev, goToStep, continueAction]); + + return {children}; +} + +/** + * Helper for step components that need to register a Continue action. + * + * The `handler` is forwarded through a ref so a fresh closure on each + * render does not retrigger the effect (which would cause an infinite + * loop, since updating the registered action re-renders consumers and + * therefore the step itself). State-shaped fields like `isDisabled` / + * `isLoading` / `label` are watched as primitives and re-publish the + * action when they change. + */ +export function useRegisterContinueAction(action: ContinueAction | undefined): void { + const { setContinueAction } = useWizard(); + + const handlerRef = React.useRef(action?.handler); + handlerRef.current = action?.handler; + + const hasAction = action !== undefined; + const isDisabled = action?.isDisabled; + const isLoading = action?.isLoading; + const label = action?.label; + + React.useEffect(() => { + if (!hasAction) { + setContinueAction(undefined); + return; + } + setContinueAction({ + handler: () => handlerRef.current?.(), + isDisabled, + isLoading, + label, + }); + }, [hasAction, isDisabled, isLoading, label, setContinueAction]); + + // Separate unmount-only cleanup, so dep changes above don't transiently + // clear the registered action. + React.useEffect(() => { + return () => setContinueAction(undefined); + }, [setContinueAction]); +} diff --git a/packages/ui/src/elements/Wizard/index.ts b/packages/ui/src/elements/Wizard/index.ts new file mode 100644 index 00000000000..18236fb3ceb --- /dev/null +++ b/packages/ui/src/elements/Wizard/index.ts @@ -0,0 +1,3 @@ +export { Wizard } from './Wizard'; +export { useWizard, useRegisterContinueAction } from './WizardContext'; +export type { ContinueAction, WizardContextValue, WizardStep } from './types'; diff --git a/packages/ui/src/elements/Wizard/types.ts b/packages/ui/src/elements/Wizard/types.ts new file mode 100644 index 00000000000..4a27f8bfe1b --- /dev/null +++ b/packages/ui/src/elements/Wizard/types.ts @@ -0,0 +1,122 @@ +import type React from 'react'; + +import type { LocalizationKey } from '@/customizables'; + +/** + * Describes a single step in a multi-step Wizard flow. + * + * Steps are typically defined as a `const` array on the consumer side + * and passed to ``. The Wizard renders one + * step at a time, driven by the SDK router (`Route`/`Switch`). + */ +export interface WizardStep { + /** + * Stable identifier for the step. Used for keying and for `goToStep(id)`. + */ + id: string; + /** + * Path fragment used by the SDK router. The first non-skipped step is + * automatically rendered as the index route, so its `path` is only + * used as a label for `goToStep`/deep-linking purposes. + */ + path: string; + /** + * Label shown in the breadcrumb / step indicator at the top of the wizard. + */ + label: LocalizationKey | string; + /** + * The component rendered when this step is active. + */ + Component: React.ComponentType; + /** + * Marks the step as conditional. Purely informational — the runtime + * decision is made by `shouldSkip`. + */ + isOptional?: boolean; + /** + * Predicate that, when it returns `true`, removes the step from the + * active list. Skipped steps are not rendered, do not appear in the + * breadcrumb, and are jumped over by `goNext`/`goPrev`. + * + * Receives the optional `data` value passed to ``. + */ + shouldSkip?: (data: TData) => boolean; +} + +/** + * Action registered by the currently active step to be invoked when the + * shared "Continue" button in the Wizard footer is clicked. + * + * If no step registers a `ContinueAction`, the footer falls back to + * calling `goNext()` directly. + */ +export interface ContinueAction { + /** + * Called when the user clicks "Continue". Should typically validate / + * submit the step's form and then call `goNext()` on success. + * + * The return value is ignored — `Promise` is allowed so that + * step authors can directly forward the wizard's `goNext()` result. + */ + handler: () => void | Promise; + /** + * Disables the Continue button (e.g. while a form is invalid). + */ + isDisabled?: boolean; + /** + * Renders a loading state on the Continue button. + */ + isLoading?: boolean; + /** + * Optional override for the Continue button label. + */ + label?: LocalizationKey | string; +} + +export interface WizardContextValue { + /** + * The list of steps after `shouldSkip` has been applied. This is what + * the breadcrumb and footer iterate over. + */ + activeSteps: WizardStep[]; + /** + * The step matched by the current SDK route, or `undefined` while the + * router is settling. + */ + currentStep: WizardStep | undefined; + /** + * Index of `currentStep` within `activeSteps`. `-1` if not matched. + */ + currentIndex: number; + /** + * Convenience: `activeSteps.length`. + */ + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + /** + * Navigate to the next active step. No-op on the last step. + */ + goNext: () => Promise | void; + /** + * Navigate to the previous active step. No-op on the first step. + */ + goPrev: () => Promise | void; + /** + * Jump to a specific step by `id`. No-op if the id is not in + * `activeSteps`. + */ + goToStep: (id: string) => Promise | void; + /** + * Currently registered Continue action, or `undefined` if no step has + * registered one. + */ + continueAction: ContinueAction | undefined; + /** + * Used by step components to register what should happen when the + * shared Continue button is pressed. Pass `undefined` to clear. + * + * Typical usage: register on mount, clear on unmount. + */ + setContinueAction: (action: ContinueAction | undefined) => void; +} From 023d9af5ea6129ac3c3da90a8d8789e130986587 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Mon, 4 May 2026 20:39:50 -0300 Subject: [PATCH 02/11] Add support for inner steps --- .../src/components/ConfigureSSO/constants.ts | 55 +++-- ...ureStep.tsx => ConfigureCreateAppStep.tsx} | 4 +- .../steps/ConfigureMapAttributesStep.tsx | 21 ++ .../ConfigureSSO/steps/ConfirmationStep.tsx | 17 ++ .../ConfigureSSO/steps/ProvideEmailStep.tsx | 4 +- .../ConfigureSSO/steps/StepLayout.tsx | 53 ++--- .../steps/TestConfigurationStep.tsx | 7 +- .../components/ConfigureSSO/steps/index.ts | 4 +- packages/ui/src/elements/Wizard/Wizard.tsx | 188 ++++++++++++++---- .../ui/src/elements/Wizard/WizardContext.tsx | 32 ++- packages/ui/src/elements/Wizard/index.ts | 2 +- packages/ui/src/elements/Wizard/types.ts | 100 ++++++++-- 12 files changed, 381 insertions(+), 106 deletions(-) rename packages/ui/src/components/ConfigureSSO/steps/{ConfigureStep.tsx => ConfigureCreateAppStep.tsx} (81%) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx diff --git a/packages/ui/src/components/ConfigureSSO/constants.ts b/packages/ui/src/components/ConfigureSSO/constants.ts index 89caec9f536..43498d0c5bd 100644 --- a/packages/ui/src/components/ConfigureSSO/constants.ts +++ b/packages/ui/src/components/ConfigureSSO/constants.ts @@ -1,31 +1,52 @@ import type { WizardStep } from '@/elements/Wizard'; import type { ConfigureSSOData } from './ConfigureSSOContext'; -import { Configure, ProvideEmail, TestStep, VerifyDomain } from './steps'; +import { + ConfigureCreateApp, + ConfigureMapAttributes, + ConfirmationStep, + ProvideEmail, + TestStep, + VerifyDomain, +} from './steps'; export const CONFIGURE_SSO_STEPS: ReadonlyArray> = [ { - id: 'provide-email', - path: 'provide-email', - label: 'Provide email', - Component: ProvideEmail, - // Skip this step when the user already has an email address - shouldSkip: data => data.email !== '', - }, - { - id: 'verify-domain', - path: 'verify-domain', + id: 'verify-email-domain', + path: 'verify-email-domain', label: 'Verify domain', - Component: VerifyDomain, isOptional: true, - // Skip this step when the primary email address domain is already verified + // Skip this step when there's a primary email address domain already verified shouldSkip: data => data.domainAlreadyVerified, + steps: [ + { + id: 'provide-email', + path: 'provide-email', + Component: ProvideEmail, + }, + { + id: 'verify-domain', + path: 'verify-domain', + Component: VerifyDomain, + }, + ], }, { id: 'configure', path: 'configure', label: 'Configure', - Component: Configure, + steps: [ + { + id: 'create-app', + path: 'create-app', + Component: ConfigureCreateApp, + }, + { + id: 'map-attributes', + path: 'map-attributes', + Component: ConfigureMapAttributes, + }, + ], }, { id: 'test', @@ -33,4 +54,10 @@ export const CONFIGURE_SSO_STEPS: ReadonlyArray> = label: 'Test', Component: TestStep, }, + { + id: 'confirmation', + path: 'confirmation', + label: 'Confirmation', + Component: ConfirmationStep, + }, ]; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx similarity index 81% rename from packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx rename to packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx index aa3b7a4862d..8e2bce37ac6 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx @@ -3,7 +3,7 @@ import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; import { StepLayout } from './StepLayout'; -export const Configure = (): JSX.Element => { +export const ConfigureCreateApp = (): JSX.Element => { const { goNext } = useWizard(); useRegisterContinueAction({ @@ -15,7 +15,7 @@ export const Configure = (): JSX.Element => { title='Configure Okta Workforce' subtitle='Create a new enterprise application in your Okta Dashboard.' > - Configuration form goes here. + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx new file mode 100644 index 00000000000..b4747e45dc0 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx @@ -0,0 +1,21 @@ +import { Text } from '@/customizables'; +import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; + +import { StepLayout } from './StepLayout'; + +export const ConfigureMapAttributes = (): JSX.Element => { + const { goNext } = useWizard(); + + useRegisterContinueAction({ + handler: () => goNext(), + }); + + return ( + + UI goes here + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx new file mode 100644 index 00000000000..f9abb17f3e0 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -0,0 +1,17 @@ +import { Text } from '@/customizables'; + +import { StepLayout } from './StepLayout'; + +export const ConfirmationStep = (): JSX.Element => { + return ( + + ({ color: theme.colors.$colorMutedForeground })} + > + UI goes here + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx index 8fcbbd61eb0..f8fc214d35d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -32,8 +32,8 @@ export const ProvideEmail = (): JSX.Element => { return ( { +export const StepLayout = ({ title, subtitle, children }: StepLayoutProps): JSX.Element => { return ( ({ gap: theme.space.$4, - padding: `${theme.space.$5} ${theme.space.$6}`, + padding: theme.space.$5, })} > - ({ gap: theme.space.$1, minWidth: 0 })}> - ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} - > - {title} - - {subtitle ? ( - ({ color: theme.colors.$colorMutedForeground })} + {title ? ( + ({ gap: theme.space.$1, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} > - {subtitle} - - ) : null} - - {showStepIndicator ? : null} + {title} + + {subtitle ? ( + ({ color: theme.colors.$colorMutedForeground })} + > + {subtitle} + + ) : null} + + ) : null} + ({ flex: 1, - padding: theme.space.$6, + paddingInline: theme.space.$5, overflowY: 'auto', })} > diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index fc1d1e02848..00525b0c8bf 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -5,8 +5,8 @@ import { StepLayout } from './StepLayout'; export const TestConfigurationStep = (): JSX.Element => { return ( ({ @@ -21,8 +21,7 @@ export const TestConfigurationStep = (): JSX.Element => { variant='body' sx={theme => ({ color: theme.colors.$colorMutedForeground })} > - Test step UI goes here. The shared “Continue” button is hidden on the last step; use a step-local - primary action to finish. + UI goes here diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts index be95e6e985b..a1d0825c17f 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/index.ts +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -1,4 +1,6 @@ -export { Configure } from './ConfigureStep'; +export { ConfigureCreateApp } from './ConfigureCreateAppStep'; +export { ConfigureMapAttributes } from './ConfigureMapAttributesStep'; +export { ConfirmationStep } from './ConfirmationStep'; export { ProvideEmail } from './ProvideEmailStep'; export { StepLayout } from './StepLayout'; export { TestConfigurationStep as TestStep } from './TestConfigurationStep'; diff --git a/packages/ui/src/elements/Wizard/Wizard.tsx b/packages/ui/src/elements/Wizard/Wizard.tsx index 87443d2d15b..46ad12778e6 100644 --- a/packages/ui/src/elements/Wizard/Wizard.tsx +++ b/packages/ui/src/elements/Wizard/Wizard.tsx @@ -4,7 +4,7 @@ import { Badge, Button, descriptors, Flex, Icon, Text, useLocalizations } from ' import { CaretLeft, CaretRight } from '@/icons'; import { Route, Switch, useRouter } from '@/router'; -import type { WizardStep } from './types'; +import type { WizardInnerStep, WizardStep } from './types'; import { useWizard, WizardProvider } from './WizardContext'; interface WizardRootProps { @@ -23,9 +23,9 @@ const Root = (props: WizardRootProps): JSX.Element => { const activeSteps = React.useMemo(() => steps.filter(step => !step.shouldSkip?.(data as TData)), [steps, data]); - // Detect the current step based on the URL. We iterate from the last - // (most specific) step back to the second one — the first step is the - // index route and always wins as a fallback. + // Detect the current main step based on the URL. We iterate from the + // last (most specific) step back to the second one — the first step + // is the index route and always wins as a fallback. const currentStep = React.useMemo | undefined>(() => { if (activeSteps.length === 0) { return undefined; @@ -38,44 +38,121 @@ const Root = (props: WizardRootProps): JSX.Element => { return activeSteps[0]; }, [activeSteps, router]); - const navigateToStep = React.useCallback( - (step: WizardStep | undefined) => { - if (!step) { + // Active inner steps of the current main step, after `shouldSkip`. + const innerSteps = React.useMemo[]>(() => { + if (!currentStep?.steps) { + return []; + } + return currentStep.steps.filter(step => !step.shouldSkip?.(data as TData)); + }, [currentStep, data]); + + // Detect the current inner step within the current main step using + // the same most-specific-first scan. The first inner step is the + // index route — it matches when no deeper path is found. The first + // main step is the wizard's catch-all route (it has no URL prefix + // of its own), so its inner steps live directly under the wizard + // base; for any other main step they live under `/`. + const currentInnerStep = React.useMemo | undefined>(() => { + if (!currentStep || innerSteps.length === 0) { + return undefined; + } + const isFirstMain = activeSteps[0]?.id === currentStep.id; + for (let i = innerSteps.length - 1; i >= 1; i--) { + const innerPath = isFirstMain ? innerSteps[i].path : `${currentStep.path}/${innerSteps[i].path}`; + if (router.matches(innerPath)) { + return innerSteps[i]; + } + } + return innerSteps[0]; + }, [activeSteps, currentStep, innerSteps, router]); + + // Resolves a navigation target to the relative path the SDK router + // expects. The first non-skipped main step is the wizard's catch-all + // route, so it contributes no URL segment; its inner steps live + // directly under the wizard base. Other main steps live at their + // own `path`, with inner steps nested under it. The first inner + // step of any main step is its parent's index route and so lives + // at the parent's URL. + const resolveNavigation = React.useCallback( + (mainStep: WizardStep | undefined, innerStep?: WizardInnerStep): string | undefined => { + if (!mainStep) { + return undefined; + } + const isFirstMain = activeSteps[0]?.id === mainStep.id; + const innerList = mainStep.steps?.filter(s => !s.shouldSkip?.(data as TData)) ?? []; + const targetInner = innerStep ?? innerList[0]; + const isFirstInner = !targetInner || innerList[0]?.id === targetInner.id; + + const mainSegment = isFirstMain ? '' : mainStep.path; + const innerSegment = isFirstInner ? '' : targetInner.path; + const segments = [mainSegment, innerSegment].filter(Boolean); + return segments.length === 0 ? './' : segments.join('/'); + }, + [activeSteps, data], + ); + + const navigateTo = React.useCallback( + (mainStep: WizardStep | undefined, innerStep?: WizardInnerStep) => { + const to = resolveNavigation(mainStep, innerStep); + if (to === undefined) { return; } - const isFirst = activeSteps[0]?.id === step.id; - // First step is the index route, so navigate to the wizard base. - // Subsequent steps live at `/`. - return router.navigate(isFirst ? './' : step.path); + return router.navigate(to); }, - [activeSteps, router], + [resolveNavigation, router], ); const goNext = React.useCallback(() => { if (!currentStep) { return; } - const idx = activeSteps.findIndex(s => s.id === currentStep.id); - return navigateToStep(activeSteps[idx + 1]); - }, [activeSteps, currentStep, navigateToStep]); + // Within a container step, advance through the inner steps first. + if (innerSteps.length > 0) { + const idx = innerSteps.findIndex(s => s.id === currentInnerStep?.id); + if (idx >= 0 && idx < innerSteps.length - 1) { + return navigateTo(currentStep, innerSteps[idx + 1]); + } + } + // Otherwise (or after the last inner step), jump to the next main + // step. Lands on its first inner step, if any. + const mainIdx = activeSteps.findIndex(s => s.id === currentStep.id); + return navigateTo(activeSteps[mainIdx + 1]); + }, [activeSteps, currentStep, currentInnerStep, innerSteps, navigateTo]); const goPrev = React.useCallback(() => { if (!currentStep) { return; } - const idx = activeSteps.findIndex(s => s.id === currentStep.id); - return navigateToStep(activeSteps[idx - 1]); - }, [activeSteps, currentStep, navigateToStep]); + if (innerSteps.length > 0) { + const idx = innerSteps.findIndex(s => s.id === currentInnerStep?.id); + if (idx > 0) { + return navigateTo(currentStep, innerSteps[idx - 1]); + } + } + const mainIdx = activeSteps.findIndex(s => s.id === currentStep.id); + const prevMain = activeSteps[mainIdx - 1]; + if (!prevMain) { + return; + } + // When stepping back into a container step, land on its *last* + // inner step — that's the position the user would naturally + // expect to revisit. + const prevInnerList = prevMain.steps?.filter(s => !s.shouldSkip?.(data as TData)) ?? []; + const targetInner = prevInnerList[prevInnerList.length - 1]; + return navigateTo(prevMain, targetInner); + }, [activeSteps, currentStep, currentInnerStep, data, innerSteps, navigateTo]); const goToStep = React.useCallback( - (id: string) => navigateToStep(activeSteps.find(s => s.id === id)), - [activeSteps, navigateToStep], + (id: string) => navigateTo(activeSteps.find(s => s.id === id)), + [activeSteps, navigateTo], ); return ( (props: WizardRootProps): JSX.Element => { }; /** - * Renders the active step component via the SDK router. The first - * active step is mounted as the index route; the rest get `path` routes - * matching their `WizardStep.path`. + * Renders the inner steps of a container step as nested routes. Falls + * back to the step's own `Component` for leaf steps. */ -const Content = (): JSX.Element => { - const { activeSteps } = useWizard(); +const StepRoutes = ({ step }: { step: WizardStep }): JSX.Element | null => { + if (!step.steps?.length) { + if (!step.Component) { + return null; + } + const StepComponent = step.Component; + return ; + } + return ( - {activeSteps.map((step, i) => { - const StepComponent = step.Component; + {step.steps.map((inner, i) => { + const InnerComponent = inner.Component; return ( - + ); })} @@ -109,6 +192,39 @@ const Content = (): JSX.Element => { ); }; +/** + * Renders the active step component via the SDK router. Non-first + * steps get `path` routes matching their `WizardStep.path`. The + * first step is mounted last as a path-less, index-less catch-all + * so that it owns the wizard's base URL *and* any deeper URL that + * doesn't belong to another main step — that's how its inner steps + * (which would otherwise live under a non-existent first-step path + * segment) end up routable. Container steps render their inner + * steps under nested routes. + */ +const Content = (): JSX.Element => { + const { activeSteps } = useWizard(); + if (activeSteps.length === 0) { + return <>; + } + const [firstStep, ...restSteps] = activeSteps; + return ( + + {restSteps.map(step => ( + + + + ))} + + + + + ); +}; + /** * Numbered breadcrumb of all active steps. Completed and current steps * are clickable for backwards navigation; future steps are disabled. @@ -194,13 +310,15 @@ const Header = (): JSX.Element => { }; /** - * Compact "Step X / Y" badge for a step to show next to its own title. - * Reads the count from context so it stays in sync with skipped steps. + * Compact "Step X / Y" badge that tracks the current main step's + * inner-step progress. Renders nothing when the current step has no + * inner steps — that's the signal that the parent layout doesn't + * need to reserve room for it. */ const StepIndicator = (): JSX.Element | null => { - const { currentIndex, totalSteps } = useWizard(); + const { totalInnerSteps, currentInnerIndex } = useWizard(); - if (currentIndex < 0) { + if (totalInnerSteps <= 0 || currentInnerIndex < 0) { return null; } @@ -216,7 +334,7 @@ const StepIndicator = (): JSX.Element | null => { as='span' sx={t => ({ fontSize: t.fontSizes.$xs })} > - Step {currentIndex + 1}/{totalSteps} + Step {currentInnerIndex + 1}/{totalInnerSteps} diff --git a/packages/ui/src/elements/Wizard/WizardContext.tsx b/packages/ui/src/elements/Wizard/WizardContext.tsx index e8f49a215fc..dab9b63a8e8 100644 --- a/packages/ui/src/elements/Wizard/WizardContext.tsx +++ b/packages/ui/src/elements/Wizard/WizardContext.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import type { ContinueAction, WizardContextValue, WizardStep } from './types'; +import type { ContinueAction, WizardContextValue, WizardInnerStep, WizardStep } from './types'; const WizardContext = React.createContext | null>(null); WizardContext.displayName = 'WizardContext'; @@ -16,6 +16,8 @@ export function useWizard(): WizardContextValue { interface WizardProviderProps { activeSteps: WizardStep[]; currentStep: WizardStep | undefined; + innerSteps: WizardInnerStep[]; + currentInnerStep: WizardInnerStep | undefined; goNext: WizardContextValue['goNext']; goPrev: WizardContextValue['goPrev']; goToStep: WizardContextValue['goToStep']; @@ -23,32 +25,46 @@ interface WizardProviderProps { } export function WizardProvider(props: WizardProviderProps): JSX.Element { - const { activeSteps, currentStep, goNext, goPrev, goToStep, children } = props; + const { activeSteps, currentStep, innerSteps, currentInnerStep, goNext, goPrev, goToStep, children } = props; const [continueAction, setContinueAction] = React.useState(undefined); - // Reset the registered continue action whenever the active step changes, - // so that a stale handler from a previous step never lingers. + // Reset the registered continue action whenever the active (inner) step + // changes, so that a stale handler from a previous step never lingers. + // Inner-step transitions don't change `currentStep`, so we also key + // off `currentInnerStep`. React.useEffect(() => { setContinueAction(undefined); - }, [currentStep?.id]); + }, [currentStep?.id, currentInnerStep?.id]); const value = React.useMemo>(() => { const currentIndex = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1; + const currentInnerIndex = currentInnerStep ? innerSteps.findIndex(s => s.id === currentInnerStep.id) : -1; + const totalInnerSteps = innerSteps.length; + const hasInnerSteps = totalInnerSteps > 0; + + const isFirstStep = currentIndex === 0 && (!hasInnerSteps || currentInnerIndex <= 0); + const isLastStep = + currentIndex === activeSteps.length - 1 && (!hasInnerSteps || currentInnerIndex === totalInnerSteps - 1); + return { activeSteps, currentStep, currentIndex, totalSteps: activeSteps.length, - isFirstStep: currentIndex === 0, - isLastStep: currentIndex === activeSteps.length - 1, + innerSteps, + currentInnerStep, + currentInnerIndex, + totalInnerSteps, + isFirstStep, + isLastStep, goNext, goPrev, goToStep, continueAction, setContinueAction, }; - }, [activeSteps, currentStep, goNext, goPrev, goToStep, continueAction]); + }, [activeSteps, currentStep, innerSteps, currentInnerStep, goNext, goPrev, goToStep, continueAction]); return {children}; } diff --git a/packages/ui/src/elements/Wizard/index.ts b/packages/ui/src/elements/Wizard/index.ts index 18236fb3ceb..f704cfac660 100644 --- a/packages/ui/src/elements/Wizard/index.ts +++ b/packages/ui/src/elements/Wizard/index.ts @@ -1,3 +1,3 @@ export { Wizard } from './Wizard'; export { useWizard, useRegisterContinueAction } from './WizardContext'; -export type { ContinueAction, WizardContextValue, WizardStep } from './types'; +export type { ContinueAction, WizardContextValue, WizardInnerStep, WizardStep } from './types'; diff --git a/packages/ui/src/elements/Wizard/types.ts b/packages/ui/src/elements/Wizard/types.ts index 4a27f8bfe1b..e72f18e4980 100644 --- a/packages/ui/src/elements/Wizard/types.ts +++ b/packages/ui/src/elements/Wizard/types.ts @@ -8,6 +8,12 @@ import type { LocalizationKey } from '@/customizables'; * Steps are typically defined as a `const` array on the consumer side * and passed to ``. The Wizard renders one * step at a time, driven by the SDK router (`Route`/`Switch`). + * + * A step can either be a *leaf* (renders a single `Component`) or a + * *container* (declares an ordered list of `steps` — inner sub-steps + * the user walks through before the wizard advances to the next main + * step). Containers are routed under their parent path + * (e.g. `/configure/create-app`). */ export interface WizardStep { /** @@ -21,13 +27,27 @@ export interface WizardStep { */ path: string; /** - * Label shown in the breadcrumb / step indicator at the top of the wizard. + * Label shown in the breadcrumb at the top of the wizard. Inner + * steps don't need a label — they don't appear in the breadcrumb. */ label: LocalizationKey | string; /** - * The component rendered when this step is active. + * The component rendered when this step is active. Required for + * leaf steps; ignored when `steps` is provided (the active inner + * step's component is rendered instead). */ - Component: React.ComponentType; + Component?: React.ComponentType; + /** + * Optional inner sub-steps. When provided, this step is treated as + * a container: the wizard advances through the inner steps via the + * Footer's "Continue" button, then moves on to the next main step + * once the last inner step is completed. + * + * Inner steps share their parent's breadcrumb entry but each get + * their own URL (`/`). The first inner + * step is mounted as the parent's index route. + */ + steps?: ReadonlyArray>; /** * Marks the step as conditional. Purely informational — the runtime * decision is made by `shouldSkip`. @@ -43,6 +63,31 @@ export interface WizardStep { shouldSkip?: (data: TData) => boolean; } +/** + * Inner sub-step of a container `WizardStep`. Inner steps are not + * shown in the breadcrumb; instead they drive the per-step indicator + * badge ("Step X / Y") and the Continue/Previous footer behaviour. + */ +export interface WizardInnerStep { + /** + * Stable identifier, unique within the parent step. + */ + id: string; + /** + * Path fragment relative to the parent step. The first non-skipped + * inner step is rendered at the parent's path (index route). + */ + path: string; + /** + * Component rendered when this inner step is active. + */ + Component: React.ComponentType; + /** + * Same semantics as `WizardStep.shouldSkip`, scoped to inner steps. + */ + shouldSkip?: (data: TData) => boolean; +} + /** * Action registered by the currently active step to be invoked when the * shared "Continue" button in the Wizard footer is clicked. @@ -75,13 +120,13 @@ export interface ContinueAction { export interface WizardContextValue { /** - * The list of steps after `shouldSkip` has been applied. This is what - * the breadcrumb and footer iterate over. + * The list of main steps after `shouldSkip` has been applied. This + * is what the breadcrumb iterates over. */ activeSteps: WizardStep[]; /** - * The step matched by the current SDK route, or `undefined` while the - * router is settling. + * The main step matched by the current SDK route, or `undefined` + * while the router is settling. */ currentStep: WizardStep | undefined; /** @@ -92,24 +137,53 @@ export interface WizardContextValue { * Convenience: `activeSteps.length`. */ totalSteps: number; + /** + * Active inner steps of the current main step (after `shouldSkip`). + * Empty when the current step has no inner steps. + */ + innerSteps: WizardInnerStep[]; + /** + * The inner step matched by the current SDK route. `undefined` when + * the current main step has no inner steps. + */ + currentInnerStep: WizardInnerStep | undefined; + /** + * Index of `currentInnerStep` within `innerSteps`. `-1` when there + * is no inner step (or none matched). + */ + currentInnerIndex: number; + /** + * Convenience: `innerSteps.length`. `0` when there are no inner steps. + */ + totalInnerSteps: number; + /** + * `true` when the user is at the very first position in the wizard + * (first main step + first inner step, if any). + */ isFirstStep: boolean; + /** + * `true` when the user is at the very last position in the wizard + * (last main step + last inner step, if any). + */ isLastStep: boolean; /** - * Navigate to the next active step. No-op on the last step. + * Navigate forward. Within a container step, advances through inner + * steps first; otherwise (or on the last inner step) advances to + * the next main step. No-op at the very end of the wizard. */ goNext: () => Promise | void; /** - * Navigate to the previous active step. No-op on the first step. + * Navigate backward. Mirror of `goNext`. */ goPrev: () => Promise | void; /** - * Jump to a specific step by `id`. No-op if the id is not in - * `activeSteps`. + * Jump to a specific main step by `id`. Lands on the step's first + * inner step when applicable. No-op if the id is not in `activeSteps`. */ goToStep: (id: string) => Promise | void; /** - * Currently registered Continue action, or `undefined` if no step has - * registered one. + * Currently registered Continue action, or `undefined` if no step + * has registered one. */ continueAction: ContinueAction | undefined; /** From da46a7cf8e2c02111012d48994d882bf657f7e25 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Mon, 4 May 2026 21:50:29 -0300 Subject: [PATCH 03/11] Query for enterprise connections --- .../components/ConfigureSSO/ConfigureSSO.tsx | 79 +++++-- .../ConfigureSSO/ConfigureSSOContext.tsx | 46 ++-- .../src/components/ConfigureSSO/constants.ts | 9 +- .../steps/ConfigureCreateAppStep.tsx | 2 +- .../steps/ConfigureMapAttributesStep.tsx | 2 +- .../ConfigureSSO/steps/ConfirmationStep.tsx | 8 +- .../ConfigureSSO/steps/ProvideEmailStep.tsx | 54 +---- .../ConfigureSSO/steps/StepLayout.tsx | 4 +- .../steps/TestConfigurationStep.tsx | 19 +- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 29 +-- .../components/ConfigureSSO/steps/index.ts | 2 +- .../ProfileCard/ProfileCardContent.tsx | 26 +-- packages/ui/src/elements/Wizard/Wizard.tsx | 216 ++++++++---------- .../ui/src/elements/Wizard/WizardContext.tsx | 17 +- packages/ui/src/elements/Wizard/types.ts | 94 ++++---- 15 files changed, 243 insertions(+), 364 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 2eaad35802e..03896cf9d71 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,9 +1,20 @@ -import { useOrganization } from '@clerk/shared/react/index'; +import { __internal_useUserEnterpriseConnections, useOrganization } from '@clerk/shared/react'; import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; import React from 'react'; import { useEnvironment, withCoreUserGuard } from '@/contexts'; -import { Box, Col, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; +import { + Box, + Col, + descriptors, + Flex, + Flow, + Icon, + localizationKeys, + Spinner, + Text, + useAppearance, +} from '@/customizables'; import { ApplicationLogo } from '@/elements/ApplicationLogo'; import { withCardStateProvider } from '@/elements/contexts'; import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; @@ -36,6 +47,11 @@ const AuthenticatedContent = withCoreUserGuard(() => { const { parsedOptions } = useAppearance(); const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); + const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } = + __internal_useUserEnterpriseConnections({ enabled: true }); + // Currently FAPI only supports one enterprise connection per user + const enterpriseConnection = enterpriseConnections?.[0]; + return ( ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} @@ -93,31 +109,58 @@ const AuthenticatedContent = withCoreUserGuard(() => { routes={[]} contentRef={contentRef} /> - - - + ({ + backgroundColor: t.colors.$colorBackground, + position: 'relative', + borderRadius: t.radii.$lg, + width: '100%', + overflow: 'hidden', + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + marginBlock: '-1px', + marginInlineEnd: '-1px', + flex: 1, + })} + > + {isLoadingEnterpriseConnections ? ( + + + + ) : ( + + + + )} + ); }); -const ConfigureSSOWizardPanel = ({ contentRef }: { contentRef: React.RefObject }) => { +const ConfigureSSOWizardPanel = () => { const data = useConfigureSSOFlow(); return ( - - - - - - - + + + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 6954e2268f8..25fda574577 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -1,5 +1,6 @@ import { useUser } from '@clerk/shared/react/index'; -import React from 'react'; +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import React, { type PropsWithChildren } from 'react'; /** * Shared form state for the ConfigureSSO wizard. Lives outside the @@ -7,50 +8,47 @@ import React from 'react'; * - it persists across step navigations (each step is its own * ``, mounted/unmounted on navigation) * - `shouldSkip` predicates on `WizardStep` can read it as plain data - * via ``. + * via `` */ export interface ConfigureSSOData { - email: string; /** - * Domain id returned by the API after the email is submitted. - * Empty until the first step succeeds. + * `true` if the user primary email address domain is already verified */ - domainId: string; + domainAlreadyVerified: boolean; /** - * `true` if the domain returned by the API is already verified at the - * time the user submits their email — the "Verify domain" step is - * skipped in that case. + * The enterprise connection from the user's primary email address domain */ - domainAlreadyVerified: boolean; + enterpriseConnection: EnterpriseConnectionResource | undefined; } -export interface ConfigureSSOContextValue extends ConfigureSSOData { - setEmail: (email: string) => void; - setDomainId: (id: string) => void; +export type ConfigureSSOContextValue = ConfigureSSOData; + +interface ConfigureSSOFlowProviderProps { + /** + * The user's enterprise connection, fetched by the parent so that + * the wizard can show a loading state before mounting the panel. + * `undefined` when the user has no enterprise connection + */ + enterpriseConnection: EnterpriseConnectionResource | undefined; } const ConfigureSSOFlowContext = React.createContext(null); ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext'; -export const ConfigureSSOFlowProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { - const [domainId, setDomainId] = React.useState(''); - +export const ConfigureSSOFlowProvider = ({ + enterpriseConnection, + children, +}: PropsWithChildren): JSX.Element => { const { user } = useUser(); - // user is guaranteed to be defined because we're using the withCoreUserGuard HOC - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const [email, setEmail] = React.useState(user!.primaryEmailAddress?.emailAddress ?? ''); const domainAlreadyVerified = user?.primaryEmailAddress?.verification.status === 'verified'; const value = React.useMemo( () => ({ - email, - domainId, + enterpriseConnection, domainAlreadyVerified, - setEmail, - setDomainId, }), - [email, domainId, domainAlreadyVerified], + [enterpriseConnection, domainAlreadyVerified], ); return {children}; diff --git a/packages/ui/src/components/ConfigureSSO/constants.ts b/packages/ui/src/components/ConfigureSSO/constants.ts index 43498d0c5bd..1a1894e5a94 100644 --- a/packages/ui/src/components/ConfigureSSO/constants.ts +++ b/packages/ui/src/components/ConfigureSSO/constants.ts @@ -6,7 +6,7 @@ import { ConfigureMapAttributes, ConfirmationStep, ProvideEmail, - TestStep, + TestConfigurationStep, VerifyDomain, } from './steps'; @@ -15,10 +15,9 @@ export const CONFIGURE_SSO_STEPS: ReadonlyArray> = id: 'verify-email-domain', path: 'verify-email-domain', label: 'Verify domain', - isOptional: true, // Skip this step when there's a primary email address domain already verified shouldSkip: data => data.domainAlreadyVerified, - steps: [ + innerSteps: [ { id: 'provide-email', path: 'provide-email', @@ -35,7 +34,7 @@ export const CONFIGURE_SSO_STEPS: ReadonlyArray> = id: 'configure', path: 'configure', label: 'Configure', - steps: [ + innerSteps: [ { id: 'create-app', path: 'create-app', @@ -52,7 +51,7 @@ export const CONFIGURE_SSO_STEPS: ReadonlyArray> = id: 'test', path: 'test', label: 'Test', - Component: TestStep, + Component: TestConfigurationStep, }, { id: 'confirmation', diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx index 8e2bce37ac6..bc00d464223 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx @@ -15,7 +15,7 @@ export const ConfigureCreateApp = (): JSX.Element => { title='Configure Okta Workforce' subtitle='Create a new enterprise application in your Okta Dashboard.' > - UI goes here + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx index b4747e45dc0..c2c98d8f5cb 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx @@ -15,7 +15,7 @@ export const ConfigureMapAttributes = (): JSX.Element => { title='Map attributes' subtitle='Map identity provider attributes to Clerk user properties.' > - UI goes here + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index f9abb17f3e0..3748a27e1f5 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -5,13 +5,7 @@ import { StepLayout } from './StepLayout'; export const ConfirmationStep = (): JSX.Element => { return ( - ({ color: theme.colors.$colorMutedForeground })} - > - UI goes here - + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx index f8fc214d35d..7323ae4c6c6 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -1,33 +1,15 @@ -import { Col, Flex, Heading, Icon, Input, Text } from '@/customizables'; +import { Text } from '@/customizables'; import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; -import { Email } from '@/icons'; -import { useConfigureSSOFlow } from '../ConfigureSSOContext'; import { StepLayout } from './StepLayout'; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -// TODO -> Conditionally render this step based on the user's email address -// If the user already has an email address, skip this step -// If the instance doesn't support email addresses, skip this step -// If the user doesn't have an email address, render this step export const ProvideEmail = (): JSX.Element => { - const { email, setEmail } = useConfigureSSOFlow(); const { goNext } = useWizard(); - const isValid = EMAIL_RE.test(email.trim()); - useRegisterContinueAction({ handler: () => { - if (!isValid) { - return; - } - - // TODO -> Call API to add email address to user - return goNext(); }, - isDisabled: !isValid, }); return ( @@ -35,39 +17,7 @@ export const ProvideEmail = (): JSX.Element => { title='Verify your domain' subtitle='Verify the domain you want to enable the enterprise connection on.' > - ({ - flex: 1, - gap: theme.space.$4, - paddingBlock: theme.space.$8, - })} - > - ({ color: theme.colors.$colorMutedForeground })} - /> - ({ gap: theme.space.$1, alignItems: 'center', textAlign: 'center' })}> - We need your email - ({ color: theme.colors.$colorMutedForeground })} - > - In order to start we will need your email address - - - setEmail(e.currentTarget.value)} - sx={theme => ({ maxWidth: theme.sizes.$60, width: '100%' })} - /> - + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx index 45efaae121d..641ec9fdeed 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx @@ -14,8 +14,7 @@ interface StepLayoutProps { * underneath. Each individual step file owns the body content. * * The Step X/Y badge is rendered via `Wizard.StepIndicator`, which - * self-hides on steps that have no inner sub-steps — so consumers - * never have to opt in/out manually. + * self-hides on steps that have no inner sub-steps */ export const StepLayout = ({ title, subtitle, children }: StepLayoutProps): JSX.Element => { return ( @@ -41,6 +40,7 @@ export const StepLayout = ({ title, subtitle, children }: StepLayoutProps): JSX. > {title} + {subtitle ? ( { title='Test your SSO connection' subtitle='Test your SSO configuration to verify you can successfully authenticate via your identity provider' > - ({ - gap: theme.space.$4, - maxWidth: theme.sizes.$160, - marginInline: 'auto', - paddingBlock: theme.space.$8, - })} - > - ({ color: theme.colors.$colorMutedForeground })} - > - UI goes here - - + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index aefa604ce29..4149e03f2a9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -1,44 +1,21 @@ -import { Col, Text } from '@/customizables'; +import { Text } from '@/customizables'; import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; -import { useConfigureSSOFlow } from '../ConfigureSSOContext'; import { StepLayout } from './StepLayout'; export const VerifyDomain = (): JSX.Element => { - const { email } = useConfigureSSOFlow(); const { goNext } = useWizard(); useRegisterContinueAction({ handler: () => goNext(), }); - const domain = email.split('@')[1] ?? ''; - return ( - ({ - gap: theme.space.$3, - maxWidth: theme.sizes.$160, - marginInline: 'auto', - paddingBlock: theme.space.$8, - })} - > - ({ color: theme.colors.$colorMutedForeground })} - > - Verification form goes here - - + UI goes here ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts index a1d0825c17f..9d5a3788249 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/index.ts +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -3,5 +3,5 @@ export { ConfigureMapAttributes } from './ConfigureMapAttributesStep'; export { ConfirmationStep } from './ConfirmationStep'; export { ProvideEmail } from './ProvideEmailStep'; export { StepLayout } from './StepLayout'; -export { TestConfigurationStep as TestStep } from './TestConfigurationStep'; +export { TestConfigurationStep } from './TestConfigurationStep'; export { VerifyDomain } from './VerifyDomainStep'; diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx index 27e55db9511..f9b075c43f0 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardContent.tsx @@ -7,15 +7,9 @@ import { common, mqu } from '../../styledSystem'; type ProfileCardContentProps = React.PropsWithChildren<{ contentRef?: React.RefObject; scrollBoxId?: string; - /** - * Disables the default padding on the inner scroll container. Useful for - * flows like the SSO configuration wizard whose internal sections (header, - * body, footer) already provide their own spacing. - */ - disablePadding?: boolean; }>; export const ProfileCardContent = (props: ProfileCardContentProps) => { - const { contentRef, children, scrollBoxId, disablePadding = false } = props; + const { contentRef, children, scrollBoxId } = props; const router = useRouter(); const scrollPosRef = React.useRef(0); @@ -59,17 +53,13 @@ export const ProfileCardContent = (props: ProfileCardContentProps) => { sx={theme => ({ flex: `1`, scrollbarGutter: 'stable', - ...(disablePadding - ? { padding: 0 } - : { - paddingTop: theme.space.$7, - paddingBottom: theme.space.$7, - paddingInlineStart: theme.space.$8, - paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter - [mqu.sm]: { - padding: `${theme.space.$8} ${theme.space.$5}`, - }, - }), + paddingTop: theme.space.$7, + paddingBottom: theme.space.$7, + paddingInlineStart: theme.space.$8, + paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter + [mqu.sm]: { + padding: `${theme.space.$8} ${theme.space.$5}`, + }, ...common.maxHeightScroller(theme), })} ref={contentRef} diff --git a/packages/ui/src/elements/Wizard/Wizard.tsx b/packages/ui/src/elements/Wizard/Wizard.tsx index 46ad12778e6..c2a8841dcd2 100644 --- a/packages/ui/src/elements/Wizard/Wizard.tsx +++ b/packages/ui/src/elements/Wizard/Wizard.tsx @@ -11,7 +11,7 @@ interface WizardRootProps { steps: ReadonlyArray>; /** * Optional data passed to each step's `shouldSkip` predicate. When the - * referenced shape changes, the active step list is recomputed. + * referenced shape changes, the active step list is recomputed */ data?: TData; children: React.ReactNode; @@ -21,126 +21,100 @@ const Root = (props: WizardRootProps): JSX.Element => { const { steps, data, children } = props; const router = useRouter(); - const activeSteps = React.useMemo(() => steps.filter(step => !step.shouldSkip?.(data as TData)), [steps, data]); + const activeSteps = React.useMemo(() => steps.filter(s => !s.shouldSkip?.(data as TData)), [steps, data]); + + const getActiveInnerSteps = React.useCallback( + (step: WizardStep | undefined): WizardInnerStep[] => + step?.innerSteps?.filter(s => !s.shouldSkip?.(data as TData)) ?? [], + [data], + ); - // Detect the current main step based on the URL. We iterate from the - // last (most specific) step back to the second one — the first step - // is the index route and always wins as a fallback. const currentStep = React.useMemo | undefined>(() => { if (activeSteps.length === 0) { return undefined; } - for (let i = activeSteps.length - 1; i >= 1; i--) { - if (router.matches(activeSteps[i].path)) { - return activeSteps[i]; - } - } - return activeSteps[0]; + + // Match the URL against non-first main steps (most-specific first), + // the first main step is mounted as the wizard's catch-all and is + // always the fallback + return ( + activeSteps + .slice(1) + .reverse() + .find(s => router.matches(s.path)) ?? activeSteps[0] + ); }, [activeSteps, router]); - // Active inner steps of the current main step, after `shouldSkip`. - const innerSteps = React.useMemo[]>(() => { - if (!currentStep?.steps) { - return []; - } - return currentStep.steps.filter(step => !step.shouldSkip?.(data as TData)); - }, [currentStep, data]); - - // Detect the current inner step within the current main step using - // the same most-specific-first scan. The first inner step is the - // index route — it matches when no deeper path is found. The first - // main step is the wizard's catch-all route (it has no URL prefix - // of its own), so its inner steps live directly under the wizard - // base; for any other main step they live under `/`. + const innerSteps = React.useMemo(() => getActiveInnerSteps(currentStep), [currentStep, getActiveInnerSteps]); + + // Builds a path relative to the wizard base. The first main step + // is the catch-all, the first inner step of any main + // step is its parent's index route (no segment) + const buildPath = React.useCallback( + (mainStep: WizardStep, innerStep?: WizardInnerStep): string => { + const inners = getActiveInnerSteps(mainStep); + const target = innerStep ?? inners[0]; + const isFirstMain = activeSteps[0]?.id === mainStep.id; + const isFirstInner = !target || inners[0]?.id === target.id; + const segments = [isFirstMain ? '' : mainStep.path, isFirstInner ? '' : target.path].filter(Boolean); + return segments.join('/') || './'; + }, + [activeSteps, getActiveInnerSteps], + ); + const currentInnerStep = React.useMemo | undefined>(() => { if (!currentStep || innerSteps.length === 0) { return undefined; } - const isFirstMain = activeSteps[0]?.id === currentStep.id; - for (let i = innerSteps.length - 1; i >= 1; i--) { - const innerPath = isFirstMain ? innerSteps[i].path : `${currentStep.path}/${innerSteps[i].path}`; - if (router.matches(innerPath)) { - return innerSteps[i]; - } - } - return innerSteps[0]; - }, [activeSteps, currentStep, innerSteps, router]); - - // Resolves a navigation target to the relative path the SDK router - // expects. The first non-skipped main step is the wizard's catch-all - // route, so it contributes no URL segment; its inner steps live - // directly under the wizard base. Other main steps live at their - // own `path`, with inner steps nested under it. The first inner - // step of any main step is its parent's index route and so lives - // at the parent's URL. - const resolveNavigation = React.useCallback( - (mainStep: WizardStep | undefined, innerStep?: WizardInnerStep): string | undefined => { - if (!mainStep) { - return undefined; - } - const isFirstMain = activeSteps[0]?.id === mainStep.id; - const innerList = mainStep.steps?.filter(s => !s.shouldSkip?.(data as TData)) ?? []; - const targetInner = innerStep ?? innerList[0]; - const isFirstInner = !targetInner || innerList[0]?.id === targetInner.id; - - const mainSegment = isFirstMain ? '' : mainStep.path; - const innerSegment = isFirstInner ? '' : targetInner.path; - const segments = [mainSegment, innerSegment].filter(Boolean); - return segments.length === 0 ? './' : segments.join('/'); - }, - [activeSteps, data], - ); + + return ( + innerSteps + .slice(1) + .reverse() + .find(s => router.matches(buildPath(currentStep, s))) ?? innerSteps[0] + ); + }, [currentStep, innerSteps, router, buildPath]); const navigateTo = React.useCallback( - (mainStep: WizardStep | undefined, innerStep?: WizardInnerStep) => { - const to = resolveNavigation(mainStep, innerStep); - if (to === undefined) { - return; - } - return router.navigate(to); - }, - [resolveNavigation, router], + (mainStep: WizardStep | undefined, innerStep?: WizardInnerStep) => + mainStep ? router.navigate(buildPath(mainStep, innerStep)) : undefined, + [router, buildPath], ); const goNext = React.useCallback(() => { if (!currentStep) { return; } - // Within a container step, advance through the inner steps first. - if (innerSteps.length > 0) { - const idx = innerSteps.findIndex(s => s.id === currentInnerStep?.id); - if (idx >= 0 && idx < innerSteps.length - 1) { - return navigateTo(currentStep, innerSteps[idx + 1]); - } + + const innerIndex = innerSteps.findIndex(s => s.id === currentInnerStep?.id); + if (innerIndex >= 0 && innerIndex < innerSteps.length - 1) { + return navigateTo(currentStep, innerSteps[innerIndex + 1]); } - // Otherwise (or after the last inner step), jump to the next main - // step. Lands on its first inner step, if any. - const mainIdx = activeSteps.findIndex(s => s.id === currentStep.id); - return navigateTo(activeSteps[mainIdx + 1]); + + const mainIndex = activeSteps.findIndex(s => s.id === currentStep.id); + return navigateTo(activeSteps[mainIndex + 1]); }, [activeSteps, currentStep, currentInnerStep, innerSteps, navigateTo]); const goPrev = React.useCallback(() => { if (!currentStep) { return; } - if (innerSteps.length > 0) { - const idx = innerSteps.findIndex(s => s.id === currentInnerStep?.id); - if (idx > 0) { - return navigateTo(currentStep, innerSteps[idx - 1]); - } + + const innerIndex = innerSteps.findIndex(s => s.id === currentInnerStep?.id); + if (innerIndex > 0) { + return navigateTo(currentStep, innerSteps[innerIndex - 1]); } - const mainIdx = activeSteps.findIndex(s => s.id === currentStep.id); - const prevMain = activeSteps[mainIdx - 1]; + + const mainIndex = activeSteps.findIndex(s => s.id === currentStep.id); + const prevMain = activeSteps[mainIndex - 1]; if (!prevMain) { return; } - // When stepping back into a container step, land on its *last* - // inner step — that's the position the user would naturally - // expect to revisit. - const prevInnerList = prevMain.steps?.filter(s => !s.shouldSkip?.(data as TData)) ?? []; - const targetInner = prevInnerList[prevInnerList.length - 1]; - return navigateTo(prevMain, targetInner); - }, [activeSteps, currentStep, currentInnerStep, data, innerSteps, navigateTo]); + + // Land on the previous main step's last inner step + const prevInners = getActiveInnerSteps(prevMain); + return navigateTo(prevMain, prevInners[prevInners.length - 1]); + }, [activeSteps, currentStep, currentInnerStep, innerSteps, navigateTo, getActiveInnerSteps]); const goToStep = React.useCallback( (id: string) => navigateTo(activeSteps.find(s => s.id === id)), @@ -163,21 +137,17 @@ const Root = (props: WizardRootProps): JSX.Element => { }; /** - * Renders the inner steps of a container step as nested routes. Falls - * back to the step's own `Component` for leaf steps. + * Renders a container step's inner sub-routes, or falls back to the + * step's own `Component` for leaf steps */ const StepRoutes = ({ step }: { step: WizardStep }): JSX.Element | null => { - if (!step.steps?.length) { - if (!step.Component) { - return null; - } - const StepComponent = step.Component; - return ; + const Component = step.Component; + if (!step.innerSteps?.length) { + return Component ? : null; } - return ( - {step.steps.map((inner, i) => { + {step.innerSteps.map((inner, i) => { const InnerComponent = inner.Component; return ( ({ step }: { step: WizardStep }): JSX.Element }; /** - * Renders the active step component via the SDK router. Non-first - * steps get `path` routes matching their `WizardStep.path`. The - * first step is mounted last as a path-less, index-less catch-all - * so that it owns the wizard's base URL *and* any deeper URL that - * doesn't belong to another main step — that's how its inner steps - * (which would otherwise live under a non-existent first-step path - * segment) end up routable. Container steps render their inner - * steps under nested routes. + * Renders the active step. Non-first main steps are routed by their + * `path`, the first main step is mounted last as a path-less, index- + * less catch-all so it owns the wizard's base URL *and* any URL that + * doesn't match another main step (e.g. its own inner-step paths) */ -const Content = (): JSX.Element => { +const Content = (): JSX.Element | null => { const { activeSteps } = useWizard(); if (activeSteps.length === 0) { - return <>; + return null; } const [firstStep, ...restSteps] = activeSteps; return ( @@ -227,7 +193,7 @@ const Content = (): JSX.Element => { /** * Numbered breadcrumb of all active steps. Completed and current steps - * are clickable for backwards navigation; future steps are disabled. + * are clickable for backwards navigation, future steps are disabled */ const Header = (): JSX.Element => { const { activeSteps, currentIndex, goToStep } = useWizard(); @@ -245,10 +211,10 @@ const Header = (): JSX.Element => { flexWrap: 'wrap', })} > - {activeSteps.map((step, idx) => { - const isCurrent = idx === currentIndex; - const isCompleted = idx < currentIndex; - const isReachable = idx <= currentIndex; + {activeSteps.map((step, index) => { + const isCurrent = index === currentIndex; + const isCompleted = index < currentIndex; + const isReachable = index <= currentIndex; const label = typeof step.label === 'string' ? step.label : t(step.label); return ( @@ -265,7 +231,6 @@ const Header = (): JSX.Element => { gap: theme.space.$1x5, padding: 0, color: isCurrent ? theme.colors.$colorForeground : theme.colors.$colorMutedForeground, - fontWeight: isCurrent ? theme.fontWeights.$semibold : theme.fontWeights.$normal, })} > { color: isCurrent ? theme.colors.$colorBackground : theme.colors.$colorMutedForeground, })} > - {idx + 1} + {index + 1} { {label} - {idx < activeSteps.length - 1 && ( + {index < activeSteps.length - 1 && ( { * Compact "Step X / Y" badge that tracks the current main step's * inner-step progress. Renders nothing when the current step has no * inner steps — that's the signal that the parent layout doesn't - * need to reserve room for it. + * need to reserve room for it */ const StepIndicator = (): JSX.Element | null => { const { totalInnerSteps, currentInnerIndex } = useWizard(); @@ -343,26 +308,26 @@ const StepIndicator = (): JSX.Element | null => { interface FooterProps { /** - * Override label for the Previous button. + * Override label for the Previous button */ previousLabel?: string; /** * Override label for the Continue button (also overridable per step - * via `setContinueAction({ label })`). + * via `setContinueAction({ label })`) */ continueLabel?: string; /** * Hides the Previous button entirely (e.g. on the first step you may * still want to keep it disabled rather than hidden — that is the - * default). + * default) */ hidePrevious?: boolean; } /** * Shared Previous / Continue footer. Continue dispatches to the - * currently registered step `ContinueAction` if any; otherwise it - * simply advances to the next step. + * currently registered step `ContinueAction` if any, otherwise it + * simply advances to the next step */ const Footer = (props: FooterProps): JSX.Element => { const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false } = props; @@ -381,6 +346,7 @@ const Footer = (props: FooterProps): JSX.Element => { void continueAction.handler(); return; } + void goNext(); }; diff --git a/packages/ui/src/elements/Wizard/WizardContext.tsx b/packages/ui/src/elements/Wizard/WizardContext.tsx index dab9b63a8e8..f86bac23861 100644 --- a/packages/ui/src/elements/Wizard/WizardContext.tsx +++ b/packages/ui/src/elements/Wizard/WizardContext.tsx @@ -29,10 +29,7 @@ export function WizardProvider(props: WizardProviderProps): JSX.El const [continueAction, setContinueAction] = React.useState(undefined); - // Reset the registered continue action whenever the active (inner) step - // changes, so that a stale handler from a previous step never lingers. - // Inner-step transitions don't change `currentStep`, so we also key - // off `currentInnerStep`. + // Clear stale continue actions when the active (inner) step changes React.useEffect(() => { setContinueAction(undefined); }, [currentStep?.id, currentInnerStep?.id]); @@ -70,14 +67,7 @@ export function WizardProvider(props: WizardProviderProps): JSX.El } /** - * Helper for step components that need to register a Continue action. - * - * The `handler` is forwarded through a ref so a fresh closure on each - * render does not retrigger the effect (which would cause an infinite - * loop, since updating the registered action re-renders consumers and - * therefore the step itself). State-shaped fields like `isDisabled` / - * `isLoading` / `label` are watched as primitives and re-publish the - * action when they change. + * Helper for step components that need to register a Continue action */ export function useRegisterContinueAction(action: ContinueAction | undefined): void { const { setContinueAction } = useWizard(); @@ -95,6 +85,7 @@ export function useRegisterContinueAction(action: ContinueAction | undefined): v setContinueAction(undefined); return; } + setContinueAction({ handler: () => handlerRef.current?.(), isDisabled, @@ -104,7 +95,7 @@ export function useRegisterContinueAction(action: ContinueAction | undefined): v }, [hasAction, isDisabled, isLoading, label, setContinueAction]); // Separate unmount-only cleanup, so dep changes above don't transiently - // clear the registered action. + // clear the registered action React.useEffect(() => { return () => setContinueAction(undefined); }, [setContinueAction]); diff --git a/packages/ui/src/elements/Wizard/types.ts b/packages/ui/src/elements/Wizard/types.ts index e72f18e4980..00ca9d71eb3 100644 --- a/packages/ui/src/elements/Wizard/types.ts +++ b/packages/ui/src/elements/Wizard/types.ts @@ -3,38 +3,32 @@ import type React from 'react'; import type { LocalizationKey } from '@/customizables'; /** - * Describes a single step in a multi-step Wizard flow. - * - * Steps are typically defined as a `const` array on the consumer side - * and passed to ``. The Wizard renders one - * step at a time, driven by the SDK router (`Route`/`Switch`). + * Describes a single step in a multi-step Wizard * * A step can either be a *leaf* (renders a single `Component`) or a - * *container* (declares an ordered list of `steps` — inner sub-steps - * the user walks through before the wizard advances to the next main - * step). Containers are routed under their parent path - * (e.g. `/configure/create-app`). + * *container* (declares an ordered list of `innerSteps`) + * Containers are routed under their parent path (e.g. `/configure/create-app`) */ export interface WizardStep { /** - * Stable identifier for the step. Used for keying and for `goToStep(id)`. + * Stable identifier for the step. Used for keying and for `goToStep(id)` */ id: string; /** * Path fragment used by the SDK router. The first non-skipped step is * automatically rendered as the index route, so its `path` is only - * used as a label for `goToStep`/deep-linking purposes. + * used as a label for `goToStep`/deep-linking purposes */ path: string; /** * Label shown in the breadcrumb at the top of the wizard. Inner - * steps don't need a label — they don't appear in the breadcrumb. + * steps don't need a label — they don't appear in the breadcrumb */ label: LocalizationKey | string; /** * The component rendered when this step is active. Required for - * leaf steps; ignored when `steps` is provided (the active inner - * step's component is rendered instead). + * leaf steps, ignored when `innerSteps` is provided (the active + * inner step's component is rendered instead) */ Component?: React.ComponentType; /** @@ -45,75 +39,67 @@ export interface WizardStep { * * Inner steps share their parent's breadcrumb entry but each get * their own URL (`/`). The first inner - * step is mounted as the parent's index route. - */ - steps?: ReadonlyArray>; - /** - * Marks the step as conditional. Purely informational — the runtime - * decision is made by `shouldSkip`. + * step is mounted as the parent's index route */ - isOptional?: boolean; + innerSteps?: ReadonlyArray>; /** - * Predicate that, when it returns `true`, removes the step from the + * When it returns `true`, removes the step from the * active list. Skipped steps are not rendered, do not appear in the * breadcrumb, and are jumped over by `goNext`/`goPrev`. * - * Receives the optional `data` value passed to ``. + * Receives the optional `data` value passed to `` */ shouldSkip?: (data: TData) => boolean; } /** * Inner sub-step of a container `WizardStep`. Inner steps are not - * shown in the breadcrumb; instead they drive the per-step indicator - * badge ("Step X / Y") and the Continue/Previous footer behaviour. + * shown in the breadcrumb, instead they drive the per-step indicator + * badge ("Step X / Y") and the Continue/Previous footer behaviour */ export interface WizardInnerStep { /** - * Stable identifier, unique within the parent step. + * Stable identifier, unique within the parent step */ id: string; /** * Path fragment relative to the parent step. The first non-skipped - * inner step is rendered at the parent's path (index route). + * inner step is rendered at the parent's path (index route) */ path: string; /** - * Component rendered when this inner step is active. + * Component rendered when this inner step is active */ Component: React.ComponentType; /** - * Same semantics as `WizardStep.shouldSkip`, scoped to inner steps. + * Same semantics as `WizardStep.shouldSkip`, scoped to inner steps */ shouldSkip?: (data: TData) => boolean; } /** * Action registered by the currently active step to be invoked when the - * shared "Continue" button in the Wizard footer is clicked. + * "Continue" button in the Wizard footer is clicked * * If no step registers a `ContinueAction`, the footer falls back to - * calling `goNext()` directly. + * calling `goNext()` directly */ export interface ContinueAction { /** * Called when the user clicks "Continue". Should typically validate / - * submit the step's form and then call `goNext()` on success. - * - * The return value is ignored — `Promise` is allowed so that - * step authors can directly forward the wizard's `goNext()` result. + * submit the step's form and then call `goNext()` on success */ handler: () => void | Promise; /** - * Disables the Continue button (e.g. while a form is invalid). + * Disables the Continue button (e.g. while a form is invalid) */ isDisabled?: boolean; /** - * Renders a loading state on the Continue button. + * Renders a loading state on the Continue button */ isLoading?: boolean; /** - * Optional override for the Continue button label. + * Optional override for the Continue button label */ label?: LocalizationKey | string; } @@ -121,76 +107,76 @@ export interface ContinueAction { export interface WizardContextValue { /** * The list of main steps after `shouldSkip` has been applied. This - * is what the breadcrumb iterates over. + * is what the breadcrumb iterates over */ activeSteps: WizardStep[]; /** * The main step matched by the current SDK route, or `undefined` - * while the router is settling. + * while the router is settling */ currentStep: WizardStep | undefined; /** - * Index of `currentStep` within `activeSteps`. `-1` if not matched. + * Index of `currentStep` within `activeSteps`. `-1` if not matched */ currentIndex: number; /** - * Convenience: `activeSteps.length`. + * Convenience: `activeSteps.length` */ totalSteps: number; /** * Active inner steps of the current main step (after `shouldSkip`). - * Empty when the current step has no inner steps. + * Empty when the current step has no inner steps */ innerSteps: WizardInnerStep[]; /** * The inner step matched by the current SDK route. `undefined` when - * the current main step has no inner steps. + * the current main step has no inner steps */ currentInnerStep: WizardInnerStep | undefined; /** * Index of `currentInnerStep` within `innerSteps`. `-1` when there - * is no inner step (or none matched). + * is no inner step (or none matched) */ currentInnerIndex: number; /** - * Convenience: `innerSteps.length`. `0` when there are no inner steps. + * Convenience: `innerSteps.length`. `0` when there are no inner steps */ totalInnerSteps: number; /** * `true` when the user is at the very first position in the wizard - * (first main step + first inner step, if any). + * (first main step + first inner step, if any) */ isFirstStep: boolean; /** * `true` when the user is at the very last position in the wizard - * (last main step + last inner step, if any). + * (last main step + last inner step, if any) */ isLastStep: boolean; /** * Navigate forward. Within a container step, advances through inner - * steps first; otherwise (or on the last inner step) advances to - * the next main step. No-op at the very end of the wizard. + * steps first, otherwise (or on the last inner step) advances to + * the next main step. No-op at the very end of the wizard */ goNext: () => Promise | void; /** - * Navigate backward. Mirror of `goNext`. + * Navigate backward. Mirror of `goNext` */ goPrev: () => Promise | void; /** * Jump to a specific main step by `id`. Lands on the step's first - * inner step when applicable. No-op if the id is not in `activeSteps`. + * inner step when applicable. No-op if the id is not in `activeSteps` */ goToStep: (id: string) => Promise | void; /** * Currently registered Continue action, or `undefined` if no step - * has registered one. + * has registered one */ continueAction: ContinueAction | undefined; /** * Used by step components to register what should happen when the * shared Continue button is pressed. Pass `undefined` to clear. * - * Typical usage: register on mount, clear on unmount. + * Typical usage: register on mount, clear on unmount */ setContinueAction: (action: ContinueAction | undefined) => void; } From 1a5e8c763f1629cdf6265d6b1ec1d8ec3a804b57 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Mon, 4 May 2026 23:42:07 -0300 Subject: [PATCH 04/11] Add changeset --- .changeset/lucky-tables-learn.md | 5 +++++ .../src/components/ConfigureSSO/ConfigureSSOContext.tsx | 9 +++------ packages/ui/src/components/ConfigureSSO/constants.ts | 4 ++-- .../ui/src/components/ConfigureSSO/steps/StepLayout.tsx | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 .changeset/lucky-tables-learn.md diff --git a/.changeset/lucky-tables-learn.md b/.changeset/lucky-tables-learn.md new file mode 100644 index 00000000000..ae4e51b2cb3 --- /dev/null +++ b/.changeset/lucky-tables-learn.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Add wizard steps for the `<__experimental_ConfigureSSO />` component diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 25fda574577..6e10395f520 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -3,12 +3,9 @@ import type { EnterpriseConnectionResource } from '@clerk/shared/types'; import React, { type PropsWithChildren } from 'react'; /** - * Shared form state for the ConfigureSSO wizard. Lives outside the - * Wizard's own context so that: - * - it persists across step navigations (each step is its own - * ``, mounted/unmounted on navigation) - * - `shouldSkip` predicates on `WizardStep` can read it as plain data - * via `` + * Shared form state for the ConfigureSSO wizard, persisted across step + * route mounts and exposed to `WizardStep.shouldSkip` via `Wizard.Root`'s + * `data` prop */ export interface ConfigureSSOData { /** diff --git a/packages/ui/src/components/ConfigureSSO/constants.ts b/packages/ui/src/components/ConfigureSSO/constants.ts index 1a1894e5a94..64fd84c759c 100644 --- a/packages/ui/src/components/ConfigureSSO/constants.ts +++ b/packages/ui/src/components/ConfigureSSO/constants.ts @@ -15,8 +15,6 @@ export const CONFIGURE_SSO_STEPS: ReadonlyArray> = id: 'verify-email-domain', path: 'verify-email-domain', label: 'Verify domain', - // Skip this step when there's a primary email address domain already verified - shouldSkip: data => data.domainAlreadyVerified, innerSteps: [ { id: 'provide-email', @@ -29,6 +27,8 @@ export const CONFIGURE_SSO_STEPS: ReadonlyArray> = Component: VerifyDomain, }, ], + // Skip this step when there's a primary email address domain already verified + shouldSkip: data => data.domainAlreadyVerified, }, { id: 'configure', diff --git a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx index 641ec9fdeed..1191cda5584 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx @@ -11,7 +11,7 @@ interface StepLayoutProps { /** * Renders the title row (with the Wizard's Step X/Y badge) on top, a divider, and the step body - * underneath. Each individual step file owns the body content. + * underneath. Each individual step file owns the body content * * The Step X/Y badge is rendered via `Wizard.StepIndicator`, which * self-hides on steps that have no inner sub-steps From 972d3527aac58af810d94be83d9a1d2d7dfaae49 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 00:09:34 -0300 Subject: [PATCH 05/11] Improve loading state --- .../components/ConfigureSSO/ConfigureSSO.tsx | 49 ++---- .../ConfigureSSO/ConfigureSSOContext.tsx | 18 ++- .../steps/ConfigureCreateAppStep.tsx | 16 +- .../steps/ConfigureMapAttributesStep.tsx | 16 +- .../ConfigureSSO/steps/ConfirmationStep.tsx | 10 +- .../ConfigureSSO/steps/ProvideEmailStep.tsx | 16 +- .../steps/TestConfigurationStep.tsx | 16 +- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 16 +- packages/ui/src/elements/Wizard/Wizard.tsx | 153 ++++++++++++------ .../ui/src/elements/Wizard/WizardContext.tsx | 11 +- packages/ui/src/elements/Wizard/types.ts | 6 + packages/ui/src/elements/contexts/index.tsx | 8 +- 12 files changed, 203 insertions(+), 132 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 03896cf9d71..77738b04ef4 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -3,18 +3,7 @@ import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; import React from 'react'; import { useEnvironment, withCoreUserGuard } from '@/contexts'; -import { - Box, - Col, - descriptors, - Flex, - Flow, - Icon, - localizationKeys, - Spinner, - Text, - useAppearance, -} from '@/customizables'; +import { Box, Col, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; import { ApplicationLogo } from '@/elements/ApplicationLogo'; import { withCardStateProvider } from '@/elements/contexts'; import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; @@ -29,13 +18,11 @@ import { CONFIGURE_SSO_STEPS } from './constants'; const ConfigureSSOInternal = () => { return ( - - - - - - - + + + + + ); }; @@ -126,23 +113,12 @@ const AuthenticatedContent = withCoreUserGuard(() => { flex: 1, })} > - {isLoadingEnterpriseConnections ? ( - - - - ) : ( - - - - )} + + + @@ -156,6 +132,7 @@ const ConfigureSSOWizardPanel = () => { diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 6e10395f520..43b246ba2ed 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -18,15 +18,17 @@ export interface ConfigureSSOData { enterpriseConnection: EnterpriseConnectionResource | undefined; } -export type ConfigureSSOContextValue = ConfigureSSOData; - -interface ConfigureSSOFlowProviderProps { +export interface ConfigureSSOContextValue extends ConfigureSSOData { /** - * The user's enterprise connection, fetched by the parent so that - * the wizard can show a loading state before mounting the panel. - * `undefined` when the user has no enterprise connection + * `true` while the parent is still fetching the user's enterprise + * connection */ + isLoading: boolean; +} + +interface ConfigureSSOFlowProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; + isLoading: boolean; } const ConfigureSSOFlowContext = React.createContext(null); @@ -34,6 +36,7 @@ ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext'; export const ConfigureSSOFlowProvider = ({ enterpriseConnection, + isLoading, children, }: PropsWithChildren): JSX.Element => { const { user } = useUser(); @@ -44,8 +47,9 @@ export const ConfigureSSOFlowProvider = ({ () => ({ enterpriseConnection, domainAlreadyVerified, + isLoading, }), - [enterpriseConnection, domainAlreadyVerified], + [enterpriseConnection, domainAlreadyVerified, isLoading], ); return {children}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx index bc00d464223..848b69a3903 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx @@ -1,4 +1,4 @@ -import { Text } from '@/customizables'; +import { Flow, Text } from '@/customizables'; import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; import { StepLayout } from './StepLayout'; @@ -11,11 +11,13 @@ export const ConfigureCreateApp = (): JSX.Element => { }); return ( - - UI goes here - + + + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx index c2c98d8f5cb..d92365a9285 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx @@ -1,4 +1,4 @@ -import { Text } from '@/customizables'; +import { Flow, Text } from '@/customizables'; import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; import { StepLayout } from './StepLayout'; @@ -11,11 +11,13 @@ export const ConfigureMapAttributes = (): JSX.Element => { }); return ( - - UI goes here - + + + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index 3748a27e1f5..0f6cbf2c49e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -1,11 +1,13 @@ -import { Text } from '@/customizables'; +import { Flow, Text } from '@/customizables'; import { StepLayout } from './StepLayout'; export const ConfirmationStep = (): JSX.Element => { return ( - - UI goes here - + + + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx index 7323ae4c6c6..5e96e87dda2 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -1,4 +1,4 @@ -import { Text } from '@/customizables'; +import { Flow, Text } from '@/customizables'; import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; import { StepLayout } from './StepLayout'; @@ -13,11 +13,13 @@ export const ProvideEmail = (): JSX.Element => { }); return ( - - UI goes here - + + + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index e3a43d0d117..31c1ab907de 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,14 +1,16 @@ -import { Text } from '@/customizables'; +import { Flow, Text } from '@/customizables'; import { StepLayout } from './StepLayout'; export const TestConfigurationStep = (): JSX.Element => { return ( - - UI goes here - + + + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 4149e03f2a9..646d178b9d4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -1,4 +1,4 @@ -import { Text } from '@/customizables'; +import { Flow, Text } from '@/customizables'; import { useRegisterContinueAction, useWizard } from '@/elements/Wizard'; import { StepLayout } from './StepLayout'; @@ -11,11 +11,13 @@ export const VerifyDomain = (): JSX.Element => { }); return ( - - UI goes here - + + + UI goes here + + ); }; diff --git a/packages/ui/src/elements/Wizard/Wizard.tsx b/packages/ui/src/elements/Wizard/Wizard.tsx index c2a8841dcd2..3b2551db26f 100644 --- a/packages/ui/src/elements/Wizard/Wizard.tsx +++ b/packages/ui/src/elements/Wizard/Wizard.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Badge, Button, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables'; +import { Badge, Box, Button, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables'; import { CaretLeft, CaretRight } from '@/icons'; import { Route, Switch, useRouter } from '@/router'; @@ -14,11 +14,17 @@ interface WizardRootProps { * referenced shape changes, the active step list is recomputed */ data?: TData; + /** + * `true` while the parent flow is still loading async dependencies. + * The header renders a skeleton breadcrumb, the content renders a + * centered spinner, and the footer's buttons are disabled + */ + isLoading?: boolean; children: React.ReactNode; } const Root = (props: WizardRootProps): JSX.Element => { - const { steps, data, children } = props; + const { steps, data, isLoading = false, children } = props; const router = useRouter(); const activeSteps = React.useMemo(() => steps.filter(s => !s.shouldSkip?.(data as TData)), [steps, data]); @@ -127,6 +133,7 @@ const Root = (props: WizardRootProps): JSX.Element => { currentStep={currentStep} innerSteps={innerSteps} currentInnerStep={currentInnerStep} + isLoading={isLoading} goNext={goNext} goPrev={goPrev} goToStep={goToStep} @@ -169,11 +176,30 @@ const StepRoutes = ({ step }: { step: WizardStep }): JSX.Element * doesn't match another main step (e.g. its own inner-step paths) */ const Content = (): JSX.Element | null => { - const { activeSteps } = useWizard(); + const { activeSteps, isLoading } = useWizard(); + + if (isLoading) { + return ( + + + + ); + } + if (activeSteps.length === 0) { return null; } + const [firstStep, ...restSteps] = activeSteps; + return ( {restSteps.map(step => ( @@ -196,7 +222,7 @@ const Content = (): JSX.Element | null => { * are clickable for backwards navigation, future steps are disabled */ const Header = (): JSX.Element => { - const { activeSteps, currentIndex, goToStep } = useWizard(); + const { activeSteps, currentIndex, isLoading, goToStep } = useWizard(); const { t } = useLocalizations(); return ( @@ -219,47 +245,51 @@ const Header = (): JSX.Element => { return ( - + ({ + width: theme.sizes.$5, + height: theme.sizes.$5, + borderRadius: theme.radii.$circle, + fontSize: theme.fontSizes.$xs, + fontWeight: theme.fontWeights.$semibold, + backgroundColor: isCurrent + ? theme.colors.$colorForeground + : isCompleted + ? theme.colors.$neutralAlpha200 + : theme.colors.$neutralAlpha100, + color: isCurrent ? theme.colors.$colorBackground : theme.colors.$colorMutedForeground, + })} + > + {index + 1} + + + {label} + + + )} {index < activeSteps.length - 1 && ( { ); }; +const SkeletonBreadcrumbStep = (): JSX.Element => ( + ({ gap: t.space.$1x5 })} + > + ({ + width: t.sizes.$5, + height: t.sizes.$5, + borderRadius: t.radii.$circle, + backgroundColor: t.colors.$neutralAlpha100, + })} + /> + ({ + width: t.sizes.$16, + height: t.space.$3, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$neutralAlpha100, + })} + /> + +); + /** * Compact "Step X / Y" badge that tracks the current main step's * inner-step progress. Renders nothing when the current step has no @@ -322,6 +376,12 @@ interface FooterProps { * default) */ hidePrevious?: boolean; + /** + * Force-disables both Previous and Continue regardless of the + * wizard's own state. Useful while async dependencies of the flow + * are still loading + */ + isDisabled?: boolean; } /** @@ -330,8 +390,9 @@ interface FooterProps { * simply advances to the next step */ const Footer = (props: FooterProps): JSX.Element => { - const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false } = props; - const { isFirstStep, isLastStep, goPrev, goNext, continueAction } = useWizard(); + const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false, isDisabled = false } = props; + const { isFirstStep, isLastStep, isLoading, goPrev, goNext, continueAction } = useWizard(); + const isForceDisabled = isDisabled || isLoading; const { t } = useLocalizations(); const continueLabelToShow = @@ -367,7 +428,7 @@ const Footer = (props: FooterProps): JSX.Element => { )} @@ -326,15 +356,15 @@ const SkeletonBreadcrumbStep = (): JSX.Element => ( ); /** - * Compact "Step X / Y" badge that tracks the current main step's - * inner-step progress. Renders nothing when the current step has no - * inner steps — that's the signal that the parent layout doesn't - * need to reserve room for it + * Compact "Step X / Y" badge that mirrors the *nearest* wizard's + * progress. Renders nothing when the nearest wizard has only one + * step (i.e. there's nothing meaningful to count). Drop this inside + * an inner wizard's step layout to surface inner-step progress */ const StepIndicator = (): JSX.Element | null => { - const { totalInnerSteps, currentInnerIndex } = useConfigureSSOWizard(); + const { totalSteps, currentIndex } = useConfigureSSOWizard(); - if (totalInnerSteps <= 0 || currentInnerIndex < 0) { + if (totalSteps <= 1 || currentIndex < 0) { return null; } @@ -350,7 +380,7 @@ const StepIndicator = (): JSX.Element | null => { as='span' sx={t => ({ fontSize: t.fontSizes.$xs })} > - Step {currentInnerIndex + 1}/{totalInnerSteps} + Step {currentIndex + 1}/{totalSteps} @@ -363,35 +393,41 @@ interface FooterProps { */ previousLabel?: string; /** - * Override label for the Continue button (also overridable per step - * via `setContinueAction({ label })`) + * Override label for the Continue button (also overridable per + * step via `useRegisterContinueAction({ label })`) */ continueLabel?: string; /** - * Hides the Previous button entirely (e.g. on the first step you may - * still want to keep it disabled rather than hidden — that is the - * default) + * Hides the Previous button entirely */ hidePrevious?: boolean; /** * Force-disables both Previous and Continue regardless of the - * wizard's own state. Useful while async dependencies of the flow - * are still loading + * wizard's own state */ isDisabled?: boolean; } /** - * Shared Previous / Continue footer. Continue dispatches to the - * currently registered step `ContinueAction` if any, otherwise it - * simply advances to the next step + * Shared Previous / Continue footer. Owned by the outermost wizard. + * Continue dispatches to the currently registered step `ContinueAction` + * if any; otherwise it advances the outermost wizard */ const Footer = (props: FooterProps): JSX.Element => { const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false, isDisabled = false } = props; - const { isFirstStep, isLastStep, isLoading, goPrev, goNext, continueAction } = useConfigureSSOWizard(); + const { isLoading } = useConfigureSSOWizard(); + const { continueAction, deepestWizardRef } = useWizardChromeRegistry(); const isForceDisabled = isDisabled || isLoading; const { t } = useLocalizations(); + // Footer-level controls always dispatch to the deepest mounted + // wizard. That way Previous from the second inner sub-step lands + // on the first inner sub-step instead of jumping out to the + // previous outer step + const deepest = deepestWizardRef.current?.current; + const isFirstStep = deepest?.isFirstStep ?? true; + const isLastStep = deepest?.isLastStep ?? true; + const continueLabelToShow = typeof continueAction?.label === 'string' ? continueAction.label @@ -405,7 +441,11 @@ const Footer = (props: FooterProps): JSX.Element => { return; } - void goNext(); + void deepestWizardRef.current?.current.goNext(); + }; + + const handlePrevious = () => { + void deepestWizardRef.current?.current.goPrev(); }; return ( @@ -426,7 +466,7 @@ const Footer = (props: FooterProps): JSX.Element => { variant='outline' size='sm' isDisabled={isForceDisabled || isFirstStep} - onClick={() => void goPrev()} + onClick={handlePrevious} > { ); }; -export const ConfigureSSOWizard = { - Root, - Header, - Content, - Footer, +/** + * Declarative wizard for the ConfigureSSO flow. + * + * Steps are written as JSX children: render a `` + * for each step and toggle visibility with regular conditional + * expressions (`{cond && ...}`). Inner sub-steps are + * declared by nesting another `` inside a step's + * body — the inner wizard's `goNext` cascades to the outer one when + * it runs out of inner steps + */ +export const ConfigureSSOWizard = Object.assign(Root, { + Step, StepIndicator, -}; +}); diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx index 301c6e74dc4..30590a77d81 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx @@ -1,86 +1,130 @@ import React from 'react'; -import type { - ConfigureSSOWizardContextValue, - ConfigureSSOWizardInnerStep, - ConfigureSSOWizardStep, - ContinueAction, -} from './types'; - -const ConfigureSSOWizardContext = React.createContext(null); +import type { ConfigureSSOWizardContextValue, ContinueAction } from './types'; + +/** + * Per-wizard context. Each `` renders one of + * these, so `useConfigureSSOWizard()` returns the *nearest* wizard. + * Inner wizards reach their parent via `React.useContext` *before* + * overriding the provider with their own value + */ +export const ConfigureSSOWizardContext = React.createContext(null); ConfigureSSOWizardContext.displayName = 'ConfigureSSOWizardContext'; +/** + * Returns the nearest ``'s API. Inner steps + * registered inside a nested wizard see the inner wizard's `goNext`, + * which itself cascades to the parent wizard on overflow + */ export function useConfigureSSOWizard(): ConfigureSSOWizardContextValue { const ctx = React.useContext(ConfigureSSOWizardContext); if (!ctx) { - throw new Error('useConfigureSSOWizard called outside of '); + throw new Error('useConfigureSSOWizard called outside of '); } return ctx; } -interface ConfigureSSOWizardProviderProps { - activeSteps: ConfigureSSOWizardStep[]; - currentStep: ConfigureSSOWizardStep | undefined; - innerSteps: ConfigureSSOWizardInnerStep[]; - currentInnerStep: ConfigureSSOWizardInnerStep | undefined; - isLoading: boolean; - goNext: ConfigureSSOWizardContextValue['goNext']; - goPrev: ConfigureSSOWizardContextValue['goPrev']; - goToStep: ConfigureSSOWizardContextValue['goToStep']; - children: React.ReactNode; +/** + * Mutable handle into a wizard's latest context value. Every wizard + * updates its own ref on every render, so consumers reading + * `ref.current` always see fresh `goNext`/`goPrev` callbacks + */ +type WizardValueRef = { current: ConfigureSSOWizardContextValue }; + +interface WizardChromeRegistry { + /** + * The currently registered Continue action, if any. Updated by + * step components via `useRegisterContinueAction` + */ + continueAction: ContinueAction | undefined; + setContinueAction: (action: ContinueAction | undefined) => void; + /** + * Marks a wizard as mounted; called by every `` + * on mount and unmount. Footer-level controls always dispatch to + * the deepest wizard in this stack + */ + pushWizard: (ref: WizardValueRef) => void; + popWizard: (ref: WizardValueRef) => void; + /** + * The deepest mounted wizard, or `undefined` if none has been + * registered yet + */ + deepestWizardRef: React.MutableRefObject; } -export function ConfigureSSOWizardProvider(props: ConfigureSSOWizardProviderProps): JSX.Element { - const { activeSteps, currentStep, innerSteps, currentInnerStep, isLoading, goNext, goPrev, goToStep, children } = - props; +/** + * Single registry shared across the entire wizard tree. Provided by + * the outermost ``; nested wizards reuse it + */ +const WizardChromeContext = React.createContext(null); +WizardChromeContext.displayName = 'ConfigureSSOWizardChromeContext'; +/** + * Mounted internally by the outermost `` + */ +export const WizardChromeProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { const [continueAction, setContinueAction] = React.useState(undefined); + const stackRef = React.useRef([]); + const deepestWizardRef = React.useRef(undefined); + + const pushWizard = React.useCallback((ref: WizardValueRef) => { + stackRef.current = [...stackRef.current, ref]; + deepestWizardRef.current = stackRef.current[stackRef.current.length - 1]; + }, []); + + const popWizard = React.useCallback((ref: WizardValueRef) => { + stackRef.current = stackRef.current.filter(r => r !== ref); + deepestWizardRef.current = stackRef.current[stackRef.current.length - 1]; + }, []); + + const value = React.useMemo( + () => ({ continueAction, setContinueAction, pushWizard, popWizard, deepestWizardRef }), + [continueAction, pushWizard, popWizard], + ); + + return {children}; +}; + +/** + * Internal accessor used by `` and the footer + */ +export function useWizardChromeRegistry(): WizardChromeRegistry { + const ctx = React.useContext(WizardChromeContext); + + if (!ctx) { + throw new Error('Wizard chrome registry is only available inside '); + } + + return ctx; +} + +/** + * Stable handle pushed/popped on mount-unmount. Wizards keep + * `valueRef.current` up to date every render so the footer reads the + * latest `goNext`/`goPrev` even after subsequent re-renders + */ +export function useRegisterWizard(value: ConfigureSSOWizardContextValue): void { + const { pushWizard, popWizard } = useWizardChromeRegistry(); + const valueRef = React.useRef(value); + valueRef.current = value; - // Clear stale continue actions when the active (inner) step changes React.useEffect(() => { - setContinueAction(undefined); - }, [currentStep?.id, currentInnerStep?.id]); - - const value = React.useMemo(() => { - const currentIndex = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1; - const currentInnerIndex = currentInnerStep ? innerSteps.findIndex(s => s.id === currentInnerStep.id) : -1; - const totalInnerSteps = innerSteps.length; - const hasInnerSteps = totalInnerSteps > 0; - - const isFirstStep = currentIndex === 0 && (!hasInnerSteps || currentInnerIndex <= 0); - const isLastStep = - currentIndex === activeSteps.length - 1 && (!hasInnerSteps || currentInnerIndex === totalInnerSteps - 1); - - return { - activeSteps, - currentStep, - currentIndex, - totalSteps: activeSteps.length, - innerSteps, - currentInnerStep, - currentInnerIndex, - totalInnerSteps, - isFirstStep, - isLastStep, - isLoading, - goNext, - goPrev, - goToStep, - continueAction, - setContinueAction, - }; - }, [activeSteps, currentStep, innerSteps, currentInnerStep, isLoading, goNext, goPrev, goToStep, continueAction]); - - return {children}; + const ref = valueRef; + pushWizard(ref); + return () => popWizard(ref); + }, [pushWizard, popWizard]); } /** - * Helper for step components that need to register a Continue action + * Helper for step components that need to register a Continue action. + * Always writes to the outermost wizard's registry, so the shared + * footer sees actions registered from arbitrarily deeply nested + * wizards */ export function useRegisterContinueAction(action: ContinueAction | undefined): void { - const { setContinueAction } = useConfigureSSOWizard(); + const { setContinueAction } = useWizardChromeRegistry(); const handlerRef = React.useRef(action?.handler); handlerRef.current = action?.handler; @@ -104,8 +148,8 @@ export function useRegisterContinueAction(action: ContinueAction | undefined): v }); }, [hasAction, isDisabled, isLoading, label, setContinueAction]); - // Separate unmount-only cleanup, so dep changes above don't transiently - // clear the registered action + // Separate unmount-only cleanup, so dep changes above don't + // transiently clear the registered action React.useEffect(() => { return () => setContinueAction(undefined); }, [setContinueAction]); diff --git a/packages/ui/src/components/ConfigureSSO/wizard/index.ts b/packages/ui/src/components/ConfigureSSO/wizard/index.ts index bacd1282a30..7497b387300 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/index.ts +++ b/packages/ui/src/components/ConfigureSSO/wizard/index.ts @@ -1,8 +1,3 @@ export { ConfigureSSOWizard } from './ConfigureSSOWizard'; export { useConfigureSSOWizard, useRegisterContinueAction } from './ConfigureSSOWizardContext'; -export type { - ConfigureSSOWizardContextValue, - ConfigureSSOWizardInnerStep, - ConfigureSSOWizardStep, - ContinueAction, -} from './types'; +export type { ConfigureSSOWizardContextValue, ConfigureSSOWizardStepProps, ContinueAction } from './types'; diff --git a/packages/ui/src/components/ConfigureSSO/wizard/types.ts b/packages/ui/src/components/ConfigureSSO/wizard/types.ts index ce3e5c67a1a..557ee696281 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/types.ts +++ b/packages/ui/src/components/ConfigureSSO/wizard/types.ts @@ -2,87 +2,40 @@ import type React from 'react'; import type { LocalizationKey } from '@/customizables'; -import type { ConfigureSSOData } from '../ConfigureSSOContext'; - -/** - * Describes a single main step in the ConfigureSSO wizard. - * - * A step can either be a *leaf* (renders a single `Component`) or a - * *container* (declares an ordered list of `innerSteps`). - * Containers are routed under their parent path (e.g. `/configure/create-app`). - */ -export interface ConfigureSSOWizardStep { - /** - * Stable identifier for the step. Used for keying and for `goToStep(id)` - */ - id: string; - /** - * Path fragment used by the SDK router. The first non-skipped step is - * automatically rendered as the index route, so its `path` is only - * used as a label for `goToStep`/deep-linking purposes - */ - path: string; - /** - * Label shown in the breadcrumb at the top of the wizard. Inner - * steps don't need a label — they don't appear in the breadcrumb - */ - label: LocalizationKey | string; - /** - * The component rendered when this step is active. Required for - * leaf steps, ignored when `innerSteps` is provided (the active - * inner step's component is rendered instead) - */ - Component?: React.ComponentType; - /** - * Optional inner sub-steps. When provided, this step is treated as - * a container: the wizard advances through the inner steps via the - * Footer's "Continue" button, then moves on to the next main step - * once the last inner step is completed. - * - * Inner steps share their parent's breadcrumb entry but each get - * their own URL (`/`). The first inner - * step is mounted as the parent's index route - */ - innerSteps?: ReadonlyArray; - /** - * When it returns `true`, removes the step from the active list. - * Skipped steps are not rendered, do not appear in the breadcrumb, - * and are jumped over by `goNext`/`goPrev`. - * - * Receives the live ConfigureSSO flow data sourced from - * `useConfigureSSOFlow()` - */ - shouldSkip?: (data: ConfigureSSOData) => boolean; -} - /** - * Inner sub-step of a container `ConfigureSSOWizardStep`. Inner steps - * are not shown in the breadcrumb, instead they drive the per-step - * indicator badge ("Step X / Y") and the Continue/Previous footer behaviour + * Props for ``. Each rendered Step is one + * navigable position in its parent ``. Inner + * sub-steps are declared by nesting another `` + * inside the Step's body */ -export interface ConfigureSSOWizardInnerStep { +export interface ConfigureSSOWizardStepProps { /** - * Stable identifier, unique within the parent step + * Stable identifier for the step. Used as a React key, for + * `goToStep(id)`, and as a fallback when two steps share a path */ id: string; /** - * Path fragment relative to the parent step. The first non-skipped - * inner step is rendered at the parent's path (index route) + * Path fragment used by the SDK router. The first non-skipped + * sibling is mounted as the parent's index route, so its `path` + * is only used for `goToStep` / deep-linking purposes */ path: string; /** - * Component rendered when this inner step is active + * Label shown in the breadcrumb at the top of the wizard. Only + * outermost steps need a label — inner steps reuse their parent's + * breadcrumb entry */ - Component: React.ComponentType; + label?: LocalizationKey | string; /** - * Same semantics as `ConfigureSSOWizardStep.shouldSkip`, scoped to inner steps + * The step body. Anything React, including a nested + * `` for inner sub-steps */ - shouldSkip?: (data: ConfigureSSOData) => boolean; + children: React.ReactNode; } /** - * Action registered by the currently active step to be invoked when the - * "Continue" button in the Wizard footer is clicked + * Action registered by the currently active step to be invoked when + * the "Continue" button in the Wizard footer is clicked * * If no step registers a `ContinueAction`, the footer falls back to * calling `goNext()` directly @@ -107,17 +60,28 @@ export interface ContinueAction { label?: LocalizationKey | string; } +/** + * Internal step descriptor extracted from a Step element's props. + * Consumers shouldn't need to construct these directly + */ +export interface ConfigureSSOWizardActiveStep { + id: string; + path: string; + label?: LocalizationKey | string; + children: React.ReactNode; +} + export interface ConfigureSSOWizardContextValue { /** - * The list of main steps after `shouldSkip` has been applied. This - * is what the breadcrumb iterates over + * The active siblings inside the *current* Wizard scope (only the + * steps that survived conditional rendering) */ - activeSteps: ConfigureSSOWizardStep[]; + activeSteps: ConfigureSSOWizardActiveStep[]; /** - * The main step matched by the current SDK route, or `undefined` - * while the router is settling + * The step matched by the current SDK route, or `undefined` while + * the router is settling */ - currentStep: ConfigureSSOWizardStep | undefined; + currentStep: ConfigureSSOWizardActiveStep | undefined; /** * Index of `currentStep` within `activeSteps`. `-1` if not matched */ @@ -127,32 +91,13 @@ export interface ConfigureSSOWizardContextValue { */ totalSteps: number; /** - * Active inner steps of the current main step (after `shouldSkip`). - * Empty when the current step has no inner steps - */ - innerSteps: ConfigureSSOWizardInnerStep[]; - /** - * The inner step matched by the current SDK route. `undefined` when - * the current main step has no inner steps - */ - currentInnerStep: ConfigureSSOWizardInnerStep | undefined; - /** - * Index of `currentInnerStep` within `innerSteps`. `-1` when there - * is no inner step (or none matched) - */ - currentInnerIndex: number; - /** - * Convenience: `innerSteps.length`. `0` when there are no inner steps - */ - totalInnerSteps: number; - /** - * `true` when the user is at the very first position in the wizard - * (first main step + first inner step, if any) + * `true` when the user is at the very first position inside *this* + * wizard scope and there is no parent wizard to fall back on */ isFirstStep: boolean; /** - * `true` when the user is at the very last position in the wizard - * (last main step + last inner step, if any) + * `true` when the user is at the very last position inside *this* + * wizard scope and there is no parent wizard to fall back on */ isLastStep: boolean; /** @@ -162,30 +107,25 @@ export interface ConfigureSSOWizardContextValue { */ isLoading: boolean; /** - * Navigate forward. Within a container step, advances through inner - * steps first, otherwise (or on the last inner step) advances to - * the next main step. No-op at the very end of the wizard + * Navigate forward. Within this wizard, advances to the next active + * sibling. On the last sibling, falls through to the parent + * wizard's `goNext` (if any) */ goNext: () => Promise | void; /** - * Navigate backward. Mirror of `goNext` + * Navigate backward. Mirror of `goNext`: previous sibling, then + * back to the parent's last sibling on overflow */ goPrev: () => Promise | void; /** - * Jump to a specific main step by `id`. Lands on the step's first - * inner step when applicable. No-op if the id is not in `activeSteps` + * Jump to a specific step by `id` within this wizard scope. No-op + * if the id is not in `activeSteps` */ goToStep: (id: string) => Promise | void; /** - * Currently registered Continue action, or `undefined` if no step - * has registered one - */ - continueAction: ContinueAction | undefined; - /** - * Used by step components to register what should happen when the - * shared Continue button is pressed. Pass `undefined` to clear. - * - * Typical usage: register on mount, clear on unmount + * `true` when this wizard is rendered inside another wizard. The + * outermost wizard owns the breadcrumb / footer chrome; nested + * wizards render only the active step's body */ - setContinueAction: (action: ContinueAction | undefined) => void; + isNested: boolean; } From a3ab0143280919bba43d85dcc5c5abde4f7c3cba Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 18:02:14 -0300 Subject: [PATCH 08/11] Add guard for user --- packages/clerk-js/src/core/clerk.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index ce01dbe24a9..72314c2cc7a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -74,9 +74,9 @@ import type { AuthenticateWithSolanaParams, BillingNamespace, CheckoutSignalValue, - Clerk as ClerkInterface, ClerkAPIError, ClerkAuthenticateWithWeb3Params, + Clerk as ClerkInterface, ClerkOptions, ClientJSONSnapshot, ClientResource, @@ -1457,6 +1457,15 @@ export class Clerk implements ClerkInterface { return; } + if (noUserExists(this)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotOpenCheckout, { + code: CANNOT_RENDER_USER_MISSING_ERROR_CODE, + }); + } + return; + } + this.assertComponentsReady(this.#clerkUI); const component = 'ConfigureSSO'; void this.#clerkUI From 551b23a0e1a5b71e1e3191daff7410dae590a8ac Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 18:10:21 -0300 Subject: [PATCH 09/11] Always display verify domain step --- packages/clerk-js/src/core/clerk.ts | 2 +- .../components/ConfigureSSO/ConfigureSSO.tsx | 64 +++++-------- .../ConfigureSSO/ConfigureSSOContext.tsx | 16 +--- .../steps/ConfigureMapAttributesStep.tsx | 23 ----- .../components/ConfigureSSO/steps/index.ts | 1 - .../wizard/ConfigureSSOWizard.tsx | 93 ++++++++++--------- 6 files changed, 74 insertions(+), 125 deletions(-) delete mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 72314c2cc7a..0cf46d9e98a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -74,9 +74,9 @@ import type { AuthenticateWithSolanaParams, BillingNamespace, CheckoutSignalValue, + Clerk as ClerkInterface, ClerkAPIError, ClerkAuthenticateWithWeb3Params, - Clerk as ClerkInterface, ClerkOptions, ClientJSONSnapshot, ClientResource, diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index ec93c953a6f..142cf07196f 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,4 +1,4 @@ -import { __internal_useUserEnterpriseConnections, useOrganization } from '@clerk/shared/react'; +import { __internal_useUserEnterpriseConnections, useOrganization, useUser } from '@clerk/shared/react'; import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; import React from 'react'; @@ -11,15 +11,8 @@ import { ProfileCard } from '@/elements/ProfileCard'; import { BoxIcon } from '@/icons'; import { Route, Switch } from '@/router'; -import { ConfigureSSOFlowProvider, useConfigureSSOFlow } from './ConfigureSSOContext'; -import { - ConfigureCreateApp, - ConfigureMapAttributes, - ConfirmationStep, - ProvideEmail, - TestConfigurationStep, - VerifyDomain, -} from './steps'; +import { ConfigureSSOFlowProvider } from './ConfigureSSOContext'; +import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomain } from './steps'; import { ConfigureSSOWizard } from './wizard'; const ConfigureSSOInternal = () => { @@ -132,60 +125,47 @@ const AuthenticatedContent = withCoreUserGuard(() => { ); }); -/** - * The full ConfigureSSO step tree, declared inline. Each - * `` is one breadcrumb entry; nested - * `` blocks declare inner sub-step routing. - * - * Conditional rendering on a step (`{cond && }`) skips - * it from the breadcrumb and from `goNext`/`goPrev` traversal — no - * `shouldSkip` predicate needed - */ const ConfigureSSOSteps = () => { - const { domainAlreadyVerified } = useConfigureSSOFlow(); + const { user } = useUser(); + const primaryEmailAddress = user?.primaryEmailAddress; return ( - {!domainAlreadyVerified && ( - - + + + {!primaryEmailAddress && ( - - - - - - )} + )} + + + + + + {/* TODO: Implement configure steps */} - - - ): JSX.Element => { - const { user } = useUser(); - - const domainAlreadyVerified = user?.primaryEmailAddress?.verification.status === 'verified'; - const value = React.useMemo( () => ({ enterpriseConnection, - domainAlreadyVerified, isLoading, }), - [enterpriseConnection, domainAlreadyVerified, isLoading], + [enterpriseConnection, isLoading], ); return {children}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx deleted file mode 100644 index b50e977322e..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureMapAttributesStep.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Flow, Text } from '@/customizables'; - -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; - -export const ConfigureMapAttributes = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); - - useRegisterContinueAction({ - handler: () => goNext(), - }); - - return ( - - - UI goes here - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts index 9d5a3788249..d54944febbe 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/index.ts +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -1,5 +1,4 @@ export { ConfigureCreateApp } from './ConfigureCreateAppStep'; -export { ConfigureMapAttributes } from './ConfigureMapAttributesStep'; export { ConfirmationStep } from './ConfirmationStep'; export { ProvideEmail } from './ProvideEmailStep'; export { StepLayout } from './StepLayout'; diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx index 26fb227fbc3..c294b56a13d 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx @@ -18,11 +18,6 @@ import type { ConfigureSSOWizardStepProps, } from './types'; -/** - * Marker component for a single step in ``. The - * parent wizard reads its props directly off the JSX element; the - * component itself never renders independently - */ const Step = (_: ConfigureSSOWizardStepProps): JSX.Element | null => null; Step.displayName = 'ConfigureSSOWizard.Step'; @@ -32,15 +27,16 @@ interface RootProps { /** * Walks the wizard's children and returns the descriptors for every - * `` element. Anything else (`false` from - * `&&`, plain text, fragments, etc.) is silently ignored, so - * conditional rendering in JSX naturally drives "skipping" + * `` element */ function extractSteps(children: React.ReactNode): ConfigureSSOWizardActiveStep[] { const steps: ConfigureSSOWizardActiveStep[] = []; React.Children.forEach(children, child => { - if (!React.isValidElement(child)) return; + if (!React.isValidElement(child)) { + return; + } + // Tolerate fragments at the top level (e.g. when users factor a // group of steps into a helper component that returns one) if (child.type === React.Fragment) { @@ -48,7 +44,11 @@ function extractSteps(children: React.ReactNode): ConfigureSSOWizardActiveStep[] steps.push(...extractSteps(fragmentChildren)); return; } - if (child.type !== Step) return; + + if (child.type !== Step) { + return; + } + const props = child.props as ConfigureSSOWizardStepProps; steps.push({ id: props.id, @@ -103,11 +103,14 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El const activeSteps = React.useMemo(() => extractSteps(children), [children]); - // Match the URL against non-first steps (most-specific first); the + // Match the URL against non-first steps (most-specific first), the // first step is mounted as the index route and is always the // fallback when nothing else matches const currentStep = React.useMemo(() => { - if (activeSteps.length === 0) return undefined; + if (activeSteps.length === 0) { + return undefined; + } + return ( activeSteps .slice(1) @@ -130,22 +133,30 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El ); const goNext = React.useCallback(() => { - if (!currentStep) return; - const idx = activeSteps.findIndex(s => s.id === currentStep.id); - const next = activeSteps[idx + 1]; - if (next) return navigateTo(next); - // End of *this* wizard's siblings → cascade to the parent so - // the outer wizard advances past us to the next outer step + if (!currentStep) { + return; + } + + const index = activeSteps.findIndex(s => s.id === currentStep.id); + const next = activeSteps[index + 1]; + if (next) { + return navigateTo(next); + } + return parentWizard?.goNext(); }, [activeSteps, currentStep, navigateTo, parentWizard]); const goPrev = React.useCallback(() => { - if (!currentStep) return; - const idx = activeSteps.findIndex(s => s.id === currentStep.id); - const prev = activeSteps[idx - 1]; - if (prev) return navigateTo(prev); - // At the first sibling — defer to the parent so the user can - // step back into the previous outer step's last position + if (!currentStep) { + return; + } + + const index = activeSteps.findIndex(s => s.id === currentStep.id); + const prev = activeSteps[index - 1]; + if (prev) { + return navigateTo(prev); + } + return parentWizard?.goPrev(); }, [activeSteps, currentStep, navigateTo, parentWizard]); @@ -155,21 +166,19 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El ); const value = React.useMemo(() => { - const idx = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1; + const index = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1; return { activeSteps, currentStep, - currentIndex: idx, + currentIndex: index, totalSteps: activeSteps.length, - // First/last only count as edges when there's nothing past us - // to fall back on (otherwise the parent wizard absorbs the move) - isFirstStep: idx <= 0 && !parentWizard, - isLastStep: idx === activeSteps.length - 1 && !parentWizard, isLoading, goNext, goPrev, goToStep, isNested, + isFirstStep: index <= 0 && !parentWizard, + isLastStep: index === activeSteps.length - 1 && !parentWizard, }; }, [activeSteps, currentStep, isLoading, goNext, goPrev, goToStep, isNested, parentWizard]); @@ -181,13 +190,10 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El const body = ; if (isNested) { - // Nested wizards plug into the parent's chrome; they only own - // the inner routing for their step's body return {body}; } - // Outermost wizard owns the full layout: breadcrumb on top, the - // step body in the middle, the shared footer at the bottom + // Outermost wizard owns the full layout return (
@@ -198,15 +204,15 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El }; /** - * Renders the active step's body (or a spinner while async deps are - * loading). Each step is mounted at its `path`; the first step is the - * index/catch-all so the wizard's base URL renders something + * Renders the active step's body */ const Body = ({ activeSteps }: { activeSteps: ConfigureSSOWizardActiveStep[] }): JSX.Element | null => { const { isLoading, isNested } = useConfigureSSOWizard(); if (isLoading) { - if (isNested) return null; + if (isNested) { + return null; + } return ( ( /** * Compact "Step X / Y" badge that mirrors the *nearest* wizard's * progress. Renders nothing when the nearest wizard has only one - * step (i.e. there's nothing meaningful to count). Drop this inside - * an inner wizard's step layout to surface inner-step progress + * step */ const StepIndicator = (): JSX.Element | null => { const { totalSteps, currentIndex } = useConfigureSSOWizard(); @@ -499,10 +504,10 @@ const Footer = (props: FooterProps): JSX.Element => { * * Steps are written as JSX children: render a `` * for each step and toggle visibility with regular conditional - * expressions (`{cond && ...}`). Inner sub-steps are - * declared by nesting another `` inside a step's - * body — the inner wizard's `goNext` cascades to the outer one when - * it runs out of inner steps + * expressions (`{cond && ...}`) + * + * Inner sub-steps are declared by nesting another `` inside + * a step's body */ export const ConfigureSSOWizard = Object.assign(Root, { Step, From 78d6cc483f4e6d832de41a0618073baa3e5eb0ca Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Tue, 5 May 2026 19:00:41 -0300 Subject: [PATCH 10/11] Add element descriptors --- .../wizard/ConfigureSSOWizard.tsx | 35 ++++++++++++++++--- .../wizard/ConfigureSSOWizardContext.tsx | 17 ++------- .../src/customizables/elementDescriptors.ts | 10 ++++++ packages/ui/src/internal/appearance.ts | 10 ++++++ 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx index c294b56a13d..369ec29bd98 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Badge, Box, Button, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables'; +import { Badge, Box, Button, Col, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables'; import { CaretLeft, CaretRight } from '@/icons'; import { Route, Switch, useRouter } from '@/router'; @@ -241,14 +241,26 @@ const Body = ({ activeSteps }: { activeSteps: ConfigureSSOWizardActiveStep[] }): key={step.id} path={step.path} > - {step.children} + ))} - {firstStep.children} + + + ); }; +const StepBody = ({ step }: { step: ConfigureSSOWizardActiveStep }): JSX.Element => ( + + {step.children} + +); + /** * Numbered breadcrumb of the outermost wizard's active steps. * Completed and current steps are clickable for backwards navigation, @@ -260,6 +272,7 @@ const Header = (): JSX.Element => { return ( ({ gap: theme.space.$2, @@ -282,6 +295,9 @@ const Header = (): JSX.Element => { ) : ( )}