Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stepper API: add initialize method and deprecate useSteps #97999

Merged
merged 33 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4bb327e
Add `bootFlow` method
alshakero Jan 6, 2025
44bce7d
Add `types` changes
alshakero Jan 6, 2025
11e3d7b
Add `index.tsx` change
alshakero Jan 6, 2025
d62342f
Add example flow
alshakero Jan 6, 2025
4b3088c
Add possibility to return false
alshakero Jan 6, 2025
c6a69bb
Update README
alshakero Jan 6, 2025
ec108c4
Types and example flow.
alshakero Jan 6, 2025
3d3a754
Types
alshakero Jan 6, 2025
f669d28
Types
alshakero Jan 6, 2025
3454512
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 6, 2025
f29bb5f
Remove extraneous `await`
alshakero Jan 7, 2025
d6a7d99
Merge branch 'fix/use-steps-hook' of github.com:Automattic/wp-calypso…
alshakero Jan 7, 2025
b45bee2
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 7, 2025
50d10f2
Rename to `initialize`
alshakero Jan 9, 2025
f860739
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 9, 2025
8e6336f
Merge branch 'fix/use-steps-hook' of github.com:Automattic/wp-calypso…
alshakero Jan 9, 2025
c0dab5e
README
alshakero Jan 9, 2025
81c4c7b
Cache the flow steps
alshakero Jan 9, 2025
a546417
Types
alshakero Jan 9, 2025
838a0b6
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 10, 2025
9d2d115
Update the example flow
alshakero Jan 13, 2025
de3dc75
Merge branch 'fix/use-steps-hook' of github.com:Automattic/wp-calypso…
alshakero Jan 13, 2025
082bfbd
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 13, 2025
44a492e
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 13, 2025
cbb653b
Clean up
alshakero Jan 13, 2025
94a83c0
Make `getSteps` optional
alshakero Jan 13, 2025
3dcd18f
Fix code comment
alshakero Jan 13, 2025
95b3755
Address feedback
alshakero Jan 14, 2025
d45d4a9
Fix readme
escapemanuele Jan 15, 2025
3ad0b3a
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 21, 2025
fb4882f
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 21, 2025
55c8e3e
Remove `checklistSlug`
alshakero Jan 21, 2025
fd5c5d4
Merge branch 'trunk' into fix/use-steps-hook
alshakero Jan 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading