From 6946ef384a87aed20e16e2b1c9ff4c42ed346197 Mon Sep 17 00:00:00 2001 From: Moon Date: Mon, 29 May 2023 07:45:30 -0700 Subject: [PATCH] Core Profiler - Add extensions page (#38405) * Initial design impl. without the full functionality * Delete unused icons * Add is_installed and plugins_page_skipped * Add plugin-card component to render an installable plugin * Implement plugins page * Add loaders for plugins * Add changelog * Remove unused type * Add changelog * Remove unnecessary return statement * Add obw/core-profiler * Replace extensions with plugins * Temp -- use window.location.href for Woo Home redirection * Minor: code refactor * Refactor isntallAndActivatedPlugins * Skip plugins page when there is no available plugin * Apply mobile styles * Update plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.scss Co-authored-by: Chi-Hsuan Huang * Update plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.scss Co-authored-by: Chi-Hsuan Huang * Update plugins/woocommerce-admin/client/core-profiler/style.scss Co-authored-by: Chi-Hsuan Huang * Bold errored plugin name * Fix checkbox alignment * Update changelog * Fix object type for formatToParts function * Fix lint issues * Fix CSS lint issues * Fallback to en-US when locale is not available * Fix error with siteLocale --------- Co-authored-by: Chi-Hsuan Huang --- ...-add-is_installed-and-plugins_page_skipped | 4 + packages/js/data/src/onboarding/types.ts | 2 + packages/js/data/src/plugins/actions.ts | 14 +- .../assets/images/loader-developing.svg | 37 +++ .../assets/images/loader-layout.svg | 53 ++++ .../components/plugin-card/plugin-card.scss | 122 ++++++++ .../components/plugin-card/plugin-card.tsx | 59 ++++ .../core-profiler/get-loader-stage-meta.tsx | 64 +++- .../client/core-profiler/index.tsx | 292 ++++++++++++++++-- .../client/core-profiler/pages/Extensions.tsx | 31 -- .../client/core-profiler/pages/Plugins.tsx | 213 +++++++++++++ .../services/installAndActivatePlugins.ts | 168 ++++++++++ .../client/core-profiler/style.scss | 169 ++++++++-- ...11-create-extensions-page-in-core-profiler | 4 + 14 files changed, 1122 insertions(+), 110 deletions(-) create mode 100644 packages/js/data/changelog/update-add-is_installed-and-plugins_page_skipped create mode 100644 plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg create mode 100644 plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg create mode 100644 plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.scss create mode 100644 plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.tsx delete mode 100644 plugins/woocommerce-admin/client/core-profiler/pages/Extensions.tsx create mode 100644 plugins/woocommerce-admin/client/core-profiler/pages/Plugins.tsx create mode 100644 plugins/woocommerce-admin/client/core-profiler/services/installAndActivatePlugins.ts create mode 100644 plugins/woocommerce/changelog/add-211-create-extensions-page-in-core-profiler diff --git a/packages/js/data/changelog/update-add-is_installed-and-plugins_page_skipped b/packages/js/data/changelog/update-add-is_installed-and-plugins_page_skipped new file mode 100644 index 00000000000..05fd971aa95 --- /dev/null +++ b/packages/js/data/changelog/update-add-is_installed-and-plugins_page_skipped @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Add is_installed and plugins_page_skipped type and added async option for installPlugins. \ No newline at end of file diff --git a/packages/js/data/src/onboarding/types.ts b/packages/js/data/src/onboarding/types.ts index 217cac646b0..20c5d3966e5 100644 --- a/packages/js/data/src/onboarding/types.ts +++ b/packages/js/data/src/onboarding/types.ts @@ -129,6 +129,7 @@ export type ProfileItems = { selling_venues?: string | null; setup_client?: boolean | null; skipped?: boolean | null; + plugins_page_skipped?: boolean | null; /** @deprecated This is always null, the theme step has been removed since WC 7.7. */ theme?: string | null; wccom_connected?: boolean | null; @@ -181,4 +182,5 @@ export type Extension = { name: string; is_built_by_wc: boolean; is_visible: boolean; + is_installed?: boolean; }; diff --git a/packages/js/data/src/plugins/actions.ts b/packages/js/data/src/plugins/actions.ts index be5df5aea54..f2f718894cd 100644 --- a/packages/js/data/src/plugins/actions.ts +++ b/packages/js/data/src/plugins/actions.ts @@ -215,20 +215,26 @@ function* handlePluginAPIError( } // Action Creator Generators -export function* installPlugins( plugins: Partial< PluginNames >[] ) { +export function* installPlugins( + plugins: Partial< PluginNames >[], + async = false +) { yield setIsRequesting( 'installPlugins', true ); try { const results: InstallPluginsResponse = yield apiFetch( { path: `${ WC_ADMIN_NAMESPACE }/plugins/install`, method: 'POST', - data: { plugins: plugins.join( ',' ) }, + data: { plugins: plugins.join( ',' ), async }, } ); - if ( results.data.installed.length ) { + if ( results.data.installed?.length ) { yield updateInstalledPlugins( results.data.installed ); } - if ( Object.keys( results.errors.errors ).length ) { + if ( + results.errors?.errors && + Object.keys( results.errors.errors ).length + ) { throw results.errors.errors; } diff --git a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg new file mode 100644 index 00000000000..fcde1ed4897 --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg new file mode 100644 index 00000000000..0cfb87d4c00 --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.scss b/plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.scss new file mode 100644 index 00000000000..cfebf5c6b6c --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.scss @@ -0,0 +1,122 @@ +.woocommerce-profiler-plugins-plugin-card { + max-width: 492px; + display: flex; + flex-direction: row; + box-sizing: border-box; + border: 1px solid #e0e0e0; + border-radius: 2px; + padding: 26px; + align-items: flex-start; + flex: 0 0 48%; + + @include breakpoint( '<782px' ) { + width: 100%; + padding: 16px; + flex: 100%; + max-width: 100%; + } + + input { + margin: 3px 26px 0 0; + width: 20px; + height: 20px; + border: 1px solid $gray-700; + border-radius: 2px; + background: #fff; + } + img { + margin-right: 12px; + width: 25px; + height: 25px; + @include breakpoint( '<782px' ) { + align-self: center; + } + } + h3 { + font-size: 14px; + line-height: 20px; + margin: 0 0 8px 0; + padding: 0; + @include breakpoint( '<782px' ) { + margin: 0; + } + } + + p { + font-size: 13px; + line-height: 16px; + color: $gray-700; + margin: 0; + padding: 0; + a { + color: inherit; + } + @include breakpoint( '<782px' ) { + display: none; + } + } + + .components-checkbox-control { + .components-checkbox-control__input, + components-checkbox-control__input-container { + width: 16px; + height: 16px; + &:focus { + box-shadow: none; + } + } + .components-checkbox-control__input[type='checkbox']:checked { + box-shadow: 0 0 0 1px #fff, + 0 0 0 2px + var(--wp-components-color-accent, var(--wp-admin-theme-color, #007cba)); + outline: 1px solid transparent; + } + .components-checkbox-control__checked { + left: -1px; + top: -1px; + width: 18px; + height: 18px; + } + } + + .components-checkbox-control { + @include breakpoint( '<782px' ) { + align-self: center; + } + .components-base-control__field { + @include breakpoint( '<782px' ) { + margin-bottom: 0 !important; + } + .components-checkbox-control__input-container { + @include breakpoint( '<782px' ) { + height: inherit; + } + } + } + } + + .woocommerce-profiler-plugins-plugin-card-text { + @include breakpoint( '<782px' ) { + align-self: center; + } + } + .woocommerce-profiler-plugins-plugin-card-text-header { + display: flex; + gap: 8px; + + &.installed { + span { + padding: 0 10px; + height: 20px; + background: #b8e6bf; + border-radius: 2px; + color: #00450c; + font-size: 11px; + font-weight: 500; + @include breakpoint( '<782px' ) { + display: none; + } + } + } + } +} diff --git a/plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.tsx b/plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.tsx new file mode 100644 index 00000000000..377ba46f0fa --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/components/plugin-card/plugin-card.tsx @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import { ReactNode } from 'react'; +import { CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import sanitizeHTML from '~/lib/sanitize-html'; +import './plugin-card.scss'; + +export const PluginCard = ( { + installed = false, + icon, + title, + onChange, + checked = false, + description, +}: { + // Checkbox will be hidden if true + installed?: boolean; + key?: string; + icon: ReactNode; + title: string | ReactNode; + description: string | ReactNode; + checked?: boolean; + onChange?: () => void; +} ) => { + return ( +
+ { ! installed && ( + {} } + /> + ) } + { icon } +
+
+

