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:
parent
532f3ca3f8
commit
1b1f86066f
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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' ),
|
|
@ -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,
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
|
||||
Refactored core profiler state machine by modularising each page
|
Loading…
Reference in New Issue