Skip to content

Commit

Permalink
Stepper API: add initialize method and deprecate useSteps (#97999)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Grullon <[email protected]>
Co-authored-by: Emanuele Buccelli <[email protected]>
  • Loading branch information
3 people authored Jan 22, 2025
1 parent 5e6ce23 commit d0c13e7
Show file tree
Hide file tree
Showing 16 changed files with 377 additions and 75 deletions.
42 changes: 9 additions & 33 deletions client/landing/stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The stepper framework is a new framework for quickly spinning up sign-up flows.

## Non-linearity

It has been tricky for us to create flows with input-driven steps configuration. Stepper makes it easy by allowing flows to create their own two hooks `useSteps`, and `useStepNavigation`. These hooks have access to the state of the flow so they can make decisions based on that.
It has been tricky for us to create flows with input-driven steps configuration. Stepper makes it easy by using `useStepNavigation`. This hook has access to the state of the flow so it makes navigation decisions based on it at every stage of the flow.

### Example flow

Expand All @@ -15,8 +15,8 @@ import type { StepPath } from './internals/steps-repository';
import type { Flow } from './internals/types';

export const exampleFlow: Flow = {
useSteps(): Array< StepPath > {
return [ 'domain', 'design' ];
initialize() {
return [ STEPS.DOMAINS, STEPS.DESIGN ];
},
useStepNavigation( currentStep, navigate ) {
const goBack = () => {
Expand All @@ -34,14 +34,13 @@ export const exampleFlow: Flow = {

## The API

To create a flow, you only have to implement `useSteps` and `useStepNavigation`. `useSteps` just returns an array of step keys, `useStepNavigation` is the engine where you make navigation decisions. This hook returns an object of type [`NavigationControls`](./declarative-flow/internals/types.ts):
To create a flow, you only have to implement `initialize` and `useStepNavigation`. `initialize` can do any checks you need and it should finally return an array of step objects, `useStepNavigation` is the engine where you make navigation decisions. This hook returns an object of type [`NavigationControls`](./declarative-flow/internals/types.ts):

There is also an optional `useSideEffect` hook. You can implement this hook to run any side-effects to the flow. You can prefetch information, send track events when something changes, etc...

There is a required `isSignupFlow` flag that _MUST be `true` for signup flows_ (generally where a new site may be created), and should be `false` for other flows. The `isSignupFlow` flag controls whether we'll trigger a `calypso_signup_start` Tracks event when the flow starts. For signup flows, you can also supply additional event props to the `calypso_signup_start` event by implementing the optional `useTracksEventProps()` hook on the flow.

```tsx
// prettier-ignore
/**
* This is the return type of useStepNavigation hook
*/
Expand All @@ -68,44 +67,21 @@ export type NavigationControls = {
Since this is a hook, it can access any state from any store, so you can make dynamic navigation decisions based on the state. [Here](./declarative-flow/site-setup-flow.ts) is a developed example of this hook.

```ts
import type { StepPath } from './internals/steps-repository';
import type { Flow } from './internals/types';

export const exampleFlow: Flow = {
useSteps(): Array< StepPath > {
return [];
initialize() {
return [
STEPS.INTRO,
STEPS.DOMAINS
];
},
useStepNavigation( currentStep, navigate ) {
return { goNext, goBack };
},
};
```

### Assert Conditions

Optionally, you could also define a `useAssertConditions` function in the flow. This function can be used to add some conditions to check, and maybe return an error if those are not met.

```ts
import type { StepPath } from './internals/steps-repository';
import type { Flow } from './internals/types';

export const exampleFlow: Flow = {
useSteps(): Array< StepPath > {
return [];
},
useStepNavigation( currentStep, navigate ) {
return { goNext, goBack };
},
useAssertConditions() {
const siteSlug = useSiteSlugParam();

if ( ! siteSlug ) {
throw new Error( 'site-setup did not provide the site slug it is configured to.' );
}
},
};
```

## Reusability

Stepper aims to create a big `steps-repository` that contains the steps and allows them to be recycled and reused. Every step you create is inherently reusable by any future flow. Because steps are like components, they're not parts of the flows, flows just happen to use them.
Expand Down
175 changes: 175 additions & 0 deletions client/landing/stepper/declarative-flow/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Onboard, OnboardActions, updateLaunchpadSettings } from '@automattic/data-stores';
import { EXAMPLE_FLOW } from '@automattic/onboarding';
import { dispatch } from '@wordpress/data';
import { addQueryArgs } from '@wordpress/url';
import { translate } from 'i18n-calypso';
import { useLaunchpadDecider } from 'calypso/landing/stepper/declarative-flow/internals/hooks/use-launchpad-decider';
import { useQuery } from 'calypso/landing/stepper/hooks/use-query';
import { skipLaunchpad } from 'calypso/landing/stepper/utils/skip-launchpad';
import { triggerGuidesForStep } from 'calypso/lib/guides/trigger-guides-for-step';
import {
clearSignupDestinationCookie,
setSignupCompleteSlug,
persistSignupDestination,
setSignupCompleteFlowName,
} from 'calypso/signup/storageUtils';
import { useExitFlow } from '../hooks/use-exit-flow';
import { useSiteIdParam } from '../hooks/use-site-id-param';
import { useSiteSlug } from '../hooks/use-site-slug';
import { ONBOARD_STORE } from '../stores';
import { getQuery } from '../utils/get-query';
import { stepsWithRequiredLogin } from '../utils/steps-with-required-login';
import { STEPS } from './internals/steps';
import { ProvidedDependencies } from './internals/types';
import type { Flow } from './internals/types';

const newsletter: Flow = {
name: EXAMPLE_FLOW,
get title() {
return translate( 'Newsletter Example Flow' );
},
isSignupFlow: true,
initialize() {
const query = getQuery();
const isComingFromMarketingPage = query[ 'ref' ] === 'newsletter-lp';

const { setHidePlansFeatureComparison, setIntent } = dispatch(
ONBOARD_STORE
) as OnboardActions;

// We can just call these. They're guaranteed to run once.
setHidePlansFeatureComparison( true );
clearSignupDestinationCookie();
setIntent( Onboard.SiteIntent.Newsletter );

const privateSteps = stepsWithRequiredLogin( [
STEPS.NEWSLETTER_SETUP,
STEPS.NEWSLETTER_GOALS,
STEPS.DOMAINS,
STEPS.PLANS,
STEPS.PROCESSING,
STEPS.SUBSCRIBERS,
STEPS.SITE_CREATION_STEP,
STEPS.LAUNCHPAD,
] );

if ( ! isComingFromMarketingPage ) {
return [ STEPS.INTRO, ...privateSteps ];
}

return privateSteps;
},

useStepNavigation( _currentStep, navigate ) {
const flowName = this.name;
const siteId = useSiteIdParam();
const siteSlug = useSiteSlug();
const query = useQuery();
const { exitFlow } = useExitFlow();
const isComingFromMarketingPage = query.get( 'ref' ) === 'newsletter-lp';

const { getPostFlowUrl, initializeLaunchpadState } = useLaunchpadDecider( {
exitFlow,
navigate,
} );

const completeSubscribersTask = async () => {
if ( siteSlug ) {
await updateLaunchpadSettings( siteSlug, {
checklist_statuses: { subscribers_added: true },
} );
}
};

triggerGuidesForStep( flowName, _currentStep );

function submit( providedDependencies: ProvidedDependencies = {} ) {
const launchpadUrl = `/setup/${ flowName }/launchpad?siteSlug=${ providedDependencies.siteSlug }`;

switch ( _currentStep ) {
case 'intro':
return navigate( 'newsletterSetup' );

case 'newsletterSetup':
return navigate( 'newsletterGoals' );

case 'newsletterGoals':
return navigate( 'domains' );

case 'domains':
return navigate( 'plans' );

case 'plans':
return navigate( 'createSite' );

case 'createSite':
return navigate( 'processing' );

case 'processing':
if ( providedDependencies?.goToHome && providedDependencies?.siteSlug ) {
return window.location.replace(
addQueryArgs( `/home/${ siteId ?? providedDependencies?.siteSlug }`, {
celebrateLaunch: true,
launchpadComplete: true,
} )
);
}

if ( providedDependencies?.goToCheckout && providedDependencies?.siteSlug ) {
persistSignupDestination( launchpadUrl );
setSignupCompleteSlug( providedDependencies?.siteSlug );
setSignupCompleteFlowName( flowName );

return window.location.assign(
`/checkout/${ encodeURIComponent(
providedDependencies?.siteSlug as string
) }?redirect_to=${ encodeURIComponent( launchpadUrl ) }&signup=1`
);
}

initializeLaunchpadState( {
siteId: providedDependencies?.siteId as number,
siteSlug: providedDependencies?.siteSlug as string,
} );

return window.location.assign(
getPostFlowUrl( {
flow: flowName,
siteId: providedDependencies?.siteId as number,
siteSlug: providedDependencies?.siteSlug as string,
} )
);

case 'subscribers':
completeSubscribersTask();
return navigate( 'launchpad' );
}
}

const goBack = () => {
return;
};

const goNext = async () => {
switch ( _currentStep ) {
case 'launchpad':
skipLaunchpad( {
siteId,
siteSlug,
} );
return;

default:
return navigate( isComingFromMarketingPage ? 'newsletterSetup' : 'intro' );
}
};

const goToStep = ( step: string ) => {
navigate( step );
};

return { goNext, goBack, goToStep, submit };
},
};

export default newsletter;
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export const useFlowNavigation = ( flow: Flow ): FlowNavigation => {
const match = useMatch( '/:flow/:step?/:lang?' );
const { step: currentStepSlug = null, lang = null } = match?.params || {};
const [ currentSearchParams ] = useSearchParams();
const steps = 'useSteps' in flow ? flow.useSteps() : flow.__flowSteps ?? [];
const flowName = flow.variantSlug ?? flow.name;
const steps = flow.useSteps();
const isLoggedIn = useSelector( isUserLoggedIn );
const stepsSlugs = steps.map( ( step ) => step.slug );
const locale = useFlowLocale();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const useStepNavigationWithTracking = ( {
flow,
currentStepRoute,
navigate,
}: Params< ReturnType< Flow[ 'useSteps' ] > > ) => {
}: Params< StepperStep[] > ) => {
const stepNavigation = flow.useStepNavigation( currentStepRoute, navigate );
const { intent, goals } = useSelect( ( select ) => {
const onboardStore = select( ONBOARD_STORE ) as OnboardSelect;
Expand Down
10 changes: 8 additions & 2 deletions client/landing/stepper/declarative-flow/internals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,18 @@ function flowStepComponent( flowStep: StepperStep | undefined ) {
* 3. It's responsive to the dynamic changes in side the flow's hooks (useSteps and useStepsNavigation)
* @param props
* @param props.flow the flow you want to render
* @param props.steps the steps of the flow.
* @returns A React router switch will all the routes
*/
export const FlowRenderer: React.FC< { flow: Flow } > = ( { flow } ) => {
export const FlowRenderer: React.FC< { flow: Flow; steps: readonly StepperStep[] | null } > = ( {
flow,
steps,
} ) => {
// Configure app element that React Modal will aria-hide when modal is open
Modal.setAppElement( '#wpcom' );
const flowSteps = flow.useSteps();
const deprecatedFlowSteps = 'useSteps' in flow ? flow.useSteps() : null;
const flowSteps = steps ?? deprecatedFlowSteps ?? [];

const stepPaths = flowSteps.map( ( step ) => step.slug );
const firstStepSlug = useFirstStep( stepPaths );
const { navigate, params } = useFlowNavigation( flow );
Expand Down
20 changes: 20 additions & 0 deletions client/landing/stepper/declarative-flow/internals/steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ export const STEPS = {
asyncComponent: () => import( './steps-repository/migration-error' ),
},

NEWSLETTER_SETUP: {
slug: 'newsletterSetup',
asyncComponent: () => import( './steps-repository/newsletter-setup' ),
},

NEWSLETTER_GOALS: {
slug: 'newsletterGoals',
asyncComponent: () => import( './steps-repository/newsletter-goals' ),
},

SUBSCRIBERS: {
slug: 'subscribers',
asyncComponent: () => import( './steps-repository/subscribers' ),
},

FREE_POST_SETUP: {
slug: 'freePostSetup',
asyncComponent: () => import( './steps-repository/free-post-setup' ),
Expand Down Expand Up @@ -124,6 +139,11 @@ export const STEPS = {
asyncComponent: () => import( './steps-repository/intent-step' ),
},

INTRO: {
slug: 'intro',
asyncComponent: () => import( './steps-repository/intro' ),
},

NEW_OR_EXISTING_SITE: {
slug: 'new-or-existing-site',
asyncComponent: () => import( './steps-repository/new-or-existing-site' ),
Expand Down
Loading

0 comments on commit d0c13e7

Please sign in to comment.