{ title }

+ { installed && ( + { __( 'Installed', 'woocommerce' ) } + ) } +
+

+

+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx b/plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx index 9d9679a3f4c..ea76e4b0a99 100644 --- a/plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/get-loader-stage-meta.tsx @@ -6,27 +6,59 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import LightBulb from './assets/images/loader-lightbulb.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'; +const LightbulbStage = { + title: __( 'Turning on the lights', 'woocommerce' ), + image: loader-lightbulb, + paragraphs: [ + { + label: __( '#FunWooFact: ', 'woocommerce' ), + text: __( + 'The Woo team is made up of over 350 talented individuals, distributed across 30+ countries.', + 'woocommerce' + ), + }, + ], +}; +const LayoutStage = { + title: __( "Extending your store's capabilities", 'woocommerce' ), + image: loader-lightbulb, + paragraphs: [ + { + label: __( '#FunWooFact: ', 'woocommerce' ), + text: __( + '#FunWooFact: Did you know that Woo powers almost 4 million stores worldwide? You’re in good company.', + 'woocommerce' + ), + }, + ], +}; + +const DevelopingStage = { + title: __( "Woo! Let's get your features ready", 'woocommerce' ), + image: loader-developng, + paragraphs: [ + { + label: __( '#FunWooFact: ', 'woocommerce' ), + text: __( + 'Did you know that Woo was founded by two South Africans and a Norwegian? Here are three alternative ways to say “store” in those countries – Winkel, ivenkile, and butikk.', + 'woocommerce' + ), + }, + ], +}; + export const getLoaderStageMeta = ( key: string ): Stages => { switch ( key ) { + case 'plugins': + return [ DevelopingStage, LayoutStage, LightbulbStage ]; case 'default': default: - return [ - { - title: __( 'Turning on the lights', 'woocommerce' ), - image: loader-lightbulb, - paragraphs: [ - { - label: __( '#FunWooFact: ', 'woocommerce' ), - text: __( - 'The Woo team is made up of over 350 talented individuals, distributed across 30+ countries.', - 'woocommerce' - ), - }, - ], - }, - ]; + return [ LightbulbStage ]; } }; diff --git a/plugins/woocommerce-admin/client/core-profiler/index.tsx b/plugins/woocommerce-admin/client/core-profiler/index.tsx index ecf48f28f24..186ffe9b54e 100644 --- a/plugins/woocommerce-admin/client/core-profiler/index.tsx +++ b/plugins/woocommerce-admin/client/core-profiler/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable xstate/no-inline-implementation */ /** * External dependencies */ @@ -5,7 +6,6 @@ import { createMachine, assign, DoneInvokeEvent, actions, spawn } from 'xstate'; import { useMachine } from '@xstate/react'; import { useEffect, useMemo } from '@wordpress/element'; import { resolveSelect, dispatch } from '@wordpress/data'; -import { navigateTo, getNewPath } from '@woocommerce/navigation'; import { ExtensionList, OPTIONS_STORE_NAME, @@ -32,9 +32,15 @@ import { BusinessInfo } from './pages/BusinessInfo'; import { BusinessLocation } from './pages/BusinessLocation'; import { getCountryStateOptions } from './services/country'; import { Loader } from './pages/Loader'; -import { Extensions } from './pages/Extensions'; - +import { Plugins } from './pages/Plugins'; +import { getPluginTrackKey, getTimeFrame } from '~/utils'; import './style.scss'; +import { + InstallationCompletedResult, + InstallAndActivatePlugins, + InstalledPlugin, + PluginInstallError, +} from './services/installAndActivatePlugins'; // TODO: Typescript support can be improved, but for now lets write the types ourselves // https://stately.ai/blog/introducing-typescript-typegen-for-xstate @@ -74,10 +80,10 @@ export type BusinessLocationEvent = { }; }; -export type ExtensionsEvent = { - type: 'EXTENSIONS_COMPLETED'; +export type PluginsInstallationRequestedEvent = { + type: 'PLUGINS_INSTALLATION_REQUESTED'; payload: { - extensionsSelected: CoreProfilerStateMachineContext[ 'extensionsSelected' ]; + plugins: CoreProfilerStateMachineContext[ 'pluginsSelected' ]; }; }; @@ -89,6 +95,31 @@ export type OnboardingProfile = { skip?: boolean; }; +export type PluginsPageSkippedEvent = { + type: 'PLUGINS_PAGE_SKIPPED'; +}; + +export type PluginInstalledAndActivatedEvent = { + type: 'PLUGIN_INSTALLED_AND_ACTIVATED'; + payload: { + pluginsCount: number; + installedPluginIndex: number; + }; +}; +export type PluginsInstallationCompletedEvent = { + type: 'PLUGINS_INSTALLATION_COMPLETED'; + payload: { + installationCompletedResult: InstallationCompletedResult; + }; +}; + +export type PluginsInstallationCompletedWithErrorsEvent = { + type: 'PLUGINS_INSTALLATION_COMPLETED_WITH_ERRORS'; + payload: { + errors: PluginInstallError[]; + }; +}; + export type CoreProfilerStateMachineContext = { optInDataSharing: boolean; userProfile: { @@ -100,8 +131,9 @@ export type CoreProfilerStateMachineContext = { geolocatedLocation: { location: string; }; - extensionsAvailable: ExtensionList[ 'plugins' ] | []; - extensionsSelected: string[]; // extension slugs + pluginsAvailable: ExtensionList[ 'plugins' ] | []; + pluginsSelected: string[]; // extension slugs + pluginsInstallationErrors: PluginInstallError[]; businessInfo: { foo?: { bar: 'qux' }; location: string }; countries: { [ key: string ]: string }; loader: { @@ -175,7 +207,10 @@ const handleOnboardingProfileOption = assign( { } ); const redirectToWooHome = () => { - navigateTo( { url: getNewPath( {}, '/', {} ) } ); + /** + * @todo replace with navigateTo + */ + window.location.href = '/wp-admin/admin.php?page=wc-admin'; }; const recordTracksIntroCompleted = () => { @@ -203,6 +238,13 @@ const recordTracksUserProfileViewed = () => { } ); }; +const recordTracksPluginsViewed = () => { + recordEvent( 'storeprofiler_step_view', { + step: 'plugins', + wc_version: getSetting( 'wcVersion' ), + } ); +}; + const recordTracksUserProfileCompleted = ( _context: CoreProfilerStateMachineContext, event: Extract< UserProfileEvent, { type: 'USER_PROFILE_COMPLETED' } > @@ -225,6 +267,10 @@ const recordTracksUserProfileSkipped = () => { recordEvent( 'storeprofiler_user_profile_skip' ); }; +const recordTracksPluginsSkipped = () => { + recordEvent( 'storeprofiler_plugins_skip' ); +}; + const recordTracksSkipBusinessLocationViewed = () => { recordEvent( 'storeprofiler_step_view', { step: 'skip_business_location', @@ -299,7 +345,7 @@ const assignOptInDataSharing = assign( { /** * Prefetch it so that @wp/data caches it and there won't be a loading delay when its used */ -const preFetchGetExtensions = assign( { +const preFetchGetPlugins = assign( { extensionsRef: () => spawn( resolveSelect( ONBOARDING_STORE_NAME ).getFreeExtensions(), @@ -307,33 +353,39 @@ const preFetchGetExtensions = assign( { ), } ); -const getExtensions = async () => { +const getPlugins = async () => { + dispatch( ONBOARDING_STORE_NAME ).invalidateResolution( + 'getFreeExtensions' + ); const extensionsBundles = await resolveSelect( ONBOARDING_STORE_NAME ).getFreeExtensions(); return ( - extensionsBundles.find( ( bundle ) => bundle.key === 'obw/grow' ) - ?.plugins || [] + extensionsBundles.find( + ( bundle ) => bundle.key === 'obw/core-profiler' + )?.plugins || [] ); }; -const handleExtensions = assign( { - extensionsAvailable: ( _context, event: DoneInvokeEvent< Extension[] > ) => +const handlePlugins = assign( { + pluginsAvailable: ( _context, event: DoneInvokeEvent< Extension[] > ) => event.data, } ); const coreProfilerMachineActions = { updateTrackingOption, - preFetchGetExtensions, + preFetchGetPlugins, preFetchGetCountries, handleTrackingOption, - handleExtensions, + handlePlugins, recordTracksIntroCompleted, recordTracksIntroSkipped, recordTracksIntroViewed, recordTracksUserProfileCompleted, recordTracksUserProfileSkipped, recordTracksUserProfileViewed, + recordTracksPluginsViewed, + recordTracksPluginsSkipped, recordTracksSkipBusinessLocationViewed, recordTracksSkipBusinessLocationCompleted, assignOptInDataSharing, @@ -345,8 +397,8 @@ const coreProfilerMachineActions = { const coreProfilerMachineServices = { getAllowTrackingOption, getCountries, - getExtensions, getOnboardingProfileOption, + getPlugins, }; export const coreProfilerStateMachineDefinition = createMachine( { id: 'coreProfiler', @@ -359,8 +411,9 @@ export const coreProfilerStateMachineDefinition = createMachine( { userProfile: { skipped: true }, geolocatedLocation: { location: 'US:CA' }, businessInfo: { location: 'US:CA' }, - extensionsAvailable: [], - extensionsSelected: [], + pluginsAvailable: [], + pluginsSelected: [], + pluginsInstallationErrors: [], countries: {}, loader: {}, } as CoreProfilerStateMachineContext, @@ -371,7 +424,7 @@ export const coreProfilerStateMachineDefinition = createMachine( { target: 'introOptIn', }, }, - entry: [ 'preFetchGetExtensions', 'preFetchGetCountries' ], + entry: [ 'preFetchGetPlugins', 'preFetchGetCountries' ], invoke: [ { src: 'getAllowTrackingOption', @@ -507,7 +560,7 @@ export const coreProfilerStateMachineDefinition = createMachine( { businessInfo: { on: { BUSINESS_INFO_COMPLETED: { - target: 'preExtensions', + target: 'prePlugins', actions: [ assign( { businessInfo: ( @@ -616,32 +669,209 @@ export const coreProfilerStateMachineDefinition = createMachine( { component: Loader, }, }, - preExtensions: { + prePlugins: { invoke: { - src: 'getExtensions', + src: 'getPlugins', onDone: [ - { target: 'extensions', actions: 'handleExtensions' }, + { + 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( { - extensionsAvailable: ( context ) => { - return context.extensionsAvailable.filter( () => true ); + pluginsAvailable: ( context ) => { + return context.pluginsAvailable.filter( () => true ); }, // TODO : define an extensible filter function here } ), meta: { progress: 70, }, }, - extensions: { + 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: [ 'recordTracksPluginsViewed' ], on: { - EXTENSIONS_COMPLETED: { - target: 'settingUpStore', + PLUGINS_PAGE_SKIPPED: { + actions: [ 'recordTracksPluginsSkipped' ], + target: 'pluginsSkipped', + }, + PLUGINS_INSTALLATION_REQUESTED: { + target: 'installPlugins', + actions: [ + assign( { + pluginsSelected: ( + _context, + event: PluginsInstallationRequestedEvent + ) => event.payload.plugins, + } ), + ], }, }, meta: { progress: 80, - component: Extensions, + 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, + }; + }, + } ), + ], + }, + 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, + }, + meta: { + component: Loader, }, }, settingUpStore: {}, diff --git a/plugins/woocommerce-admin/client/core-profiler/pages/Extensions.tsx b/plugins/woocommerce-admin/client/core-profiler/pages/Extensions.tsx deleted file mode 100644 index ebac1868d63..00000000000 --- a/plugins/woocommerce-admin/client/core-profiler/pages/Extensions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Internal dependencies - */ -import { CoreProfilerStateMachineContext, ExtensionsEvent } from '../index'; - -export const Extensions = ( { - context, - sendEvent, -}: { - context: CoreProfilerStateMachineContext; - sendEvent: ( payload: ExtensionsEvent ) => void; -} ) => { - return ( - <> -
Extensions
-
{ JSON.stringify( context.extensionsAvailable ) }
- - - ); -}; diff --git a/plugins/woocommerce-admin/client/core-profiler/pages/Plugins.tsx b/plugins/woocommerce-admin/client/core-profiler/pages/Plugins.tsx new file mode 100644 index 00000000000..5c8770b25ad --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/pages/Plugins.tsx @@ -0,0 +1,213 @@ +/** + * External dependencies + */ +import { __, sprintf, _n } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { Link } from '@woocommerce/components'; +import { Extension, ExtensionList } from '@woocommerce/data'; +import { useState } from 'react'; + +/** + * Internal dependencies + */ +import { CoreProfilerStateMachineContext } from '../index'; +import { PluginsInstallationRequestedEvent, PluginsPageSkippedEvent } from '..'; +import { Heading } from '../components/heading/heading'; +import { Navigation } from '../components/navigation/navigation'; +import { PluginCard } from '../components/plugin-card/plugin-card'; +import { getAdminSetting } from '~/utils/admin-settings'; + +const locale = ( getAdminSetting( 'locale' )?.siteLocale || 'en_US' ).replace( + '_', + '-' +); +const joinWithAnd = ( items: string[] ) => { + return new Intl.ListFormat( locale, { + style: 'long', + type: 'conjunction', + } ).formatToParts( items ); +}; + +export const Plugins = ( { + context, + navigationProgress, + sendEvent, +}: { + context: CoreProfilerStateMachineContext; + sendEvent: ( + payload: PluginsInstallationRequestedEvent | PluginsPageSkippedEvent + ) => void; + navigationProgress: number; +} ) => { + const [ selectedPlugins, setSelectedPlugins ] = useState< + ExtensionList[ 'plugins' ] + >( context.pluginsAvailable.filter( ( plugin ) => ! plugin.is_installed ) ); + + const setSelectedPlugin = ( plugin: Extension ) => { + setSelectedPlugins( + selectedPlugins.some( ( item ) => item.key === plugin.key ) + ? selectedPlugins.filter( ( item ) => item.key !== plugin.key ) + : [ ...selectedPlugins, plugin ] + ); + }; + + const skipPluginsPage = () => { + return sendEvent( { + type: 'PLUGINS_PAGE_SKIPPED', + } ); + }; + const submitInstallationRequest = () => { + return sendEvent( { + type: 'PLUGINS_INSTALLATION_REQUESTED', + payload: { + plugins: selectedPlugins.map( ( plugin ) => + plugin.key.replace( ':alt', '' ) + ), + }, + } ); + }; + + const composeListFormatParts = ( part: { + type: string; + value: string; + } ) => { + if ( part.type === 'element' ) { + return '{{span}}' + part.value + '{{/span}}'; + } + return part.value; + }; + const errorMessage = context.pluginsInstallationErrors.length + ? interpolateComponents( { + mixedString: sprintf( + // Translators: %s is a list of plugins that does not need to be translated + __( + 'Oops! We encountered a problem while installing %s. {{link}}Please try again{{/link}}.', + 'woocommerce' + ), + joinWithAnd( + context.pluginsInstallationErrors.map( + ( error ) => error.plugin + ) + ) + .map( composeListFormatParts ) + .join( '' ) + ), + components: { + span: , + link: ( + + + { pluginsWithAgreement.length > 0 && ( +

+ { interpolateComponents( { + mixedString: sprintf( + /* translators: %s: a list of plugins, e.g. Jetpack */ + _n( + 'By installing %s plugin for free you agree to our {{link}}Terms of Service{{/link}}.', + 'By installing %s plugins for free you agree to our {{link}}Terms of Service{{/link}}.', + pluginsWithAgreement.length, + 'woocommerce' + ), + joinWithAnd( + pluginsWithAgreement.map( + ( plugin ) => plugin.name + ) + ) + .map( composeListFormatParts ) + .join( '' ) + ), + components: { + span: , + link: ( + + ), + }, + } ) } +

+ ) } + + + ); +}; diff --git a/plugins/woocommerce-admin/client/core-profiler/services/installAndActivatePlugins.ts b/plugins/woocommerce-admin/client/core-profiler/services/installAndActivatePlugins.ts new file mode 100644 index 00000000000..75302bcd064 --- /dev/null +++ b/plugins/woocommerce-admin/client/core-profiler/services/installAndActivatePlugins.ts @@ -0,0 +1,168 @@ +/** + * External dependencies + */ +import { PLUGINS_STORE_NAME, PluginNames } from '@woocommerce/data'; +import { dispatch } from '@wordpress/data'; +import { differenceWith } from 'lodash'; + +/** + * Internal dependencies + */ +import { + PluginInstalledAndActivatedEvent, + PluginsInstallationCompletedEvent, + PluginsInstallationCompletedWithErrorsEvent, + CoreProfilerStateMachineContext, +} from '..'; +import { getPluginSlug } from '~/utils'; + +export type InstalledPlugin = { + plugin: string; + installTime: number; +}; + +export type InstallationCompletedResult = { + installedPlugins: InstalledPlugin[]; + totalTime: number; +}; + +export type PluginInstallError = { + plugin: string; + error: string; +}; + +const createInstallationCompletedWithErrorsEvent = ( + errors: PluginInstallError[] +): PluginsInstallationCompletedWithErrorsEvent => ( { + type: 'PLUGINS_INSTALLATION_COMPLETED_WITH_ERRORS', + payload: { + errors, + }, +} ); + +const createInstallationCompletedEvent = ( + installationCompletedResult: InstallationCompletedResult +): PluginsInstallationCompletedEvent => ( { + type: 'PLUGINS_INSTALLATION_COMPLETED', + payload: { + installationCompletedResult, + }, +} ); + +const createPluginInstalledAndActivatedEvent = ( + pluginsCount: number, + installedPluginIndex: number +): PluginInstalledAndActivatedEvent => ( { + type: 'PLUGIN_INSTALLED_AND_ACTIVATED', + payload: { + pluginsCount, + installedPluginIndex, + }, +} ); + +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; + }; + + const handleInstallationCompleted = () => { + if ( errors.length ) { + return send( + createInstallationCompletedWithErrorsEvent( errors ) + ); + } + 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(); + }; diff --git a/plugins/woocommerce-admin/client/core-profiler/style.scss b/plugins/woocommerce-admin/client/core-profiler/style.scss index ce77508a119..0b5329f59a8 100644 --- a/plugins/woocommerce-admin/client/core-profiler/style.scss +++ b/plugins/woocommerce-admin/client/core-profiler/style.scss @@ -1,3 +1,9 @@ +.woocommerce-layout .woocommerce-layout__main { + @include breakpoint( '<782px' ) { + padding-top: 0 !important; + } +} + .woocommerce-profile-wizard__body { background-color: #fff; @@ -208,44 +214,76 @@ } } } - // Business location page -.woocommerce-profiler-business-location { - display: flex; - flex-direction: column; - .woocommerce-profiler-business-location__content { - max-width: 550px; - width: 100%; +.woocommerce-profiler-select-control__country { + max-width: 400px; + margin: auto auto 32px auto; + .woocommerce-select-control__option { + font-size: 13px; + &:hover { + background: #eff2fd; + } + } + .woocommerce-select-control__listbox { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + border: 1px solid #ccc; + margin-top: 8px; + } + + .woocommerce-select-control__control { + height: 40px; + padding: 12px; + border: 1px solid #bbb; + label, + input { + font-size: 13px; + color: var(--wp-components-color-foreground, #757575); + } + &.is-active { + border: 1px solid #3858e9; + } + } + #woocommerce-select-control__listbox-0 { + top: 40px !important; + } + + .woocommerce-profiler-business-location { display: flex; - align-self: center; - - & > div { + flex-direction: column; + .woocommerce-profiler-business-location__content { + max-width: 550px; width: 100%; - } + display: flex; + align-self: center; - .components-base-control__field { - height: 40px; - } + & > div { + width: 100%; + } - .woocommerce-select-control__control-icon { - display: none; - } + .components-base-control__field { + height: 40px; + } - .woocommerce-select-control__control.is-active { - .components-base-control__label { + .woocommerce-select-control__control-icon { display: none; } - } - .woocommerce-select-control.is-searchable - .woocommerce-select-control__control-input { - margin: 0; - padding: 0; - } + .woocommerce-select-control__control.is-active { + .components-base-control__label { + display: none; + } + } - .woocommerce-select-control.is-searchable - .components-base-control__label { - left: 13px; + .woocommerce-select-control.is-searchable + .woocommerce-select-control__control-input { + margin: 0; + padding: 0; + } + + .woocommerce-select-control.is-searchable + .components-base-control__label { + left: 13px; + } } } } @@ -344,3 +382,78 @@ margin-top: 20px; } } + +.woocommerce-profiler-plugins { + display: flex; + flex-direction: column; + .woocommerce-profiler-plugins__content { + display: flex; + align-items: center; + align-self: center; + max-width: 1000px; + width: 100%; + } + .woocommerce-profiler-heading { + max-width: 615px; + margin: 58px 0 0 0; + } + .woocommerce-profiler-heading__subtitle { + margin: 0 0 48px 0 !important; + @include breakpoint( '<782px' ) { + margin-top: 12px !important; + } + } + .woocommerce-profiler-plugins__list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; + flex: 50%; + } + + .woocommerce-profiler-plugins-continue-button { + width: 200px; + height: 48px; + margin-top: 28px; + text-align: center; + display: block; + } + + .plugin-error { + width: 100%; + border-left: 4px solid #cc1818; + background-color: #fce2e4; + padding: 12px; + margin: 0 0 28px 0; + color: #1e1e1e; + button { + text-decoration: none; + } + span { + font-weight: bold; + } + } + + .woocommerce-profiler-plugins-jetpack-agreement { + color: $gray-700; + font-size: 12px; + a { + color: inherit; + } + } + + .woocommerce-profiler-plugins-continue-button-container { + @include breakpoint( '<782px' ) { + position: absolute; + bottom: 20px; + padding: 0 20px; + width: 100%; + } + + .woocommerce-profiler-plugins-continue-button { + @include breakpoint( '<782px' ) { + width: 100%; + } + } + } +} diff --git a/plugins/woocommerce/changelog/add-211-create-extensions-page-in-core-profiler b/plugins/woocommerce/changelog/add-211-create-extensions-page-in-core-profiler new file mode 100644 index 00000000000..c66c1e1e947 --- /dev/null +++ b/plugins/woocommerce/changelog/add-211-create-extensions-page-in-core-profiler @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add plugins page to the core profiler