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

View File

@ -8,7 +8,7 @@ import { useState, useEffect } from '@wordpress/element';
*/
import { CoreProfilerStateMachineContext } from '..';
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 = {
title: string;

View File

@ -1897,7 +1897,7 @@ Object {
/>
<div>
<div
class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipFlowBusinessLocation"
class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipGuidedSetup"
>
<div
class="woocommerce-profiler-business-location"
@ -2049,7 +2049,7 @@ Object {
</body>,
"container": <div>
<div
class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipFlowBusinessLocation"
class="woocommerce-profile-wizard__container woocommerce-profile-wizard__step-skipGuidedSetup"
>
<div
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
*/
import LightBulbImage from './assets/images/loader-lightbulb.svg';
import DevelopingImage from './assets/images/loader-developing.svg';
import LayoutImage from './assets/images/loader-layout.svg';
import LightBulbImage from '../assets/images/loader-lightbulb.svg';
import DevelopingImage from '../assets/images/loader-developing.svg';
import LayoutImage from '../assets/images/loader-layout.svg';
import { Stages } from './pages/Loader';
import { Stages } from '../pages/Loader';
const LightbulbStage = {
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