dev: refactor installAndActivatePlugins to xstate (#38577)
* dev: refactor installAndActivatePlugins to xstate * ts fixes * fixed first plugin install progress bar reset issue * clean up todo comments * fixed bug where errors were not reported if past 30s timeout * fix lint
This commit is contained in:
parent
b04376b501
commit
622711c48b
|
@ -49,13 +49,13 @@ import { BusinessLocation } from './pages/BusinessLocation';
|
|||
import { getCountryStateOptions } from './services/country';
|
||||
import { Loader } from './pages/Loader';
|
||||
import { Plugins } from './pages/Plugins';
|
||||
import { getPluginTrackKey, getTimeFrame } from '~/utils';
|
||||
import { getPluginSlug, getPluginTrackKey, getTimeFrame } from '~/utils';
|
||||
import './style.scss';
|
||||
import {
|
||||
InstallationCompletedResult,
|
||||
InstallAndActivatePlugins,
|
||||
InstalledPlugin,
|
||||
PluginInstallError,
|
||||
pluginInstallerMachine,
|
||||
} from './services/installAndActivatePlugins';
|
||||
import { ProfileSpinner } from './components/profile-spinner/profile-spinner';
|
||||
import recordTracksActions from './actions/tracks';
|
||||
|
@ -362,6 +362,23 @@ const updateBusinessLocation = ( countryAndState: string ) => {
|
|||
} );
|
||||
};
|
||||
|
||||
const assignStoreLocation = assign( {
|
||||
businessInfo: (
|
||||
context: CoreProfilerStateMachineContext,
|
||||
event: BusinessLocationEvent
|
||||
) => {
|
||||
return {
|
||||
...context.businessInfo,
|
||||
location: event.payload.storeLocation,
|
||||
};
|
||||
},
|
||||
} );
|
||||
|
||||
const assignUserProfile = assign( {
|
||||
userProfile: ( context, event: UserProfileEvent ) =>
|
||||
event.payload.userProfile, // sets context.userProfile to the payload of the event
|
||||
} );
|
||||
|
||||
const updateBusinessInfo = async (
|
||||
_context: CoreProfilerStateMachineContext,
|
||||
event: BusinessInfoEvent
|
||||
|
@ -442,8 +459,8 @@ const browserPopstateHandler = () => ( sendBack: Sender< AnyEventObject > ) => {
|
|||
};
|
||||
|
||||
const handlePlugins = assign( {
|
||||
pluginsAvailable: ( _context, event: DoneInvokeEvent< Extension[] > ) =>
|
||||
event.data,
|
||||
pluginsAvailable: ( _context, event ) =>
|
||||
( event as DoneInvokeEvent< Extension[] > ).data,
|
||||
} );
|
||||
|
||||
type ActType = (
|
||||
|
@ -462,6 +479,12 @@ const updateQueryStep: ActType = ( _context, _evt, { action } ) => {
|
|||
}
|
||||
};
|
||||
|
||||
const assignPluginsSelected = assign( {
|
||||
pluginsSelected: ( _context, event: PluginsInstallationRequestedEvent ) => {
|
||||
return event.payload.plugins.map( getPluginSlug );
|
||||
},
|
||||
} );
|
||||
|
||||
export const preFetchActions = {
|
||||
preFetchGetPlugins,
|
||||
preFetchGetCountries,
|
||||
|
@ -480,6 +503,9 @@ const coreProfilerMachineActions = {
|
|||
handleStoreNameOption,
|
||||
handleStoreCountryOption,
|
||||
assignOptInDataSharing,
|
||||
assignStoreLocation,
|
||||
assignPluginsSelected,
|
||||
assignUserProfile,
|
||||
handleCountries,
|
||||
handleOnboardingProfileOption,
|
||||
assignOnboardingProfile,
|
||||
|
@ -712,25 +738,11 @@ export const coreProfilerStateMachineDefinition = createMachine( {
|
|||
on: {
|
||||
USER_PROFILE_COMPLETED: {
|
||||
target: 'postUserProfile',
|
||||
actions: [
|
||||
assign( {
|
||||
userProfile: (
|
||||
_context,
|
||||
event: UserProfileEvent
|
||||
) => event.payload.userProfile, // sets context.userProfile to the payload of the event
|
||||
} ),
|
||||
],
|
||||
actions: [ 'assignUserProfile' ],
|
||||
},
|
||||
USER_PROFILE_SKIPPED: {
|
||||
target: 'postUserProfile',
|
||||
actions: [
|
||||
assign( {
|
||||
userProfile: (
|
||||
_context,
|
||||
event: UserProfileEvent
|
||||
) => event.payload.userProfile, // assign context.userProfile to the payload of the event
|
||||
} ),
|
||||
],
|
||||
actions: [ 'assignUserProfile' ],
|
||||
},
|
||||
},
|
||||
exit: actions.choose( [
|
||||
|
@ -952,18 +964,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
|
|||
BUSINESS_LOCATION_COMPLETED: {
|
||||
target: 'postSkipFlowBusinessLocation',
|
||||
actions: [
|
||||
assign( {
|
||||
businessInfo: (
|
||||
_context,
|
||||
event: BusinessLocationEvent
|
||||
) => {
|
||||
return {
|
||||
..._context.businessInfo,
|
||||
location:
|
||||
event.payload.storeLocation,
|
||||
};
|
||||
},
|
||||
} ),
|
||||
'assignStoreLocation',
|
||||
'recordTracksSkipBusinessLocationCompleted',
|
||||
],
|
||||
},
|
||||
|
@ -1108,14 +1109,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
|
|||
},
|
||||
PLUGINS_INSTALLATION_REQUESTED: {
|
||||
target: 'installPlugins',
|
||||
actions: [
|
||||
assign( {
|
||||
pluginsSelected: (
|
||||
_context,
|
||||
event: PluginsInstallationRequestedEvent
|
||||
) => event.payload.plugins,
|
||||
} ),
|
||||
],
|
||||
actions: [ 'assignPluginsSelected' ],
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
|
@ -1268,7 +1262,12 @@ export const coreProfilerStateMachineDefinition = createMachine( {
|
|||
} ),
|
||||
],
|
||||
invoke: {
|
||||
src: InstallAndActivatePlugins,
|
||||
src: pluginInstallerMachine,
|
||||
data: ( context ) => {
|
||||
return {
|
||||
selectedPlugins: context.pluginsSelected,
|
||||
};
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
component: Loader,
|
||||
|
@ -1290,6 +1289,8 @@ export const CoreProfilerController = ( {
|
|||
const augmentedStateMachine = useMemo( () => {
|
||||
// When adding extensibility, this is the place to manipulate the state machine definition.
|
||||
return coreProfilerStateMachineDefinition.withConfig( {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- there seems to be a flaky error here - it fails sometimes and then not on recompile, will need to investigate further.
|
||||
actions: {
|
||||
...coreProfilerMachineActions,
|
||||
...actionOverrides,
|
||||
|
|
|
@ -3,7 +3,13 @@
|
|||
*/
|
||||
import { PLUGINS_STORE_NAME, PluginNames } from '@woocommerce/data';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { differenceWith } from 'lodash';
|
||||
import {
|
||||
assign,
|
||||
createMachine,
|
||||
sendParent,
|
||||
actions,
|
||||
DoneInvokeEvent,
|
||||
} from 'xstate';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -12,9 +18,9 @@ import {
|
|||
PluginInstalledAndActivatedEvent,
|
||||
PluginsInstallationCompletedEvent,
|
||||
PluginsInstallationCompletedWithErrorsEvent,
|
||||
CoreProfilerStateMachineContext,
|
||||
} from '..';
|
||||
import { getPluginSlug } from '~/utils';
|
||||
|
||||
const { pure } = actions;
|
||||
|
||||
export type InstalledPlugin = {
|
||||
plugin: string;
|
||||
|
@ -60,109 +66,207 @@ const createPluginInstalledAndActivatedEvent = (
|
|||
},
|
||||
} );
|
||||
|
||||
export const InstallAndActivatePlugins =
|
||||
( context: CoreProfilerStateMachineContext ) =>
|
||||
async (
|
||||
send: (
|
||||
event:
|
||||
| PluginInstalledAndActivatedEvent
|
||||
| PluginsInstallationCompletedEvent
|
||||
| PluginsInstallationCompletedWithErrorsEvent
|
||||
) => void
|
||||
) => {
|
||||
let continueInstallation = true;
|
||||
const errors: PluginInstallError[] = [];
|
||||
const installationCompletedResult: InstallationCompletedResult = {
|
||||
installedPlugins: [],
|
||||
totalTime: 0,
|
||||
};
|
||||
const installationStartTime = window.performance.now();
|
||||
const setInstallationCompletedTime = () => {
|
||||
installationCompletedResult.totalTime =
|
||||
window.performance.now() - installationStartTime;
|
||||
};
|
||||
export type PluginInstallerMachineContext = {
|
||||
selectedPlugins: PluginNames[];
|
||||
pluginsInstallationQueue: PluginNames[];
|
||||
installedPlugins: InstalledPlugin[];
|
||||
startTime: number;
|
||||
installationDuration: number;
|
||||
errors: PluginInstallError[];
|
||||
};
|
||||
|
||||
const handleInstallationCompleted = () => {
|
||||
if ( errors.length ) {
|
||||
return send(
|
||||
createInstallationCompletedWithErrorsEvent( errors )
|
||||
type InstallAndActivateErrorResponse = DoneInvokeEvent< {
|
||||
error: string;
|
||||
message: string;
|
||||
} >;
|
||||
|
||||
type InstallAndActivateSuccessResponse = DoneInvokeEvent< {
|
||||
installed: PluginNames[];
|
||||
results: Record< PluginNames, boolean >;
|
||||
install_time: Record< PluginNames, number >;
|
||||
} >;
|
||||
|
||||
export const pluginInstallerMachine = createMachine(
|
||||
{
|
||||
id: 'plugin-installer',
|
||||
predictableActionArguments: true,
|
||||
initial: 'installing',
|
||||
context: {
|
||||
selectedPlugins: [] as PluginNames[],
|
||||
pluginsInstallationQueue: [] as PluginNames[],
|
||||
installedPlugins: [] as InstalledPlugin[],
|
||||
startTime: 0,
|
||||
installationDuration: 0,
|
||||
errors: [] as PluginInstallError[],
|
||||
} as PluginInstallerMachineContext,
|
||||
states: {
|
||||
installing: {
|
||||
initial: 'installer',
|
||||
entry: [
|
||||
'populateDefaults',
|
||||
'assignPluginsInstallationQueue',
|
||||
'assignStartTime',
|
||||
],
|
||||
after: {
|
||||
INSTALLATION_TIMEOUT: 'timedOut',
|
||||
},
|
||||
states: {
|
||||
installer: {
|
||||
initial: 'installing',
|
||||
states: {
|
||||
installing: {
|
||||
invoke: {
|
||||
src: 'installPlugin',
|
||||
onDone: {
|
||||
actions: [
|
||||
'assignInstallationSuccessDetails',
|
||||
],
|
||||
target: 'removeFromQueue',
|
||||
},
|
||||
onError: {
|
||||
actions:
|
||||
'assignInstallationErrorDetails',
|
||||
target: 'removeFromQueue',
|
||||
},
|
||||
},
|
||||
},
|
||||
removeFromQueue: {
|
||||
entry: [
|
||||
'removePluginFromQueue',
|
||||
'updateParentWithPluginProgress',
|
||||
],
|
||||
always: [
|
||||
{
|
||||
target: 'installing',
|
||||
cond: 'hasPluginsToInstall',
|
||||
},
|
||||
{ target: '#installation-finished' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
finished: {
|
||||
id: 'installation-finished',
|
||||
entry: [ 'assignInstallationDuration' ],
|
||||
always: [
|
||||
{ target: 'reportErrors', cond: 'hasErrors' },
|
||||
{ target: 'reportSuccess' },
|
||||
],
|
||||
},
|
||||
timedOut: {
|
||||
entry: [ 'assignInstallationDuration' ],
|
||||
invoke: {
|
||||
src: 'queueRemainingPluginsAsync',
|
||||
onDone: {
|
||||
target: 'finished',
|
||||
},
|
||||
},
|
||||
},
|
||||
reportErrors: {
|
||||
entry: 'updateParentWithInstallationErrors',
|
||||
},
|
||||
reportSuccess: {
|
||||
entry: 'updateParentWithInstallationSuccess',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
delays: {
|
||||
INSTALLATION_TIMEOUT: 30000,
|
||||
},
|
||||
actions: {
|
||||
// populateDefaults needed because passing context from the parents overrides the
|
||||
// full context object of the child. Not needed in xstatev5 because it
|
||||
// becomes a merge
|
||||
populateDefaults: assign( {
|
||||
installedPlugins: [],
|
||||
errors: [],
|
||||
startTime: 0,
|
||||
installationDuration: 0,
|
||||
} ),
|
||||
assignPluginsInstallationQueue: assign( {
|
||||
pluginsInstallationQueue: ( ctx ) => {
|
||||
return ctx.selectedPlugins;
|
||||
},
|
||||
} ),
|
||||
assignStartTime: assign( {
|
||||
startTime: () => window.performance.now(),
|
||||
} ),
|
||||
assignInstallationDuration: assign( {
|
||||
installationDuration: ( ctx ) =>
|
||||
window.performance.now() - ctx.startTime,
|
||||
} ),
|
||||
assignInstallationSuccessDetails: assign( {
|
||||
installedPlugins: ( ctx, evt ) => {
|
||||
const plugin = ctx.pluginsInstallationQueue[ 0 ];
|
||||
return [
|
||||
...ctx.installedPlugins,
|
||||
{
|
||||
plugin,
|
||||
installTime:
|
||||
(
|
||||
evt as DoneInvokeEvent< InstallAndActivateSuccessResponse >
|
||||
).data.data.install_time[ plugin ] || 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
} ),
|
||||
assignInstallationErrorDetails: assign( {
|
||||
errors: ( ctx, evt ) => {
|
||||
return [
|
||||
...ctx.errors,
|
||||
{
|
||||
plugin: ctx.pluginsInstallationQueue[ 0 ],
|
||||
error: (
|
||||
evt as DoneInvokeEvent< InstallAndActivateErrorResponse >
|
||||
).data.data.message,
|
||||
},
|
||||
];
|
||||
},
|
||||
} ),
|
||||
removePluginFromQueue: assign( {
|
||||
pluginsInstallationQueue: ( ctx ) => {
|
||||
return ctx.pluginsInstallationQueue.slice( 1 );
|
||||
},
|
||||
} ),
|
||||
updateParentWithPluginProgress: pure( ( ctx ) =>
|
||||
sendParent(
|
||||
createPluginInstalledAndActivatedEvent(
|
||||
ctx.selectedPlugins.length,
|
||||
ctx.selectedPlugins.length -
|
||||
ctx.pluginsInstallationQueue.length
|
||||
)
|
||||
)
|
||||
),
|
||||
updateParentWithInstallationErrors: sendParent( ( ctx ) =>
|
||||
createInstallationCompletedWithErrorsEvent( ctx.errors )
|
||||
),
|
||||
updateParentWithInstallationSuccess: sendParent( ( ctx ) =>
|
||||
createInstallationCompletedEvent( {
|
||||
installedPlugins: ctx.installedPlugins,
|
||||
totalTime: ctx.installationDuration,
|
||||
} )
|
||||
),
|
||||
},
|
||||
guards: {
|
||||
hasErrors: ( ctx ) => ctx.errors.length > 0,
|
||||
hasPluginsToInstall: ( ctx ) =>
|
||||
ctx.pluginsInstallationQueue.length > 0,
|
||||
},
|
||||
services: {
|
||||
installPlugin: ( ctx ) => {
|
||||
return dispatch( PLUGINS_STORE_NAME ).installAndActivatePlugins(
|
||||
[ ctx.pluginsInstallationQueue[ 0 ] ]
|
||||
);
|
||||
}
|
||||
setInstallationCompletedTime();
|
||||
send(
|
||||
createInstallationCompletedEvent( installationCompletedResult )
|
||||
);
|
||||
};
|
||||
|
||||
const handlePluginInstalledAndActivated = (
|
||||
installedPluginIndex: number
|
||||
) => {
|
||||
send(
|
||||
createPluginInstalledAndActivatedEvent(
|
||||
context.pluginsSelected.length,
|
||||
installedPluginIndex + 1
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handlePluginInstallError = ( plugin: string, error: unknown ) => {
|
||||
errors.push( {
|
||||
plugin,
|
||||
error: error instanceof Error ? error.message : String( error ),
|
||||
} );
|
||||
};
|
||||
|
||||
const handlePluginInstallation = async (
|
||||
installedPluginIndex: number
|
||||
) => {
|
||||
// Set by timer when it's up
|
||||
if ( ! continueInstallation ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = getPluginSlug(
|
||||
context.pluginsSelected[ installedPluginIndex ]
|
||||
);
|
||||
try {
|
||||
const response = await dispatch(
|
||||
PLUGINS_STORE_NAME
|
||||
).installAndActivatePlugins( [ plugin ] );
|
||||
|
||||
installationCompletedResult.installedPlugins.push( {
|
||||
plugin,
|
||||
installTime: response.data?.install_time?.[ plugin ] || 0,
|
||||
} );
|
||||
|
||||
handlePluginInstalledAndActivated( installedPluginIndex );
|
||||
} catch ( error ) {
|
||||
handlePluginInstallError( plugin, error );
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimerExpired = async () => {
|
||||
continueInstallation = false;
|
||||
|
||||
const remainingPlugins = differenceWith(
|
||||
context.pluginsSelected,
|
||||
installationCompletedResult.installedPlugins.map(
|
||||
( plugin ) => plugin.plugin
|
||||
)
|
||||
);
|
||||
|
||||
await dispatch( PLUGINS_STORE_NAME ).installPlugins(
|
||||
remainingPlugins as PluginNames[],
|
||||
true
|
||||
);
|
||||
|
||||
handleInstallationCompleted();
|
||||
};
|
||||
|
||||
const timer = setTimeout( handleTimerExpired, 1000 * 30 );
|
||||
|
||||
for ( let index = 0; index < context.pluginsSelected.length; index++ ) {
|
||||
await handlePluginInstallation( index );
|
||||
}
|
||||
|
||||
clearTimeout( timer );
|
||||
handleInstallationCompleted();
|
||||
};
|
||||
},
|
||||
queueRemainingPluginsAsync: ( ctx ) => {
|
||||
return dispatch( PLUGINS_STORE_NAME ).installPlugins(
|
||||
ctx.pluginsInstallationQueue,
|
||||
true
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { PluginNames } from '@woocommerce/data';
|
||||
import { interpret } from 'xstate';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
pluginInstallerMachine,
|
||||
InstalledPlugin,
|
||||
PluginInstallError,
|
||||
} from '../installAndActivatePlugins';
|
||||
|
||||
describe( 'pluginInstallerMachine', () => {
|
||||
const dispatchInstallPluginMock = jest.fn();
|
||||
const defaultContext = {
|
||||
selectedPlugins: [] as PluginNames[],
|
||||
pluginsInstallationQueue: [] as PluginNames[],
|
||||
installedPlugins: [] as InstalledPlugin[],
|
||||
startTime: 0,
|
||||
installationDuration: 0,
|
||||
errors: [] as PluginInstallError[],
|
||||
};
|
||||
const mockConfig = {
|
||||
delays: {
|
||||
INSTALLATION_DELAY: 1000,
|
||||
},
|
||||
actions: {
|
||||
updateParentWithPluginProgress: jest.fn(),
|
||||
updateParentWithInstallationErrors: jest.fn(),
|
||||
updateParentWithInstallationSuccess: jest.fn(),
|
||||
},
|
||||
services: {
|
||||
installPlugin: dispatchInstallPluginMock,
|
||||
queueRemainingPluginsAsync: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach( () => {
|
||||
jest.resetAllMocks();
|
||||
} );
|
||||
|
||||
it( 'when given one plugin it should call the installPlugin service once', ( done ) => {
|
||||
const machineUnderTest = pluginInstallerMachine
|
||||
.withConfig( mockConfig )
|
||||
.withContext( {
|
||||
...defaultContext,
|
||||
selectedPlugins: [ 'woocommerce-payments' ],
|
||||
} );
|
||||
|
||||
dispatchInstallPluginMock.mockImplementationOnce( ( context ) => {
|
||||
expect( context.pluginsInstallationQueue ).toEqual( [
|
||||
'woocommerce-payments',
|
||||
] );
|
||||
return Promise.resolve( {
|
||||
data: {
|
||||
install_time: {
|
||||
'woocommerce-payments': 1000,
|
||||
},
|
||||
},
|
||||
} );
|
||||
} );
|
||||
|
||||
const service = interpret( machineUnderTest ).start();
|
||||
service.onTransition( ( state ) => {
|
||||
if ( state.matches( 'reportSuccess' ) ) {
|
||||
expect(
|
||||
mockConfig.actions.updateParentWithInstallationSuccess
|
||||
).toHaveBeenCalledWith(
|
||||
// context param
|
||||
expect.objectContaining( {
|
||||
installedPlugins: [
|
||||
{
|
||||
plugin: 'woocommerce-payments',
|
||||
installTime: 1000,
|
||||
},
|
||||
],
|
||||
} ),
|
||||
// event param
|
||||
expect.any( Object ),
|
||||
// meta param
|
||||
expect.any( Object )
|
||||
);
|
||||
done();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
|
||||
Refactored Core Profiler's plugin installation step to use XState
|
Loading…
Reference in New Issue