dev: refactor core profiler pages (#38606)

* dev: refactor core-profiler - modularise each page

- wrapped each page's pre, post, and main states into their top level states for tidiness
- tagged them with id so that we can easily jump to them when doing routing
- generalised component finder code such that it recursively traverses the state meta object until it finds a component key
- fixed css label to use top level state key

* moved initializing into introOptIn so it's not a special case by itself
This commit is contained in:
RJ 2023-06-07 13:39:38 +10:00 committed by GitHub
parent 532f3ca3f8
commit 1b1f86066f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 734 additions and 559 deletions

View File

@ -3,7 +3,7 @@
* External dependencies * External dependencies
*/ */
import { createMachine, assign, DoneInvokeEvent, actions, spawn } from 'xstate'; import { createMachine, assign, DoneInvokeEvent, actions, spawn } from 'xstate';
import { useMachine } from '@xstate/react'; import { useMachine, useSelector } from '@xstate/react';
import { useEffect, useMemo } from '@wordpress/element'; import { useEffect, useMemo } from '@wordpress/element';
import { resolveSelect, dispatch } from '@wordpress/data'; import { resolveSelect, dispatch } from '@wordpress/data';
import { import {
@ -48,6 +48,7 @@ import {
} from './services/installAndActivatePlugins'; } from './services/installAndActivatePlugins';
import { ProfileSpinner } from './components/profile-spinner/profile-spinner'; import { ProfileSpinner } from './components/profile-spinner/profile-spinner';
import recordTracksActions from './actions/tracks'; import recordTracksActions from './actions/tracks';
import { findComponentMeta } from './utils/find-component';
export type InitializationCompleteEvent = { export type InitializationCompleteEvent = {
type: 'INITIALIZATION_COMPLETE'; type: 'INITIALIZATION_COMPLETE';
@ -458,7 +459,7 @@ const coreProfilerMachineServices = {
}; };
export const coreProfilerStateMachineDefinition = createMachine( { export const coreProfilerStateMachineDefinition = createMachine( {
id: 'coreProfiler', id: 'coreProfiler',
initial: 'initializing', initial: 'introOptIn',
predictableActionArguments: true, // recommended setting: https://xstate.js.org/docs/guides/actions.html predictableActionArguments: true, // recommended setting: https://xstate.js.org/docs/guides/actions.html
context: { context: {
// these are safe default values if for some reason the steps fail to complete correctly // these are safe default values if for some reason the steps fail to complete correctly
@ -480,7 +481,11 @@ export const coreProfilerStateMachineDefinition = createMachine( {
onboardingProfile: {} as OnboardingProfile, onboardingProfile: {} as OnboardingProfile,
} as CoreProfilerStateMachineContext, } as CoreProfilerStateMachineContext,
states: { states: {
initializing: { introOptIn: {
id: 'introOptIn',
initial: 'preIntroOptIn',
states: {
preIntroOptIn: {
entry: [ entry: [
// these prefetch tasks are spawned actors in the background and do not block progression of the state machine // these prefetch tasks are spawned actors in the background and do not block progression of the state machine
'preFetchGetPlugins', 'preFetchGetPlugins',
@ -506,7 +511,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
src: 'getAllowTrackingOption', src: 'getAllowTrackingOption',
onDone: [ onDone: [
{ {
actions: [ 'handleTrackingOption' ], actions: [
'handleTrackingOption',
],
target: 'done', target: 'done',
}, },
], ],
@ -523,7 +530,6 @@ export const coreProfilerStateMachineDefinition = createMachine( {
}, },
onDone: { onDone: {
target: 'introOptIn', target: 'introOptIn',
// TODO: at this point, we can handle the URL path param if any and jump to the correct page
}, },
meta: { meta: {
progress: 0, progress: 0,
@ -532,7 +538,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
introOptIn: { introOptIn: {
on: { on: {
INTRO_COMPLETED: { INTRO_COMPLETED: {
target: 'preUserProfile', target: '#userProfile',
actions: [ actions: [
'assignOptInDataSharing', 'assignOptInDataSharing',
'updateTrackingOption', 'updateTrackingOption',
@ -540,7 +546,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
}, },
INTRO_SKIPPED: { INTRO_SKIPPED: {
// if the user skips the intro, we set the optInDataSharing to false and go to the Business Location page // if the user skips the intro, we set the optInDataSharing to false and go to the Business Location page
target: 'preSkipFlowBusinessLocation', target: '#skipGuidedSetup',
actions: [ actions: [
'assignOptInDataSharing', 'assignOptInDataSharing',
'updateTrackingOption', 'updateTrackingOption',
@ -548,7 +554,10 @@ export const coreProfilerStateMachineDefinition = createMachine( {
}, },
}, },
entry: [ entry: [
{ type: 'recordTracksStepViewed', step: 'store_details' }, {
type: 'recordTracksStepViewed',
step: 'store_details',
},
], ],
exit: actions.choose( [ exit: actions.choose( [
{ {
@ -557,7 +566,8 @@ export const coreProfilerStateMachineDefinition = createMachine( {
actions: 'recordTracksIntroCompleted', actions: 'recordTracksIntroCompleted',
}, },
{ {
cond: ( _context, event ) => event.type === 'INTRO_SKIPPED', cond: ( _context, event ) =>
event.type === 'INTRO_SKIPPED',
actions: [ actions: [
{ {
type: 'recordTracksStepSkipped', type: 'recordTracksStepSkipped',
@ -571,6 +581,12 @@ export const coreProfilerStateMachineDefinition = createMachine( {
component: IntroOptIn, component: IntroOptIn,
}, },
}, },
},
},
userProfile: {
id: 'userProfile',
initial: 'preUserProfile',
states: {
preUserProfile: { preUserProfile: {
invoke: { invoke: {
src: 'getOnboardingProfileOption', src: 'getOnboardingProfileOption',
@ -589,8 +605,15 @@ export const coreProfilerStateMachineDefinition = createMachine( {
}, },
}, },
userProfile: { userProfile: {
meta: {
progress: 40,
component: UserProfile,
},
entry: [ entry: [
{ type: 'recordTracksStepViewed', step: 'user_profile' }, {
type: 'recordTracksStepViewed',
step: 'user_profile',
},
'preFetchGeolocation', 'preFetchGeolocation',
], ],
on: { on: {
@ -598,8 +621,10 @@ export const coreProfilerStateMachineDefinition = createMachine( {
target: 'postUserProfile', target: 'postUserProfile',
actions: [ actions: [
assign( { assign( {
userProfile: ( context, event: UserProfileEvent ) => userProfile: (
event.payload.userProfile, // sets context.userProfile to the payload of the event context,
event: UserProfileEvent
) => event.payload.userProfile, // sets context.userProfile to the payload of the event
} ), } ),
], ],
}, },
@ -607,8 +632,10 @@ export const coreProfilerStateMachineDefinition = createMachine( {
target: 'postUserProfile', target: 'postUserProfile',
actions: [ actions: [
assign( { assign( {
userProfile: ( context, event: UserProfileEvent ) => userProfile: (
event.payload.userProfile, // assign context.userProfile to the payload of the event context,
event: UserProfileEvent
) => event.payload.userProfile, // assign context.userProfile to the payload of the event
} ), } ),
], ],
}, },
@ -630,10 +657,6 @@ export const coreProfilerStateMachineDefinition = createMachine( {
], ],
}, },
] ), ] ),
meta: {
progress: 40,
component: UserProfile,
},
}, },
postUserProfile: { postUserProfile: {
invoke: { invoke: {
@ -641,13 +664,19 @@ export const coreProfilerStateMachineDefinition = createMachine( {
return updateOnboardingProfileOption( context ); return updateOnboardingProfileOption( context );
}, },
onDone: { onDone: {
target: 'preBusinessInfo', target: '#businessInfo',
}, },
onError: { onError: {
target: 'preBusinessInfo', target: '#businessInfo',
}, },
}, },
}, },
},
},
businessInfo: {
id: 'businessInfo',
initial: 'preBusinessInfo',
states: {
preBusinessInfo: { preBusinessInfo: {
type: 'parallel', type: 'parallel',
states: { states: {
@ -690,7 +719,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
src: 'getStoreCountryOption', src: 'getStoreCountryOption',
onDone: [ onDone: [
{ {
actions: [ 'handleStoreCountryOption' ], actions: [
'handleStoreCountryOption',
],
target: 'done', target: 'done',
}, },
], ],
@ -712,7 +743,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
src: 'getStoreNameOption', src: 'getStoreNameOption',
onDone: [ onDone: [
{ {
actions: [ 'handleStoreNameOption' ], actions: [
'handleStoreNameOption',
],
target: 'done', target: 'done',
}, },
], ],
@ -748,28 +781,34 @@ export const coreProfilerStateMachineDefinition = createMachine( {
onDone: { onDone: {
target: 'businessInfo', target: 'businessInfo',
}, },
meta: {
progress: 50,
},
}, },
businessInfo: { businessInfo: {
meta: {
progress: 60,
component: BusinessInfo,
},
entry: [ entry: [
{ type: 'recordTracksStepViewed', step: 'business_info' }, {
type: 'recordTracksStepViewed',
step: 'business_info',
},
], ],
on: { on: {
BUSINESS_INFO_COMPLETED: { BUSINESS_INFO_COMPLETED: {
target: 'prePlugins', target: '#plugins',
actions: [ actions: [
'persistBusinessInfo', 'persistBusinessInfo',
'recordTracksBusinessInfoCompleted', 'recordTracksBusinessInfoCompleted',
], ],
}, },
}, },
meta: {
progress: 60,
component: BusinessInfo,
}, },
}, },
},
skipGuidedSetup: {
id: 'skipGuidedSetup',
initial: 'preSkipFlowBusinessLocation',
states: {
preSkipFlowBusinessLocation: { preSkipFlowBusinessLocation: {
invoke: { invoke: {
src: 'getCountries', src: 'getCountries',
@ -791,12 +830,13 @@ export const coreProfilerStateMachineDefinition = createMachine( {
actions: [ actions: [
assign( { assign( {
businessInfo: ( businessInfo: (
context, _context,
event: BusinessLocationEvent event: BusinessLocationEvent
) => { ) => {
return { return {
...context.businessInfo, ..._context.businessInfo,
location: event.payload.storeLocation, location:
event.payload.storeLocation,
}; };
}, },
} ), } ),
@ -873,13 +913,20 @@ export const coreProfilerStateMachineDefinition = createMachine( {
component: Loader, component: Loader,
}, },
}, },
},
},
plugins: {
id: 'plugins',
initial: 'prePlugins',
states: {
prePlugins: { prePlugins: {
invoke: { invoke: {
src: 'getPlugins', src: 'getPlugins',
onDone: [ onDone: [
{ {
target: 'pluginsSkipped', target: 'pluginsSkipped',
cond: ( context, event ) => event.data.length === 0, cond: ( context, event ) =>
event.data.length === 0,
}, },
{ target: 'plugins', actions: 'handlePlugins' }, { target: 'plugins', actions: 'handlePlugins' },
], ],
@ -887,7 +934,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
// add exit action to filter the extensions using a custom function here and assign it to context.extensionsAvailable // add exit action to filter the extensions using a custom function here and assign it to context.extensionsAvailable
exit: assign( { exit: assign( {
pluginsAvailable: ( context ) => { pluginsAvailable: ( context ) => {
return context.pluginsAvailable.filter( () => true ); return context.pluginsAvailable.filter(
() => true
);
}, // TODO : define an extensible filter function here }, // TODO : define an extensible filter function here
} ), } ),
meta: { meta: {
@ -902,7 +951,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
} ), } ),
invoke: { invoke: {
src: () => { src: () => {
dispatch( ONBOARDING_STORE_NAME ).updateProfileItems( { dispatch(
ONBOARDING_STORE_NAME
).updateProfileItems( {
plugins_page_skipped: true, plugins_page_skipped: true,
completed: true, completed: true,
} ); } );
@ -917,7 +968,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
}, },
}, },
plugins: { plugins: {
entry: [ { type: 'recordTracksStepViewed', step: 'plugins' } ], entry: [
{ type: 'recordTracksStepViewed', step: 'plugins' },
],
on: { on: {
PLUGINS_PAGE_SKIPPED: { PLUGINS_PAGE_SKIPPED: {
actions: [ actions: [
@ -978,7 +1031,8 @@ export const coreProfilerStateMachineDefinition = createMachine( {
event: PluginInstalledAndActivatedEvent event: PluginInstalledAndActivatedEvent
) => { ) => {
const progress = Math.round( const progress = Math.round(
( event.payload.installedPluginIndex / ( event.payload
.installedPluginIndex /
event.payload.pluginsCount ) * event.payload.pluginsCount ) *
100 100
); );
@ -1004,17 +1058,24 @@ export const coreProfilerStateMachineDefinition = createMachine( {
target: 'prePlugins', target: 'prePlugins',
actions: [ actions: [
assign( { assign( {
pluginsInstallationErrors: ( _context, event ) => pluginsInstallationErrors: (
event.payload.errors, _context,
event
) => event.payload.errors,
} ), } ),
( _context, event ) => { ( _context, event ) => {
recordEvent( recordEvent(
'storeprofiler_store_extensions_installed_and_activated', 'storeprofiler_store_extensions_installed_and_activated',
{ {
success: false, success: false,
failed_extensions: event.payload.errors.map( failed_extensions:
( error: PluginInstallError ) => event.payload.errors.map(
getPluginTrackKey( error.plugin ) (
error: PluginInstallError
) =>
getPluginTrackKey(
error.plugin
)
), ),
} }
); );
@ -1026,7 +1087,8 @@ export const coreProfilerStateMachineDefinition = createMachine( {
actions: [ actions: [
( _context, event ) => { ( _context, event ) => {
const installationCompletedResult = const installationCompletedResult =
event.payload.installationCompletedResult; event.payload
.installationCompletedResult;
const trackData: { const trackData: {
success: boolean; success: boolean;
@ -1041,7 +1103,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
success: true, success: true,
installed_extensions: installed_extensions:
installationCompletedResult.installedPlugins.map( installationCompletedResult.installedPlugins.map(
( installedPlugin: InstalledPlugin ) => (
installedPlugin: InstalledPlugin
) =>
getPluginTrackKey( getPluginTrackKey(
installedPlugin.plugin installedPlugin.plugin
) )
@ -1057,7 +1121,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
getPluginTrackKey( getPluginTrackKey(
installedPlugin.plugin installedPlugin.plugin
) )
] = getTimeFrame( installedPlugin.installTime ); ] = getTimeFrame(
installedPlugin.installTime
);
} }
recordEvent( recordEvent(
@ -1083,6 +1149,8 @@ export const coreProfilerStateMachineDefinition = createMachine( {
component: Loader, component: Loader,
}, },
}, },
},
},
settingUpStore: {}, settingUpStore: {},
}, },
} ); } );
@ -1108,20 +1176,24 @@ export const CoreProfilerController = ( {
} ); } );
}, [ actionOverrides, servicesOverrides ] ); }, [ actionOverrides, servicesOverrides ] );
const [ state, send ] = useMachine( augmentedStateMachine, { const [ state, send, service ] = useMachine( augmentedStateMachine, {
devTools: process.env.NODE_ENV === 'development', devTools: process.env.NODE_ENV === 'development',
} ); } );
const stateValue =
typeof state.value === 'object' // eslint-disable-next-line react-hooks/exhaustive-deps -- false positive due to function name match, this isn't from react std lib
? Object.keys( state.value )[ 0 ] const currentNodeMeta = useSelector( service, ( currentState ) =>
: state.value; findComponentMeta( currentState?.meta ?? undefined )
const currentNodeMeta = state.meta[ `coreProfiler.${ stateValue }` ] );
? state.meta[ `coreProfiler.${ stateValue }` ]
: undefined; const navigationProgress = currentNodeMeta?.progress;
const navigationProgress = currentNodeMeta?.progress; // This value is defined in each state node's meta tag, we can assume it is 0-100
const CurrentComponent = const CurrentComponent =
currentNodeMeta?.component ?? ( () => <ProfileSpinner /> ); // If no component is defined for the state then its a loading state currentNodeMeta?.component ?? ( () => <ProfileSpinner /> ); // If no component is defined for the state then its a loading state
const currentNodeCssLabel =
state.value instanceof Object
? Object.keys( state.value )[ 0 ]
: state.value;
useEffect( () => { useEffect( () => {
document.body.classList.remove( 'woocommerce-admin-is-loading' ); document.body.classList.remove( 'woocommerce-admin-is-loading' );
document.body.classList.add( 'woocommerce-profile-wizard__body' ); document.body.classList.add( 'woocommerce-profile-wizard__body' );
@ -1139,7 +1211,7 @@ export const CoreProfilerController = ( {
return ( return (
<> <>
<div <div
className={ `woocommerce-profile-wizard__container woocommerce-profile-wizard__step-${ state.value }` } className={ `woocommerce-profile-wizard__container woocommerce-profile-wizard__step-${ currentNodeCssLabel }` }
> >
{ {
<CurrentComponent <CurrentComponent

View File

@ -8,7 +8,7 @@ import { useState, useEffect } from '@wordpress/element';
*/ */
import { CoreProfilerStateMachineContext } from '..'; import { CoreProfilerStateMachineContext } from '..';
import ProgressBar from '../components/progress-bar/progress-bar'; import ProgressBar from '../components/progress-bar/progress-bar';
import { getLoaderStageMeta } from '../get-loader-stage-meta'; import { getLoaderStageMeta } from '../utils/get-loader-stage-meta';
export type Stage = { export type Stage = {
title: string; title: string;

View File

@ -1897,7 +1897,7 @@ Object {
/> />
<div> <div>
<div <div
class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipFlowBusinessLocation" class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipGuidedSetup"
> >
<div <div
class="woocommerce-profiler-business-location" class="woocommerce-profiler-business-location"
@ -2049,7 +2049,7 @@ Object {
</body>, </body>,
"container": <div> "container": <div>
<div <div
class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipFlowBusinessLocation" class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipGuidedSetup"
> >
<div <div
class="woocommerce-profiler-business-location" class="woocommerce-profiler-business-location"

View File

@ -0,0 +1,39 @@
/**
* Internal dependencies
*/
import { CoreProfilerStateMachineContext } from '..';
export type ComponentMeta = {
/** React component that is rendered when state matches the location this meta key is defined */
component: ( arg0: ComponentProps ) => JSX.Element;
/** number between 0 - 100 */
progress: number;
};
export type ComponentProps = {
navigationProgress: number | undefined;
sendEvent: unknown;
context: CoreProfilerStateMachineContext;
};
/**
* Does a depth-first search of a meta object to find the first instance of a component.
*/
export function findComponentMeta(
obj: Record< string, unknown >
): ComponentMeta | undefined {
for ( const key in obj ) {
if ( key === 'component' ) {
return obj as ComponentMeta;
} else if ( typeof obj[ key ] === 'object' && obj[ key ] !== null ) {
const found = findComponentMeta(
obj[ key ] as Record< string, unknown >
);
if ( found !== undefined ) {
return found;
}
}
}
return undefined;
}

View File

@ -6,11 +6,11 @@ import { __ } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import LightBulbImage from './assets/images/loader-lightbulb.svg'; import LightBulbImage from '../assets/images/loader-lightbulb.svg';
import DevelopingImage from './assets/images/loader-developing.svg'; import DevelopingImage from '../assets/images/loader-developing.svg';
import LayoutImage from './assets/images/loader-layout.svg'; import LayoutImage from '../assets/images/loader-layout.svg';
import { Stages } from './pages/Loader'; import { Stages } from '../pages/Loader';
const LightbulbStage = { const LightbulbStage = {
title: __( 'Turning on the lights', 'woocommerce' ), title: __( 'Turning on the lights', 'woocommerce' ),

View File

@ -0,0 +1,60 @@
/**
* Internal dependencies
*/
import { findComponentMeta, ComponentProps } from '../find-component';
describe( 'findComponentMeta', () => {
it( 'should return the whole object once "component" key is found in a nested object', () => {
const obj: Record< string, unknown > = {
'coreProfiler.skipGuidedSetup.skipFlowBusinessLocation': {
component: ( props: ComponentProps ) => (
<div>{ props.context }</div>
),
progress: 50,
},
};
const result = findComponentMeta( obj );
expect( result ).toEqual( {
component: expect.any( Function ),
progress: 50,
} );
} );
it( 'should return undefined if no "component" key is present', () => {
const obj: Record< string, unknown > = {
a: 1,
b: {
key: 'value',
},
c: 2,
};
const result = findComponentMeta( obj );
expect( result ).toBeUndefined();
} );
it( 'should handle deeply nested objects', () => {
const obj: Record< string, unknown > = {
a: 1,
b: {
key: 'value',
nested: {
anotherKey: 'anotherValue',
component: ( props: ComponentProps ) => (
<div>{ props.context }</div>
),
progress: 100,
},
},
c: 2,
};
const result = findComponentMeta( obj );
expect( result ).toEqual( {
anotherKey: 'anotherValue',
component: expect.any( Function ),
progress: 100,
} );
} );
} );

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Refactored core profiler state machine by modularising each page