/** * External dependencies */ import { createMachine, assign } from 'xstate'; import { useMachine } from '@xstate/react'; import { useEffect } from '@wordpress/element'; import { ExtensionList } from '@woocommerce/data'; /** * Internal dependencies */ import { IntroOptIn } from './pages/IntroOptIn'; import { UserProfile } from './pages/UserProfile'; import { BusinessInfo } from './pages/BusinessInfo'; import { BusinessLocation } from './pages/BusinessLocation'; // TODO: Typescript support can be improved, but for now lets write the types ourselves // https://stately.ai/blog/introducing-typescript-typegen-for-xstate export type IntroOptInEvent = | { type: 'INTRO_COMPLETED'; payload: { optInDataSharing: boolean } } // can be true or false depending on whether the user opted in or not | { type: 'INTRO_SKIPPED'; payload: { optInDataSharing: false } }; // always false for now export type UserProfileEvent = | { type: 'USER_PROFILE_COMPLETED'; payload: { userProfile: CoreProfilerStateMachineContext[ 'userProfile' ]; }; // TODO: fill in the types for this when developing this page } | { type: 'USER_PROFILE_SKIPPED'; payload: { userProfile: { skipped: true } }; }; export type BusinessInfoEvent = { type: 'BUSINESS_INFO_COMPLETED'; payload: { businessInfo: CoreProfilerStateMachineContext[ 'businessInfo' ]; }; }; export type BusinessLocationEvent = { type: 'BUSINESS_LOCATION_COMPLETED'; payload: { businessInfo: CoreProfilerStateMachineContext[ 'businessInfo' ]; }; }; export type ExtensionsEvent = { type: 'EXTENSIONS_COMPLETED'; payload: { extensionsSelected: CoreProfilerStateMachineContext[ 'extensionsSelected' ]; }; }; export type CoreProfilerStateMachineContext = { optInDataSharing: boolean; userProfile: { foo: { bar: 'qux' }; skipped: false } | { skipped: true }; geolocatedLocation: { location: string; }; extensionsAvailable: ExtensionList[ 'plugins' ] | []; extensionsSelected: string[]; // extension slugs businessInfo: { foo?: { bar: 'qux' }; location: string }; }; const Extensions = ( { context, sendEvent, }: { context: CoreProfilerStateMachineContext; sendEvent: ( payload: ExtensionsEvent ) => void; } ) => { return ( // TOOD: we need to fetch the extensions list from the API as part of initializing the profiler <>
Extensions
{ context.extensionsAvailable }
); }; const coreProfilerStateMachineDefinition = createMachine( { id: 'coreProfiler', initial: 'initializing', context: { // these are safe default values if for some reason the steps fail to complete correctly // actual defaults displayed to the user should be handled in the steps themselves optInDataSharing: false, userProfile: { skipped: true }, geolocatedLocation: { location: 'US:CA' }, businessInfo: { location: 'US:CA' }, extensionsAvailable: [], extensionsSelected: [], } as CoreProfilerStateMachineContext, states: { initializing: { on: { INITIALIZATION_COMPLETE: 'introOptIn', }, invoke: [ { src: 'initializeProfiler', }, ], meta: { progress: 0, }, }, introOptIn: { on: { INTRO_COMPLETED: { target: 'userProfile', actions: [ assign( { optInDataSharing: ( _context, event: IntroOptInEvent ) => event.payload.optInDataSharing, // sets context.optInDataSharing to the payload of the event } ), ], }, INTRO_SKIPPED: { // if the user skips the intro, we set the optInDataSharing to false and go to the Business Location page target: 'skipFlowBusinessLocation', actions: [ assign( { optInDataSharing: ( context, event: IntroOptInEvent ) => event.payload.optInDataSharing, // sets context.optInDataSharing to the payload of the event, which is always false } ), ], }, }, meta: { progress: 20, component: IntroOptIn, }, }, userProfile: { on: { USER_PROFILE_COMPLETED: { target: 'preBusinessInfo', actions: [ assign( { userProfile: ( context, event: UserProfileEvent ) => event.payload.userProfile, // sets context.userProfile to the payload of the event } ), ], }, USER_PROFILE_SKIPPED: { target: 'preBusinessInfo', actions: [ assign( { userProfile: ( context, event: UserProfileEvent ) => event.payload.userProfile, // assign context.userProfile to the payload of the event } ), ], }, }, meta: { progress: 40, component: UserProfile, }, }, preBusinessInfo: { always: [ // immediately transition to businessInfo without any events as long as geolocation parallel has completed { target: 'businessInfo', cond: () => true, // TODO: use a custom function to check on the parallel state using meta when we implement that. https://xstate.js.org/docs/guides/guards.html#guards-condition-functions }, ], meta: { progress: 50, }, }, businessInfo: { on: { BUSINESS_INFO_COMPLETED: { target: 'preExtensions', actions: [ assign( { businessInfo: ( _context, event: BusinessInfoEvent ) => event.payload.businessInfo, // assign context.businessInfo to the payload of the event } ), ], }, }, meta: { progress: 60, component: BusinessInfo, }, }, skipFlowBusinessLocation: { on: { BUSINESS_LOCATION_COMPLETED: { target: 'settingUpStore', actions: [ assign( { businessInfo: ( _context, event: BusinessLocationEvent ) => event.payload.businessInfo, // assign context.businessInfo to the payload of the event } ), ], }, }, meta: { progress: 80, component: BusinessLocation, }, }, preExtensions: { always: [ // immediately transition to extensions without any events as long as extensions fetching parallel has completed { target: 'extensions', cond: () => true, // TODO: use a custom function to check on the parallel state using meta when we implement that. https://xstate.js.org/docs/guides/guards.html#guards-condition-functions }, ], // add exit action to filter the extensions using a custom function here and assign it to context.extensionsAvailable exit: assign( { extensionsAvailable: ( context ) => { return context.extensionsAvailable.filter( () => true ); }, // TODO : define an extensible filter function here } ), meta: { progress: 70, }, }, extensions: { on: { EXTENSIONS_COMPLETED: { target: 'settingUpStore', }, }, meta: { progress: 80, component: Extensions, }, }, settingUpStore: {}, }, } ); const CoreProfilerController = ( {} ) => { const [ state, send ] = useMachine( coreProfilerStateMachineDefinition.withConfig( { services: { initializeProfiler: () => ( sendBack ) => { // TODO: placeholder to simulate initialization time const interval = setInterval( () => { sendBack( { type: 'INITIALIZATION_COMPLETE', } ); }, 1000 ); return () => { clearInterval( interval ); }; }, }, } ), { devTools: true } ); const currentNodeMeta = state.meta[ `coreProfiler.${ state.value }` ] ? state.meta[ `coreProfiler.${ state.value }` ] : undefined; const navigationProgress = currentNodeMeta?.progress; // This value is defined in each state node's meta tag, we can assume it is 0-100 const CurrentComponent = currentNodeMeta?.component ?? ( () =>
Insert Spinner
); // If no component is defined for the state then its a loading state useEffect( () => { document.body.classList.remove( 'woocommerce-admin-is-loading' ); document.body.classList.add( 'woocommerce-profile-wizard__body' ); document.body.classList.add( 'woocommerce-admin-full-screen' ); document.body.classList.add( 'is-wp-toolbar-disabled' ); return () => { document.body.classList.remove( 'woocommerce-profile-wizard__body' ); document.body.classList.remove( 'woocommerce-admin-full-screen' ); document.body.classList.remove( 'is-wp-toolbar-disabled' ); }; } ); return ( <>
Core Profiler Placeholder
{ `${ navigationProgress }% complete` }
{ `Current State: ${ state.value }` }
{ `Context values: ${ JSON.stringify( state.context, null, 2 ) }` }
{ }
); }; export default CoreProfilerController;