From 1b1f86066f7670a6ab863b374f27d05f8fdc1a99 Mon Sep 17 00:00:00 2001 From: RJ <27843274+rjchow@users.noreply.github.com> Date: Wed, 7 Jun 2023 13:39:38 +1000 Subject: [PATCH] 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 --- .../client/core-profiler/index.tsx | 1176 +++++++++-------- .../client/core-profiler/pages/Loader.tsx | 2 +- .../core-profiler-machine.test.tsx.snap | 4 +- .../core-profiler/utils/find-component.tsx | 39 + .../{ => utils}/get-loader-stage-meta.tsx | 8 +- .../utils/test/find-component.test.tsx | 60 + .../dev-refactor-core-profiler-pages | 4 + 7 files changed, 734 insertions(+), 559 deletions(-) create mode 100644 plugins/woocommerce-admin/client/core-profiler/utils/find-component.tsx rename plugins/woocommerce-admin/client/core-profiler/{ => utils}/get-loader-stage-meta.tsx (86%) create mode 100644 plugins/woocommerce-admin/client/core-profiler/utils/test/find-component.test.tsx create mode 100644 plugins/woocommerce/changelog/dev-refactor-core-profiler-pages diff --git a/plugins/woocommerce-admin/client/core-profiler/index.tsx b/plugins/woocommerce-admin/client/core-profiler/index.tsx index a34b4ec5988..328fd14c993 100644 --- a/plugins/woocommerce-admin/client/core-profiler/index.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/index.tsx @@ -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,380 +481,469 @@ export const coreProfilerStateMachineDefinition = createMachine( { onboardingProfile: {} as OnboardingProfile, } as CoreProfilerStateMachineContext, states: { - initializing: { - entry: [ - // these prefetch tasks are spawned actors in the background and do not block progression of the state machine - 'preFetchGetPlugins', - 'preFetchGetCountries', - { - type: 'preFetchOptions', - options: [ - 'blogname', - 'woocommerce_onboarding_profile', - 'woocommerce_default_country', - ], - }, - ], - type: 'parallel', + introOptIn: { + id: 'introOptIn', + initial: 'preIntroOptIn', states: { - // if we have any other init tasks to do in parallel, add them as a parallel state here. - // this blocks the introOptIn UI from loading keep that in mind when adding new tasks here - trackingOption: { - initial: 'fetching', + preIntroOptIn: { + entry: [ + // these prefetch tasks are spawned actors in the background and do not block progression of the state machine + 'preFetchGetPlugins', + 'preFetchGetCountries', + { + type: 'preFetchOptions', + options: [ + 'blogname', + 'woocommerce_onboarding_profile', + 'woocommerce_default_country', + ], + }, + ], + type: 'parallel', states: { - fetching: { - invoke: { - src: 'getAllowTrackingOption', - onDone: [ - { - actions: [ 'handleTrackingOption' ], - target: 'done', + // if we have any other init tasks to do in parallel, add them as a parallel state here. + // this blocks the introOptIn UI from loading keep that in mind when adding new tasks here + trackingOption: { + initial: 'fetching', + states: { + fetching: { + invoke: { + src: 'getAllowTrackingOption', + onDone: [ + { + actions: [ + 'handleTrackingOption', + ], + target: 'done', + }, + ], + onError: { + target: 'done', // leave it as initialised default on error + }, }, - ], - onError: { - target: 'done', // leave it as initialised default on error + }, + done: { + type: 'final', }, }, }, - done: { - type: 'final', - }, + }, + onDone: { + target: 'introOptIn', + }, + meta: { + progress: 0, }, }, - }, - 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, - }, - }, - introOptIn: { - on: { - INTRO_COMPLETED: { - target: 'preUserProfile', - actions: [ - 'assignOptInDataSharing', - 'updateTrackingOption', - ], - }, - INTRO_SKIPPED: { - // if the user skips the intro, we set the optInDataSharing to false and go to the Business Location page - target: 'preSkipFlowBusinessLocation', - actions: [ - 'assignOptInDataSharing', - 'updateTrackingOption', - ], - }, - }, - entry: [ - { type: 'recordTracksStepViewed', step: 'store_details' }, - ], - exit: actions.choose( [ - { - cond: ( _context, event ) => - event.type === 'INTRO_COMPLETED', - actions: 'recordTracksIntroCompleted', - }, - { - cond: ( _context, event ) => event.type === 'INTRO_SKIPPED', - actions: [ + introOptIn: { + on: { + INTRO_COMPLETED: { + target: '#userProfile', + actions: [ + 'assignOptInDataSharing', + 'updateTrackingOption', + ], + }, + INTRO_SKIPPED: { + // if the user skips the intro, we set the optInDataSharing to false and go to the Business Location page + target: '#skipGuidedSetup', + actions: [ + 'assignOptInDataSharing', + 'updateTrackingOption', + ], + }, + }, + entry: [ { - type: 'recordTracksStepSkipped', + type: 'recordTracksStepViewed', step: 'store_details', }, ], - }, - ] ), - meta: { - progress: 20, - component: IntroOptIn, - }, - }, - preUserProfile: { - invoke: { - src: 'getOnboardingProfileOption', - onDone: [ - { - actions: [ - 'handleOnboardingProfileOption', - 'assignOnboardingProfile', - ], - target: 'userProfile', + exit: actions.choose( [ + { + cond: ( _context, event ) => + event.type === 'INTRO_COMPLETED', + actions: 'recordTracksIntroCompleted', + }, + { + cond: ( _context, event ) => + event.type === 'INTRO_SKIPPED', + actions: [ + { + type: 'recordTracksStepSkipped', + step: 'store_details', + }, + ], + }, + ] ), + meta: { + progress: 20, + component: IntroOptIn, }, - ], - onError: { - target: 'userProfile', }, }, }, userProfile: { - entry: [ - { type: 'recordTracksStepViewed', step: 'user_profile' }, - 'preFetchGeolocation', - ], - on: { - USER_PROFILE_COMPLETED: { - target: 'postUserProfile', - actions: [ - assign( { - userProfile: ( context, event: UserProfileEvent ) => - event.payload.userProfile, // sets context.userProfile to the payload of the event - } ), - ], + id: 'userProfile', + initial: 'preUserProfile', + states: { + preUserProfile: { + invoke: { + src: 'getOnboardingProfileOption', + onDone: [ + { + actions: [ + 'handleOnboardingProfileOption', + 'assignOnboardingProfile', + ], + target: 'userProfile', + }, + ], + onError: { + target: 'userProfile', + }, + }, }, - USER_PROFILE_SKIPPED: { - target: 'postUserProfile', - actions: [ - assign( { - userProfile: ( context, event: UserProfileEvent ) => - event.payload.userProfile, // assign context.userProfile to the payload of the event - } ), - ], - }, - }, - exit: actions.choose( [ - { - cond: ( _context, event ) => - event.type === 'USER_PROFILE_COMPLETED', - actions: 'recordTracksUserProfileCompleted', - }, - { - cond: ( _context, event ) => - event.type === 'USER_PROFILE_SKIPPED', - actions: [ + userProfile: { + meta: { + progress: 40, + component: UserProfile, + }, + entry: [ { - type: 'recordTracksStepSkipped', + type: 'recordTracksStepViewed', step: 'user_profile', }, + 'preFetchGeolocation', ], - }, - ] ), - meta: { - progress: 40, - component: UserProfile, - }, - }, - postUserProfile: { - invoke: { - src: ( context ) => { - return updateOnboardingProfileOption( context ); - }, - onDone: { - target: 'preBusinessInfo', - }, - onError: { - target: 'preBusinessInfo', - }, - }, - }, - preBusinessInfo: { - type: 'parallel', - states: { - geolocation: { - initial: 'checkDataOptIn', - states: { - checkDataOptIn: { - // if the user has opted out of data sharing, we skip the geolocation step - always: [ + on: { + USER_PROFILE_COMPLETED: { + target: 'postUserProfile', + actions: [ + assign( { + userProfile: ( + context, + event: UserProfileEvent + ) => event.payload.userProfile, // sets context.userProfile to the payload of the event + } ), + ], + }, + USER_PROFILE_SKIPPED: { + target: 'postUserProfile', + actions: [ + assign( { + userProfile: ( + context, + event: UserProfileEvent + ) => event.payload.userProfile, // assign context.userProfile to the payload of the event + } ), + ], + }, + }, + exit: actions.choose( [ + { + cond: ( _context, event ) => + event.type === 'USER_PROFILE_COMPLETED', + actions: 'recordTracksUserProfileCompleted', + }, + { + cond: ( _context, event ) => + event.type === 'USER_PROFILE_SKIPPED', + actions: [ { - cond: ( context ) => - context.optInDataSharing, - target: 'fetching', - }, - { - target: 'done', + type: 'recordTracksStepSkipped', + step: 'user_profile', }, ], }, - fetching: { - invoke: { - src: 'getGeolocation', - onDone: { - target: 'done', - actions: 'handleGeolocation', - }, - // onError TODO: handle error - }, + ] ), + }, + postUserProfile: { + invoke: { + src: ( context ) => { + return updateOnboardingProfileOption( context ); }, - done: { - type: 'final', + onDone: { + target: '#businessInfo', + }, + onError: { + target: '#businessInfo', }, }, }, - storeCountryOption: { - initial: 'fetching', - states: { - fetching: { - invoke: { - src: 'getStoreCountryOption', - onDone: [ - { - actions: [ 'handleStoreCountryOption' ], - target: 'done', - }, - ], - onError: { - target: 'done', - }, - }, - }, - done: { - type: 'final', - }, - }, - }, - storeNameOption: { - initial: 'fetching', - states: { - fetching: { - invoke: { - src: 'getStoreNameOption', - onDone: [ - { - actions: [ 'handleStoreNameOption' ], - target: 'done', - }, - ], - onError: { - target: 'done', // leave it as initialised default on error - }, - }, - }, - done: { - type: 'final', - }, - }, - }, - countries: { - initial: 'fetching', - states: { - fetching: { - invoke: { - src: 'getCountries', - onDone: { - target: 'done', - actions: 'handleCountries', - }, - }, - }, - done: { - type: 'final', - }, - }, - }, - }, - // onDone is reached when child parallel states are all at their final states - onDone: { - target: 'businessInfo', - }, - meta: { - progress: 50, }, }, businessInfo: { - entry: [ - { type: 'recordTracksStepViewed', step: 'business_info' }, - ], - on: { - BUSINESS_INFO_COMPLETED: { - target: 'prePlugins', - actions: [ - 'persistBusinessInfo', - 'recordTracksBusinessInfoCompleted', - ], - }, - }, - meta: { - progress: 60, - component: BusinessInfo, - }, - }, - preSkipFlowBusinessLocation: { - invoke: { - src: 'getCountries', - onDone: [ - { - actions: [ 'handleCountries' ], - target: 'skipFlowBusinessLocation', - }, - ], - onError: { - target: 'skipFlowBusinessLocation', - }, - }, - }, - skipFlowBusinessLocation: { - on: { - BUSINESS_LOCATION_COMPLETED: { - target: 'postSkipFlowBusinessLocation', - actions: [ - assign( { - businessInfo: ( - context, - event: BusinessLocationEvent - ) => { - return { - ...context.businessInfo, - location: event.payload.storeLocation, - }; - }, - } ), - 'recordTracksSkipBusinessLocationCompleted', - ], - }, - }, - entry: [ - { - type: 'recordTracksStepViewed', - step: 'skip_business_location', - }, - ], - meta: { - progress: 80, - component: BusinessLocation, - }, - }, - postSkipFlowBusinessLocation: { - initial: 'updateBusinessLocation', + id: 'businessInfo', + initial: 'preBusinessInfo', states: { - updateBusinessLocation: { - entry: assign( { - loader: { - progress: 10, + preBusinessInfo: { + type: 'parallel', + states: { + geolocation: { + initial: 'checkDataOptIn', + states: { + checkDataOptIn: { + // if the user has opted out of data sharing, we skip the geolocation step + always: [ + { + cond: ( context ) => + context.optInDataSharing, + target: 'fetching', + }, + { + target: 'done', + }, + ], + }, + fetching: { + invoke: { + src: 'getGeolocation', + onDone: { + target: 'done', + actions: 'handleGeolocation', + }, + // onError TODO: handle error + }, + }, + done: { + type: 'final', + }, + }, }, - } ), + storeCountryOption: { + initial: 'fetching', + states: { + fetching: { + invoke: { + src: 'getStoreCountryOption', + onDone: [ + { + actions: [ + 'handleStoreCountryOption', + ], + target: 'done', + }, + ], + onError: { + target: 'done', + }, + }, + }, + done: { + type: 'final', + }, + }, + }, + storeNameOption: { + initial: 'fetching', + states: { + fetching: { + invoke: { + src: 'getStoreNameOption', + onDone: [ + { + actions: [ + 'handleStoreNameOption', + ], + target: 'done', + }, + ], + onError: { + target: 'done', // leave it as initialised default on error + }, + }, + }, + done: { + type: 'final', + }, + }, + }, + countries: { + initial: 'fetching', + states: { + fetching: { + invoke: { + src: 'getCountries', + onDone: { + target: 'done', + actions: 'handleCountries', + }, + }, + }, + done: { + type: 'final', + }, + }, + }, + }, + // onDone is reached when child parallel states are all at their final states + onDone: { + target: 'businessInfo', + }, + }, + businessInfo: { + meta: { + progress: 60, + component: BusinessInfo, + }, + entry: [ + { + type: 'recordTracksStepViewed', + step: 'business_info', + }, + ], + on: { + BUSINESS_INFO_COMPLETED: { + target: '#plugins', + actions: [ + 'persistBusinessInfo', + 'recordTracksBusinessInfoCompleted', + ], + }, + }, + }, + }, + }, + skipGuidedSetup: { + id: 'skipGuidedSetup', + initial: 'preSkipFlowBusinessLocation', + states: { + preSkipFlowBusinessLocation: { invoke: { - src: ( context ) => { - return updateBusinessLocation( - context.businessInfo.location as string + src: 'getCountries', + onDone: [ + { + actions: [ 'handleCountries' ], + target: 'skipFlowBusinessLocation', + }, + ], + onError: { + target: 'skipFlowBusinessLocation', + }, + }, + }, + skipFlowBusinessLocation: { + on: { + BUSINESS_LOCATION_COMPLETED: { + target: 'postSkipFlowBusinessLocation', + actions: [ + assign( { + businessInfo: ( + _context, + event: BusinessLocationEvent + ) => { + return { + ..._context.businessInfo, + location: + event.payload.storeLocation, + }; + }, + } ), + 'recordTracksSkipBusinessLocationCompleted', + ], + }, + }, + entry: [ + { + type: 'recordTracksStepViewed', + step: 'skip_business_location', + }, + ], + meta: { + progress: 80, + component: BusinessLocation, + }, + }, + postSkipFlowBusinessLocation: { + initial: 'updateBusinessLocation', + states: { + updateBusinessLocation: { + entry: assign( { + loader: { + progress: 10, + }, + } ), + invoke: { + src: ( context ) => { + return updateBusinessLocation( + context.businessInfo.location as string + ); + }, + onDone: { + target: 'progress20', + }, + }, + }, + // Although we don't need to wait 3 seconds for the following states + // We will dispaly 20% and 80% progress for 1.5 seconds each + // for the sake of user experience. + progress20: { + entry: assign( { + loader: { + progress: 20, + }, + } ), + invoke: { + src: () => { + return promiseDelay( 1500 ); + }, + onDone: { + target: 'progress80', + }, + }, + }, + progress80: { + entry: assign( { + loader: { + progress: 80, + }, + } ), + invoke: { + src: () => { + return promiseDelay( 1500 ); + }, + onDone: { + actions: [ 'redirectToWooHome' ], + }, + }, + }, + }, + meta: { + component: Loader, + }, + }, + }, + }, + plugins: { + id: 'plugins', + initial: 'prePlugins', + states: { + prePlugins: { + invoke: { + src: 'getPlugins', + onDone: [ + { + target: 'pluginsSkipped', + cond: ( context, event ) => + event.data.length === 0, + }, + { target: 'plugins', actions: 'handlePlugins' }, + ], + }, + // 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 ); - }, - onDone: { - target: 'progress20', - }, - }, - }, - // Although we don't need to wait 3 seconds for the following states - // We will dispaly 20% and 80% progress for 1.5 seconds each - // for the sake of user experience. - progress20: { - entry: assign( { - loader: { - progress: 20, - }, + }, // TODO : define an extensible filter function here } ), - invoke: { - src: () => { - return promiseDelay( 1500 ); - }, - onDone: { - target: 'progress80', - }, + meta: { + progress: 70, }, }, - progress80: { + pluginsSkipped: { entry: assign( { loader: { progress: 80, @@ -861,226 +951,204 @@ export const coreProfilerStateMachineDefinition = createMachine( { } ), invoke: { src: () => { - return promiseDelay( 1500 ); + dispatch( + ONBOARDING_STORE_NAME + ).updateProfileItems( { + plugins_page_skipped: true, + completed: true, + } ); + return promiseDelay( 3000 ); }, onDone: { actions: [ 'redirectToWooHome' ], }, }, - }, - }, - meta: { - component: Loader, - }, - }, - prePlugins: { - invoke: { - src: 'getPlugins', - onDone: [ - { - target: 'pluginsSkipped', - cond: ( context, event ) => event.data.length === 0, + meta: { + component: Loader, }, - { target: 'plugins', actions: 'handlePlugins' }, - ], - }, - // 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 ); - }, // TODO : define an extensible filter function here - } ), - meta: { - progress: 70, - }, - }, - pluginsSkipped: { - entry: assign( { - loader: { - progress: 80, }, - } ), - invoke: { - src: () => { - dispatch( ONBOARDING_STORE_NAME ).updateProfileItems( { - plugins_page_skipped: true, - completed: true, - } ); - return promiseDelay( 3000 ); - }, - onDone: { - actions: [ 'redirectToWooHome' ], - }, - }, - meta: { - component: Loader, - }, - }, - plugins: { - entry: [ { type: 'recordTracksStepViewed', step: 'plugins' } ], - on: { - PLUGINS_PAGE_SKIPPED: { - actions: [ - { - type: 'recordTracksStepSkipped', - step: 'plugins', + plugins: { + entry: [ + { type: 'recordTracksStepViewed', step: 'plugins' }, + ], + on: { + PLUGINS_PAGE_SKIPPED: { + actions: [ + { + type: 'recordTracksStepSkipped', + step: 'plugins', + }, + ], + target: 'pluginsSkipped', }, - ], - target: 'pluginsSkipped', + PLUGINS_INSTALLATION_REQUESTED: { + target: 'installPlugins', + actions: [ + assign( { + pluginsSelected: ( + _context, + event: PluginsInstallationRequestedEvent + ) => event.payload.plugins, + } ), + ], + }, + }, + meta: { + progress: 80, + component: Plugins, + }, }, - PLUGINS_INSTALLATION_REQUESTED: { - target: 'installPlugins', - actions: [ + postPluginInstallation: { + invoke: { + src: async ( _context, event ) => { + return await dispatch( + ONBOARDING_STORE_NAME + ).updateProfileItems( { + business_extensions: + event.payload.installationCompletedResult.installedPlugins.map( + ( extension: InstalledPlugin ) => + extension.plugin + ), + completed: true, + } ); + }, + onDone: { + actions: 'redirectToWooHome', + }, + }, + meta: { + component: Loader, + progress: 100, + }, + }, + installPlugins: { + on: { + PLUGIN_INSTALLED_AND_ACTIVATED: { + actions: [ + assign( { + loader: ( + _context, + event: PluginInstalledAndActivatedEvent + ) => { + const progress = Math.round( + ( event.payload + .installedPluginIndex / + event.payload.pluginsCount ) * + 100 + ); + + let stageIndex = 0; + + if ( progress > 30 ) { + stageIndex = 1; + } else if ( progress > 60 ) { + stageIndex = 2; + } + + return { + useStages: 'plugins', + progress, + stageIndex, + }; + }, + } ), + ], + }, + PLUGINS_INSTALLATION_COMPLETED_WITH_ERRORS: { + target: 'prePlugins', + actions: [ + assign( { + 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 + ) + ), + } + ); + }, + ], + }, + PLUGINS_INSTALLATION_COMPLETED: { + target: 'postPluginInstallation', + actions: [ + ( _context, event ) => { + const installationCompletedResult = + event.payload + .installationCompletedResult; + + const trackData: { + success: boolean; + installed_extensions: string[]; + total_time: string; + [ key: string ]: + | number + | boolean + | string + | string[]; + } = { + success: true, + installed_extensions: + installationCompletedResult.installedPlugins.map( + ( + installedPlugin: InstalledPlugin + ) => + getPluginTrackKey( + installedPlugin.plugin + ) + ), + total_time: getTimeFrame( + installationCompletedResult.totalTime + ), + }; + + for ( const installedPlugin of installationCompletedResult.installedPlugins ) { + trackData[ + 'install_time_' + + getPluginTrackKey( + installedPlugin.plugin + ) + ] = getTimeFrame( + installedPlugin.installTime + ); + } + + recordEvent( + 'storeprofiler_store_extensions_installed_and_activated', + trackData + ); + }, + ], + }, + }, + entry: [ assign( { - pluginsSelected: ( - _context, - event: PluginsInstallationRequestedEvent - ) => event.payload.plugins, - } ), - ], - }, - }, - meta: { - progress: 80, - component: Plugins, - }, - }, - postPluginInstallation: { - invoke: { - src: async ( _context, event ) => { - return await dispatch( - ONBOARDING_STORE_NAME - ).updateProfileItems( { - business_extensions: - event.payload.installationCompletedResult.installedPlugins.map( - ( extension: InstalledPlugin ) => - extension.plugin - ), - completed: true, - } ); - }, - onDone: { - actions: 'redirectToWooHome', - }, - }, - meta: { - component: Loader, - progress: 100, - }, - }, - installPlugins: { - on: { - PLUGIN_INSTALLED_AND_ACTIVATED: { - actions: [ - assign( { - loader: ( - _context, - event: PluginInstalledAndActivatedEvent - ) => { - const progress = Math.round( - ( event.payload.installedPluginIndex / - event.payload.pluginsCount ) * - 100 - ); - - let stageIndex = 0; - - if ( progress > 30 ) { - stageIndex = 1; - } else if ( progress > 60 ) { - stageIndex = 2; - } - - return { - useStages: 'plugins', - progress, - stageIndex, - }; + loader: { + progress: 10, + useStages: 'plugins', }, } ), ], - }, - PLUGINS_INSTALLATION_COMPLETED_WITH_ERRORS: { - target: 'prePlugins', - actions: [ - assign( { - 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 ) - ), - } - ); - }, - ], - }, - PLUGINS_INSTALLATION_COMPLETED: { - target: 'postPluginInstallation', - actions: [ - ( _context, event ) => { - const installationCompletedResult = - event.payload.installationCompletedResult; - - const trackData: { - success: boolean; - installed_extensions: string[]; - total_time: string; - [ key: string ]: - | number - | boolean - | string - | string[]; - } = { - success: true, - installed_extensions: - installationCompletedResult.installedPlugins.map( - ( installedPlugin: InstalledPlugin ) => - getPluginTrackKey( - installedPlugin.plugin - ) - ), - total_time: getTimeFrame( - installationCompletedResult.totalTime - ), - }; - - for ( const installedPlugin of installationCompletedResult.installedPlugins ) { - trackData[ - 'install_time_' + - getPluginTrackKey( - installedPlugin.plugin - ) - ] = getTimeFrame( installedPlugin.installTime ); - } - - recordEvent( - 'storeprofiler_store_extensions_installed_and_activated', - trackData - ); - }, - ], - }, - }, - entry: [ - assign( { - loader: { - progress: 10, - useStages: 'plugins', + invoke: { + src: InstallAndActivatePlugins, }, - } ), - ], - invoke: { - src: InstallAndActivatePlugins, - }, - meta: { - component: Loader, + meta: { + 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 ?? ( () => ); // 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 ( <>
{
, "container":
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; +} diff --git a/plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx b/plugins/woocommerce-admin/client/core-profiler/utils/get-loader-stage-meta.tsx similarity index 86% rename from plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx rename to plugins/woocommerce-admin/client/core-profiler/utils/get-loader-stage-meta.tsx index ea76e4b0a99..f9115e742e7 100644 --- a/plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/utils/get-loader-stage-meta.tsx @@ -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' ), diff --git a/plugins/woocommerce-admin/client/core-profiler/utils/test/find-component.test.tsx b/plugins/woocommerce-admin/client/core-profiler/utils/test/find-component.test.tsx new file mode 100644 index 00000000000..49facb15e36 --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/utils/test/find-component.test.tsx @@ -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 ) => ( +
{ props.context }
+ ), + 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 ) => ( +
{ props.context }
+ ), + progress: 100, + }, + }, + c: 2, + }; + + const result = findComponentMeta( obj ); + expect( result ).toEqual( { + anotherKey: 'anotherValue', + component: expect.any( Function ), + progress: 100, + } ); + } ); +} ); diff --git a/plugins/woocommerce/changelog/dev-refactor-core-profiler-pages b/plugins/woocommerce/changelog/dev-refactor-core-profiler-pages new file mode 100644 index 00000000000..da6d4ca877e --- /dev/null +++ b/plugins/woocommerce/changelog/dev-refactor-core-profiler-pages @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Refactored core profiler state machine by modularising each page