Merge branch 'trunk' into e2e/remove-daily-playwright-config

This commit is contained in:
rodelgc 2023-05-18 19:36:19 +08:00
commit 94b665817c
45 changed files with 1695 additions and 612 deletions

View File

@ -1,119 +0,0 @@
name: 'Release: Generate changelog'
on:
workflow_dispatch:
inputs:
releaseBranch:
description: 'The name of the release branch, in the format `release/x.y`'
required: true
releaseVersion:
description: 'The version of the release, in the format `x.y`'
required: true
env:
GIT_COMMITTER_NAME: 'WooCommerce Bot'
GIT_COMMITTER_EMAIL: 'no-reply@woocommerce.com'
GIT_AUTHOR_NAME: 'WooCommerce Bot'
GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com'
permissions: {}
jobs:
create-changelog-prs:
runs-on: ubuntu-20.04
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build: false
- name: 'Git fetch the release branch'
run: git fetch origin ${{ inputs.releaseBranch }}
- name: 'Checkout the release branch'
run: git checkout ${{ inputs.releaseBranch }}
- name: 'Create a new branch for the changelog update PR'
run: git checkout -b ${{ format( 'update/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Generate the changelog file'
run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }}
- name: Checkout pnpm-lock.yaml to prevent issues
run: git checkout pnpm-lock.yaml
- name: 'git rm deleted files'
run: git rm $(git ls-files --deleted)
- name: 'Commit deletion'
run: git commit -m "Delete changelog files from ${{ inputs.releaseVersion }} release"
- name: 'Remember the deletion commit hash'
id: rev-parse
run: echo "hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: 'Insert NEXT_CHANGELOG contents into readme.txt'
run: php .github/workflows/scripts/release-changelog.php
- name: 'git add readme.txt'
run: git add plugins/woocommerce/readme.txt
- name: 'Commit readme'
run: git commit -m "Update the readme files for the ${{ inputs.releaseVersion }} release"
- name: 'Push update branch to origin'
run: git push origin ${{ format( 'update/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Stash any other undesired changes'
run: git stash
- name: 'Checkout trunk'
run: git checkout trunk
- name: 'Create a branch for the changelog files deletion'
run: git checkout -b ${{ format( 'delete/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Cherry-pick the deletion commit'
run: git cherry-pick ${{ steps.rev-parse.outputs.hash }}
- name: 'Push deletion branch to origin'
run: git push origin ${{ format( 'delete/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Create release branch PR'
id: release-pr
uses: actions/github-script@v6
with:
script: |
const result = await github.rest.pulls.create( {
owner: "${{ github.repository_owner }}",
repo: "${{ github.event.repository.name }}",
head: "${{ format( 'update/{0}-changelog', inputs.releaseVersion ) }}",
base: "${{ inputs.releaseBranch }}",
title: "${{ format( 'Release: Prepare the changelog for {0}', inputs.releaseVersion ) }}",
body: "${{ format( 'This pull request was automatically generated during the code freeze to prepare the changelog for {0}', inputs.releaseVersion ) }}"
} );
return result.data.number;
- name: 'Create trunk PR'
id: trunk-pr
uses: actions/github-script@v6
with:
script: |
const result = await github.rest.pulls.create( {
owner: "${{ github.repository_owner }}",
repo: "${{ github.event.repository.name }}",
head: "${{ format( 'delete/{0}-changelog', inputs.releaseVersion ) }}",
base: "trunk",
title: "${{ format( 'Release: Remove {0} change files', inputs.releaseVersion ) }}",
body: "${{ format( 'This pull request was automatically generated during the code freeze to remove the changefiles from {0} that are compiled into the `{1}` branch via #{2}', inputs.releaseVersion, inputs.releaseBranch, steps.release-pr.outputs.result ) }}"
} );
return result.data.number;

View File

@ -71,6 +71,13 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run utils code-freeze version-bump -o ${{ github.repository_owner }} -v ${{ steps.milestone.outputs.nextDevelopmentVersion }}.0-dev
- name: Generate changelog changes
id: changelog
if: steps.check-freeze.outputs.freeze == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run utils code-freeze changelog -o ${{ github.repository_owner }} -v ${{ steps.milestone.outputs.nextReleaseVersion }}
notify-slack:
name: 'Sends code freeze notification to Slack'
runs-on: ubuntu-20.04
@ -87,26 +94,3 @@ jobs:
:warning-8c: ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} Code Freeze :ice_cube:
The automation to cut the release branch for ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} has run. Any PRs that were not already merged will be a part of ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.nextDevelopmentVersion }} by default. If you have something that needs to make ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.nextReleaseVersion }} that hasn't yet been merged, please see the <${{ secrets.FG_LINK }}/code-freeze-for-woocommerce-core-release/|fieldguide page for the code freeze>.
trigger-changelog-action:
name: 'Trigger changelog action'
runs-on: ubuntu-20.04
permissions:
actions: write
needs: code-freeze-prep
if: needs.code-freeze-prep.outputs.freeze == 'true'
steps:
- name: 'Trigger changelog action'
uses: actions/github-script@v6
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-changelog.yml',
ref: 'trunk',
inputs: {
releaseVersion: "${{ needs.code-freeze-prep.outputs.nextReleaseVersion }}",
releaseBranch: "${{ needs.code-freeze-prep.outputs.nextReleaseBranch }}"
}
})

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add error specific messages to product save functionality

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix double scrollbars on product editor page

View File

@ -1,18 +1 @@
@import '@wordpress/interface/src/style.scss';
.interface-interface-skeleton {
@include breakpoint( '<782px' ) {
top: $adminbar-height-mobile;
}
top: $adminbar-height;
background-color: $white;
}
.interface-interface-skeleton__sidebar {
width: 280px;
}
.interface-interface-skeleton__header {
// Higher than the sidebar which has a z-index of 90.
z-index: 100;
}

View File

@ -13,6 +13,7 @@ import { MouseEvent } from 'react';
* Internal dependencies
*/
import { useValidations } from '../../../../contexts/validation-context';
import { WPError } from '../../../../utils/get-product-error-message';
export function usePreview( {
disabled,
@ -22,7 +23,7 @@ export function usePreview( {
...props
}: Omit< Button.AnchorProps, 'aria-disabled' | 'variant' | 'href' > & {
onSaveSuccess?( product: Product ): void;
onSaveError?( error: Error ): void;
onSaveError?( error: WPError ): void;
} ): Button.AnchorProps {
const anchorRef = useRef< HTMLAnchorElement >();
@ -117,7 +118,10 @@ export function usePreview( {
const publishedProduct = await saveEditedEntityRecord< Product >(
'postType',
'product',
productId
productId,
{
throwOnError: true,
}
);
// Redirect using the default anchor behaviour. This way, the usage
@ -129,7 +133,7 @@ export function usePreview( {
}
} catch ( error ) {
if ( onSaveError ) {
onSaveError( error as Error );
onSaveError( error as WPError );
}
}
}

View File

@ -12,6 +12,7 @@ import { MouseEvent } from 'react';
* Internal dependencies
*/
import { useValidations } from '../../../../contexts/validation-context';
import { WPError } from '../../../../utils/get-product-error-message';
export function usePublish( {
disabled,
@ -21,7 +22,7 @@ export function usePublish( {
...props
}: Omit< Button.ButtonProps, 'aria-disabled' | 'variant' | 'children' > & {
onPublishSuccess?( product: Product ): void;
onPublishError?( error: Error ): void;
onPublishError?( error: WPError ): void;
} ): Button.ButtonProps {
const [ productId ] = useEntityProp< number >(
'postType',
@ -77,7 +78,10 @@ export function usePublish( {
const publishedProduct = await saveEditedEntityRecord< Product >(
'postType',
'product',
productId
productId,
{
throwOnError: true,
}
);
if ( publishedProduct && onPublishSuccess ) {
@ -85,7 +89,7 @@ export function usePublish( {
}
} catch ( error ) {
if ( onPublishError ) {
onPublishError( error as Error );
onPublishError( error as WPError );
}
}
}

View File

@ -14,6 +14,7 @@ import { MouseEvent, ReactNode } from 'react';
* Internal dependencies
*/
import { useValidations } from '../../../../contexts/validation-context';
import { WPError } from '../../../../utils/get-product-error-message';
export function useSaveDraft( {
disabled,
@ -23,7 +24,7 @@ export function useSaveDraft( {
...props
}: Omit< Button.ButtonProps, 'aria-disabled' | 'variant' | 'children' > & {
onSaveSuccess?( product: Product ): void;
onSaveError?( error: Error ): void;
onSaveError?( error: WPError ): void;
} ): Button.ButtonProps {
const [ productId ] = useEntityProp< number >(
'postType',
@ -86,7 +87,10 @@ export function useSaveDraft( {
const publishedProduct = await saveEditedEntityRecord< Product >(
'postType',
'product',
productId
productId,
{
throwOnError: true,
}
);
if ( onSaveSuccess ) {
@ -94,7 +98,7 @@ export function useSaveDraft( {
}
} catch ( error ) {
if ( onSaveError ) {
onSaveError( error as Error );
onSaveError( error as WPError );
}
}
}

View File

@ -12,6 +12,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { getProductErrorMessage } from '../../../utils/get-product-error-message';
import { usePreview } from '../hooks/use-preview';
export function PreviewButton( {
@ -36,9 +37,11 @@ export function PreviewButton( {
navigateTo( { url } );
}
},
onSaveError() {
onSaveError( error ) {
const message = getProductErrorMessage( error );
createErrorNotice(
__( 'Failed to preview product.', 'woocommerce' )
message || __( 'Failed to preview product.', 'woocommerce' )
);
},
} );

View File

@ -13,6 +13,7 @@ import { useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { getProductErrorMessage } from '../../../utils/get-product-error-message';
import { recordProductEvent } from '../../../utils/record-product-event';
import { usePublish } from '../hooks/use-publish';
@ -62,12 +63,13 @@ export function PublishButton(
navigateTo( { url } );
}
},
onPublishError() {
const noticeContent = isCreating
onPublishError( error ) {
const defaultMessage = isCreating
? __( 'Failed to create product.', 'woocommerce' )
: __( 'Failed to publish product.', 'woocommerce' );
createErrorNotice( noticeContent );
const message = getProductErrorMessage( error );
createErrorNotice( message || defaultMessage );
},
} );

View File

@ -12,6 +12,7 @@ import { useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { getProductErrorMessage } from '../../../utils/get-product-error-message';
import { recordProductEvent } from '../../../utils/record-product-event';
import { useSaveDraft } from '../hooks/use-save-draft';
@ -41,9 +42,11 @@ export function SaveDraftButton(
navigateTo( { url } );
}
},
onSaveError() {
onSaveError( error ) {
const message = getProductErrorMessage( error );
createErrorNotice(
__( 'Failed to update product.', 'woocommerce' )
message || __( 'Failed to update product.', 'woocommerce' )
);
},
} );

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export type WPError = {
code: string;
message: string;
data: {
[ key: string ]: unknown;
};
};
export function getProductErrorMessage( error: WPError ) {
if ( error.code === 'product_invalid_sku' ) {
return __( 'Invalid or duplicated SKU.', 'woocommerce' );
}
return null;
}

View File

@ -0,0 +1,22 @@
/**
* Internal dependencies
*/
import { getProductErrorMessage, WPError } from '../get-product-error-message';
describe( 'getProductErrorMessage', () => {
it( 'should return the correct error message when one exists', () => {
const error = {
code: 'product_invalid_sku',
} as WPError;
const message = getProductErrorMessage( error );
expect( message ).toBe( 'Invalid or duplicated SKU.' );
} );
it( 'should return null when no error message exists', () => {
const error = {
code: 'unanticipated_error_code',
} as WPError;
const status = getProductErrorMessage( error );
expect( status ).toBeNull();
} );
} );

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { createMachine, assign, DoneInvokeEvent, actions } from 'xstate';
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';
@ -11,6 +11,8 @@ import {
OPTIONS_STORE_NAME,
COUNTRIES_STORE_NAME,
Country,
ONBOARDING_STORE_NAME,
Extension,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { getSetting } from '@woocommerce/settings';
@ -25,6 +27,7 @@ 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 './style.scss';
@ -91,34 +94,6 @@ export type CoreProfilerStateMachineContext = {
};
};
const Extensions = ( {
context,
sendEvent,
}: {
context: CoreProfilerStateMachineContext;
sendEvent: ( payload: ExtensionsEvent ) => void;
} ) => {
return (
// TOOD: we need to fetch the extensions list from the API as part of initializing the profiler
<>
<div>Extensions</div>
<div>{ context.extensionsAvailable }</div>
<button
onClick={ () =>
sendEvent( {
type: 'EXTENSIONS_COMPLETED',
payload: {
extensionsSelected: [ 'woocommerce-payments' ],
},
} )
}
>
Next
</button>
</>
);
};
const getAllowTrackingOption = async () =>
resolveSelect( OPTIONS_STORE_NAME ).getOption(
'woocommerce_allow_tracking'
@ -131,6 +106,17 @@ const handleTrackingOption = assign( {
) => event.data !== 'no',
} );
/**
* Prefetch it so that @wp/data caches it and there won't be a loading delay when its used
*/
const preFetchGetCountries = assign( {
spawnGetCountriesRef: () =>
spawn(
resolveSelect( COUNTRIES_STORE_NAME ).getCountries(),
'core-profiler-prefetch-countries'
),
} );
const getCountries = async () =>
resolveSelect( COUNTRIES_STORE_NAME ).getCountries();
@ -217,9 +203,38 @@ const assignOptInDataSharing = assign( {
event.payload.optInDataSharing,
} );
/**
* Prefetch it so that @wp/data caches it and there won't be a loading delay when its used
*/
const preFetchGetExtensions = assign( {
extensionsRef: () =>
spawn(
resolveSelect( ONBOARDING_STORE_NAME ).getFreeExtensions(),
'core-profiler-prefetch-extensions'
),
} );
const getExtensions = async () => {
const extensionsBundles = await resolveSelect(
ONBOARDING_STORE_NAME
).getFreeExtensions();
return (
extensionsBundles.find( ( bundle ) => bundle.key === 'obw/grow' )
?.plugins || []
);
};
const handleExtensions = assign( {
extensionsAvailable: ( _context, event: DoneInvokeEvent< Extension[] > ) =>
event.data,
} );
const coreProfilerMachineActions = {
updateTrackingOption,
preFetchGetExtensions,
preFetchGetCountries,
handleTrackingOption,
handleExtensions,
recordTracksIntroCompleted,
recordTracksIntroSkipped,
recordTracksIntroViewed,
@ -233,6 +248,7 @@ const coreProfilerMachineActions = {
const coreProfilerMachineServices = {
getAllowTrackingOption,
getCountries,
getExtensions,
};
export const coreProfilerStateMachineDefinition = createMachine( {
id: 'coreProfiler',
@ -257,6 +273,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
target: 'introOptIn',
},
},
entry: [ 'preFetchGetExtensions', 'preFetchGetCountries' ],
invoke: [
{
src: 'getAllowTrackingOption',
@ -462,13 +479,12 @@ export const coreProfilerStateMachineDefinition = createMachine( {
},
},
preExtensions: {
always: [
// immediately transition to extensions without any events as long as extensions fetching parallel has completed
{
target: 'extensions',
cond: () => true, // TODO: use a custom function to check on the parallel state using meta when we implement that. https://xstate.js.org/docs/guides/guards.html#guards-condition-functions
},
],
invoke: {
src: 'getExtensions',
onDone: [
{ target: 'extensions', actions: 'handleExtensions' },
],
},
// add exit action to filter the extensions using a custom function here and assign it to context.extensionsAvailable
exit: assign( {
extensionsAvailable: ( context ) => {

View File

@ -0,0 +1,31 @@
/**
* Internal dependencies
*/
import { CoreProfilerStateMachineContext, ExtensionsEvent } from '../index';
export const Extensions = ( {
context,
sendEvent,
}: {
context: CoreProfilerStateMachineContext;
sendEvent: ( payload: ExtensionsEvent ) => void;
} ) => {
return (
<>
<div>Extensions</div>
<div>{ JSON.stringify( context.extensionsAvailable ) }</div>
<button
onClick={ () =>
sendEvent( {
type: 'EXTENSIONS_COMPLETED',
payload: {
extensionsSelected: [ 'woocommerce-payments' ],
},
} )
}
>
Next
</button>
</>
);
};

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { RouteMatch } from 'react-router-dom';
type Page = {
container: JSX.Element;
path: string;
breadcrumbs:
| string[]
| ( ( { match }: { match: RouteMatch } ) => string[] );
wpOpenMenu: string;
navArgs: {
id: string;
};
capability: string;
};
export function usePageClasses( page: Page ) {
function convertCamelCaseToKebabCase( str: string ) {
return str.replace(
/[A-Z]/g,
( letter ) => `-${ letter.toLowerCase() }`
);
}
function getPathClassName( path: string ) {
const suffix =
path === '/'
? '_home'
: path
.replace( /:[a-zA-Z?]+/g, function ( match ) {
return convertCamelCaseToKebabCase( match ).replace(
':',
''
);
} )
.replace( /\//g, '_' );
return `woocommerce-admin-page_${ suffix }`;
}
useEffect( () => {
if ( ! page.path ) {
return;
}
const classes = getPathClassName( page.path );
document.body.classList.add( classes );
return () => {
document.body.classList.remove( classes );
};
}, [ page.path ] );
}

View File

@ -4,7 +4,7 @@
import { SlotFillProvider } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { Component, lazy, Suspense } from '@wordpress/element';
import { Component, lazy, Suspense, useEffect } from '@wordpress/element';
import {
unstable_HistoryRouter as HistoryRouter,
Route,
@ -15,7 +15,7 @@ import {
} from 'react-router-dom';
import { Children, cloneElement } from 'react';
import PropTypes from 'prop-types';
import { get, isFunction, identity, memoize } from 'lodash';
import { isFunction, identity } from 'lodash';
import {
CustomerEffortScoreModalContainer,
triggerExitPageCesSurvey,
@ -45,6 +45,7 @@ import { Footer } from './footer';
import Notices from './notices';
import TransientNotices from './transient-notices';
import { getAdminSetting } from '~/utils/admin-settings';
import { usePageClasses } from './hooks/use-page-classes';
import '~/activity-panel';
import '~/mobile-banner';
import './navigation';
@ -118,39 +119,18 @@ const LayoutSwitchWrapper = ( props ) => {
);
};
class _Layout extends Component {
memoizedLayoutContext = memoize(
( page ) => page?.navArgs?.id?.toLowerCase() || 'page'
);
componentDidMount() {
this.recordPageViewTrack();
triggerExitPageCesSurvey();
}
componentDidUpdate( prevProps ) {
const previousPath = get( prevProps, 'location.pathname' );
const currentPath = get( this.props, 'location.pathname' );
if ( ! previousPath || ! currentPath ) {
return;
}
if ( previousPath !== currentPath ) {
this.recordPageViewTrack();
setTimeout( () => {
triggerExitPageCesSurvey();
}, 0 );
}
}
recordPageViewTrack() {
const {
activePlugins,
installedPlugins,
isEmbedded,
isJetpackConnected,
} = this.props;
function _Layout( {
activePlugins,
installedPlugins,
isEmbedded,
isJetpackConnected,
location,
match,
page,
} ) {
usePageClasses( page );
function recordPageViewTrack() {
const navigationFlag = {
has_navigation: !! window.wcNavigation,
};
@ -164,7 +144,7 @@ class _Layout extends Component {
return;
}
const pathname = get( this.props, 'location.pathname' );
const { pathname } = location;
if ( ! pathname ) {
return;
}
@ -185,69 +165,78 @@ class _Layout extends Component {
} );
}
isWCPaySettingsPage() {
const { page, section, tab } = getQuery();
useEffect( () => {
triggerExitPageCesSurvey();
}, [] );
useEffect( () => {
recordPageViewTrack();
setTimeout( () => {
triggerExitPageCesSurvey();
}, 0 );
}, [ location?.pathname ] );
function isWCPaySettingsPage() {
const { page: queryPage, section, tab } = getQuery();
return (
page === 'wc-settings' &&
queryPage === 'wc-settings' &&
tab === 'checkout' &&
section === 'woocommerce_payments'
);
}
render() {
const { isEmbedded, ...restProps } = this.props;
const { location, page } = this.props;
const { breadcrumbs } = page;
const query = Object.fromEntries(
new URLSearchParams( location && location.search )
);
const { breadcrumbs } = page;
return (
<LayoutContextProvider
value={ getLayoutContextValue( [
this.memoizedLayoutContext( page ),
] ) }
>
<SlotFillProvider>
<div className="woocommerce-layout">
<Header
sections={
isFunction( breadcrumbs )
? breadcrumbs( this.props )
: breadcrumbs
}
isEmbedded={ isEmbedded }
query={ query }
/>
<TransientNotices />
{ ! isEmbedded && (
<PrimaryLayout>
<div className="woocommerce-layout__main">
<Controller
{ ...restProps }
query={ query }
/>
</div>
</PrimaryLayout>
) }
const query = Object.fromEntries(
new URLSearchParams( location && location.search )
);
{ isEmbedded && this.isWCPaySettingsPage() && (
<Suspense fallback={ null }>
<WCPayUsageModal />
</Suspense>
) }
<Footer />
<CustomerEffortScoreModalContainer />
</div>
<PluginArea scope="woocommerce-admin" />
{ window.wcAdminFeatures.navigation && (
<PluginArea scope="woocommerce-navigation" />
return (
<LayoutContextProvider
value={ getLayoutContextValue( [
page?.navArgs?.id?.toLowerCase() || 'page',
] ) }
>
<SlotFillProvider>
<div className="woocommerce-layout">
<Header
sections={
isFunction( breadcrumbs )
? breadcrumbs( { match } )
: breadcrumbs
}
isEmbedded={ isEmbedded }
query={ query }
/>
<TransientNotices />
{ ! isEmbedded && (
<PrimaryLayout>
<div className="woocommerce-layout__main">
<Controller
page={ page }
match={ match }
query={ query }
/>
</div>
</PrimaryLayout>
) }
<PluginArea scope="woocommerce-tasks" />
</SlotFillProvider>
</LayoutContextProvider>
);
}
{ isEmbedded && isWCPaySettingsPage() && (
<Suspense fallback={ null }>
<WCPayUsageModal />
</Suspense>
) }
<Footer />
<CustomerEffortScoreModalContainer />
</div>
<PluginArea scope="woocommerce-admin" />
{ window.wcAdminFeatures.navigation && (
<PluginArea scope="woocommerce-navigation" />
) }
<PluginArea scope="woocommerce-tasks" />
</SlotFillProvider>
</LayoutContextProvider>
);
}
_Layout.propTypes = {

View File

@ -0,0 +1,136 @@
.woocommerce-add-product,
.woocommerce-edit-product {
.woocommerce-product-form-actions {
margin-top: $gap-largest + $gap-smaller;
}
.components-checkbox-control,
.components-toggle-control {
& > * {
margin-bottom: 0;
}
}
.components-input-control {
&__prefix {
margin-left: $gap-smaller;
}
&__suffix {
margin-right: $gap-smaller;
}
}
.components-currency-control {
.components-input-control__prefix {
color: $gray-700;
}
.components-input-control__input {
text-align: right;
}
}
.components-checkbox-control {
&__label {
display: flex;
align-items: center;
}
&__input-container {
align-self: center;
}
.components-base-control__field {
display: flex;
}
}
.components-toggle-control
.components-base-control__field
.components-toggle-control__label {
display: flex;
align-items: center;
}
.woocommerce-tooltip {
margin-left: $gap-smaller;
&__button {
padding: 0;
}
}
.woocommerce-product-form {
&__custom-label-input {
display: flex;
flex-direction: column;
label {
display: block;
margin-bottom: $gap-smaller;
}
}
&__optional-input {
color: $gray-700;
}
&__secondary-text {
font-size: 12px;
color: $gray-700;
margin-top: $gap-smaller;
}
}
.has-error {
.components-base-control__help {
color: $studio-red-50;
margin-bottom: 0;
}
}
// This is needed because Gutenberg disables tooltip events on disabled elements.
// We are explicitly using this on a disabled item so this overlay prevents
// the tooltip from seeing the disabled property and allows mouse events to occur.
// See https://github.com/WordPress/gutenberg/blob/411b6eee8376e31bf9db4c15c92a80524ae38e9b/packages/components/src/tooltip/index.js#L99-L102
.woocommerce-product-form__tooltip-disabled-overlay {
position: relative;
display: inline-block;
&::after {
content: '';
display: inline-block;
position: absolute;
width: 100%;
height: 100%;
background: transparent;
left: 0;
top: 0;
cursor: default;
}
.components-base-control {
margin-bottom: 0;
}
}
.half-width-field {
@include breakpoint( '<960px' ) {
width: 100%;
}
width: 50%;
}
.components-base-control__label {
.woocommerce-product-form__secondary-text {
display: block;
font-weight: 400;
}
}
}
.woocommerce-edit-product {
position: relative;
&__error {
background: #fff;
text-align: center;
border: 1px solid $gray-400;
border-radius: 2px;
padding: 1em 2em;
}
&__spinner {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
}
}

View File

@ -15,7 +15,7 @@ import {
*/
import { ProductForm } from './product-form';
import { ProductTourContainer } from './tour';
import './product-page.scss';
import './add-edit-product-page.scss';
import './fills';
const AddProductPage: React.FC = () => {

View File

@ -21,7 +21,7 @@ import { useParams } from 'react-router-dom';
import { ProductForm } from './product-form';
import { ProductFormLayout } from './layout/product-form-layout';
import { ProductVariationForm } from './product-variation-form';
import './product-page.scss';
import './add-edit-product-page.scss';
import './fills';
const EditProductPage: React.FC = () => {

View File

@ -1,136 +1,44 @@
.woocommerce-add-product,
.woocommerce-edit-product {
.woocommerce-product-form-actions {
margin-top: $gap-largest + $gap-smaller;
}
.components-checkbox-control,
.components-toggle-control {
& > * {
margin-bottom: 0;
}
}
.components-input-control {
&__prefix {
margin-left: $gap-smaller;
}
&__suffix {
margin-right: $gap-smaller;
}
}
.components-currency-control {
.components-input-control__prefix {
color: $gray-700;
}
.components-input-control__input {
text-align: right;
}
}
.components-checkbox-control {
&__label {
display: flex;
align-items: center;
}
&__input-container {
align-self: center;
}
.components-base-control__field {
display: flex;
}
}
.components-toggle-control
.components-base-control__field
.components-toggle-control__label {
display: flex;
align-items: center;
}
.woocommerce-tooltip {
margin-left: $gap-smaller;
&__button {
padding: 0;
}
}
.woocommerce-product-form {
&__custom-label-input {
display: flex;
flex-direction: column;
label {
display: block;
margin-bottom: $gap-smaller;
}
}
&__optional-input {
color: $gray-700;
}
&__secondary-text {
font-size: 12px;
color: $gray-700;
margin-top: $gap-smaller;
}
}
.has-error {
.components-base-control__help {
color: $studio-red-50;
margin-bottom: 0;
}
.woocommerce-admin-page__add-product,
.woocommerce-admin-page__product_product-id {
.woocommerce-store-alerts {
display: none;
}
// This is needed because Gutenberg disables tooltip events on disabled elements.
// We are explicitly using this on a disabled item so this overlay prevents
// the tooltip from seeing the disabled property and allows mouse events to occur.
// See https://github.com/WordPress/gutenberg/blob/411b6eee8376e31bf9db4c15c92a80524ae38e9b/packages/components/src/tooltip/index.js#L99-L102
.woocommerce-product-form__tooltip-disabled-overlay {
position: relative;
display: inline-block;
&::after {
content: '';
display: inline-block;
position: absolute;
width: 100%;
height: 100%;
background: transparent;
left: 0;
top: 0;
cursor: default;
}
.components-base-control {
margin-bottom: 0;
}
.woocommerce-layout__primary {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
}
.half-width-field {
.woocommerce-layout .woocommerce-layout__main {
padding: 0;
}
.interface-interface-skeleton {
background-color: $white;
position: static;
}
.interface-interface-skeleton__sidebar {
width: 280px;
}
.interface-interface-skeleton__header {
background-color: $white;
width: calc(100% - $admin-menu-width);
left: $admin-menu-width;
top: $adminbar-height;
position: fixed;
@include breakpoint( '<960px' ) {
left: $admin-menu-width-collapsed;
width: calc(100% - $admin-menu-width-collapsed);
}
@include breakpoint( '<782px' ) {
top: $adminbar-height-mobile;
left: 0;
width: 100%;
}
width: 50%;
}
.components-base-control__label {
.woocommerce-product-form__secondary-text {
display: block;
font-weight: 400;
}
}
}
.woocommerce-edit-product {
position: relative;
&__error {
background: #fff;
text-align: center;
border: 1px solid $gray-400;
border-radius: 2px;
padding: 1em 2em;
}
&__spinner {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
// Higher than the sidebar which has a z-index of 90.
z-index: 100;
}
}

View File

@ -15,8 +15,8 @@ import { useParams } from 'react-router-dom';
* Internal dependencies
*/
import { useProductEntityRecord } from './hooks/use-product-entity-record';
import './fills/product-block-editor-fills';
import './product-page.scss';
declare const productBlockEditorSettings: ProductEditorSettings;

View File

@ -12,11 +12,12 @@ pnpm install
Build the example extension by running the pnpm script and passing the example name.
```bash
WC_EXT=<example> pnpm example --filter=woocommerce/client/admin
WC_EXT=<example> pnpm --filter=woocommerce/client/admin example
```
Include the output plugin in your `.wp-env.json` and `.wp-env.override.json` and restart the WordPress instance. WooCommerce Analytics reports will now reflect the changes made by the example extension.
You should see a new directory in `./woocommerce/plugins/{example} path.` Include the output plugin in your `.wp-env.json` or `.wp-env.override.json` and restart the WordPress instance. WooCommerce will now reflect the changes made by the example extension.
You can make changes to Javascript and PHP files in the example and see changes reflected upon refresh.
@ -27,3 +28,4 @@ You can make changes to Javascript and PHP files in the example and see changes
- `dashboard-section` - Adding a custom "section" to the new dashboard area.
- `table-column` - An example of how to add column(s) to any report.
- `sql-modification` - An example of how to modify SQL statements.
- `payment-gateway-suggestions` - An example of how to add a new payment gateway suggestion

View File

@ -0,0 +1,49 @@
<?php
/**
* Custom task example.
*
* @package WooCommerce\Admin
*/
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
/**
* Custom task class.
*/
class MyTask extends Task {
/**
* Get the task ID.
*
* @return string
*/
public function get_id() {
return 'my-task';
}
/**
* Title.
*
* @return string
*/
public function get_title() {
return __( 'My task', 'woocommerce' );
}
/**
* Content.
*
* @return string
*/
public function get_content() {
return __( 'Add your task description here for display in the task list.', 'woocommerce' );
}
/**
* Time.
*
* @return string
*/
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
}

View File

@ -1,13 +1,17 @@
/* eslint-disable @wordpress/i18n-text-domain */
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Card, CardBody } from '@wordpress/components';
import { ONBOARDING_STORE_NAME } from '@woocommerce/data';
import { registerPlugin } from '@wordpress/plugins';
import { useDispatch } from '@wordpress/data';
import { WooOnboardingTask } from '@woocommerce/onboarding';
import {
WooOnboardingTask,
WooOnboardingTaskListItem,
} from '@woocommerce/onboarding';
const Task = ( { onComplete, task } ) => {
const { actionTask } = useDispatch( ONBOARDING_STORE_NAME );
@ -46,10 +50,32 @@ const Task = ( { onComplete, task } ) => {
registerPlugin( 'add-task-content', {
render: () => (
<WooOnboardingTask id="my-task">
{ ( { onComplete, query, task } ) => (
<Task onComplete={ onComplete } task={ task } />
) }
{ ( {
onComplete,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
query,
task,
} ) => <Task onComplete={ onComplete } task={ task } /> }
</WooOnboardingTask>
),
scope: 'woocommerce-tasks',
} );
registerPlugin( 'my-task-list-item-plugin', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTaskListItem id="my-task">
{ ( { defaultTaskItem: DefaultTaskItem } ) => (
// Add a custom wrapper around the default task item.
<div
className="woocommerce-custom-tasklist-item"
style={ {
border: '1px solid red',
} }
>
<DefaultTaskItem />
</div>
) }
</WooOnboardingTaskListItem>
),
} );

View File

@ -5,29 +5,18 @@
* @package WooCommerce\Admin
*/
use Automattic\WooCommerce\Internal\Admin\Onboarding;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
/**
* Register the task.
*/
function add_task_my_task() {
TaskLists::add_task(
require_once __DIR__ . '/class-mytask.php';
$task_lists = \Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists::instance();
// Add the task to the extended list.
$task_lists::add_task(
'extended',
array(
'id' => 'my-task',
'title' => __( 'My task', 'woocommerce-admin' ),
'content' => __(
'Add your task description here for display in the task list.',
'woocommerce-admin'
),
'action_label' => __( 'Do action', 'woocommerce-admin' ),
'is_complete' => Task::is_task_actioned( 'my-task' ),
'can_view' => 'US' === WC()->countries->get_base_country(),
'time' => __( '2 minutes', 'woocommerce-admin' ),
'is_dismissable' => true,
'is_snoozeable' => true,
new MyTask(
$task_lists::get_list( 'extended' ),
)
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -4,78 +4,127 @@ The onboarding tasks provides a way to help store owners get their sites quickly
The task list is easily extensible to allow inserting custom tasks around plugin setup that benefits store owners.
### Models and classes
<img src="./images/task-list.png" width="500px" alt="Onboarding Task List" />
#### TaskLists
## Adding a custom task
The `TaskLists` class acts as a data store for tasks and provides a way to add or retrieve tasks and lists.
### Step 1: Add your task in PHP
* `TaskLists::get_lists()` - Get all registered task lists
* `TaskLists::get_visible()` - Get visible task lists
* `TaskLists::get_list( $id )` - Get a list by ID
* `TaskLists::get_task( $id )` - Get a task by ID
* `TaskLists::add_list( $args )` - Add a list with the given arguments
* `TaskLists::add_task( $list_id, $args )` - Add a task to a given list ID
#### Task
**Arguments**
To add a custom task, you first need to create a new class that extends the `Task` class.
```php
$args = array(
'id' => 'my-task', // A unique task ID.
'title' => 'My Task', // Task title.
'content' => 'Task explanation and instructions', // Content shown in the task list item.
'action_label' => __( "Do the task!", 'woocommerce' ), // Text used for the action button.
'action_url' => 'http://wordpress.com/my/task', // URL used when clicking the task item in lieu of SlotFill.
'is_complete' => get_option( 'my-task-option', false ), // Determine if the task is complete.
'can_view' => 'US:CA' === wc_get_base_location(),
'level' => 3, // Priority level shown for extended tasks.
'time' => __( '2 minutes', 'plugin-text-domain' ), // Time string for time to complete the task.
'is_dismissable' => false, // Determine if the task is dismissable.
'is_snoozeable' => true, // Determine if the task is snoozeable.
'additional_info' => array( 'apples', 'oranges', 'bananas' ), // Additional info passed to the task.
)
$task = new Task( $args );
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
class MyTask extends Task {
public function get_id() {
return 'my-task';
}
public function get_title() {
return __( 'My task', 'woocommerce' );
}
public function get_content() {
return __( 'Add your task description here for display in the task list.', 'woocommerce');
}
public function get_time() {
return __( '2 minutes', 'woocommerce' );
}
}
```
**Methods**
You can then add the task to the task list by calling the `add_task` method on the `TaskLists` class.
* `$task->dismiss()` - Dismiss the task
* `$task->undo_dismiss()` - Undo dismissal of a task
* `$task->is_dismissed()` - Check if a task is dismissed
* `$task->snooze()` - Snooze a task for later
* `$task->undo_snooze()` - Undo snoozing of a task
* `$task->is_snoozed()` - Check if a task has been snoozed
* `$task->mark_actioned()` - Mark a task as actioned. Optional to help determine completion
* `$task->is_actioned()` - Check if a task has been actioned
* `$task->get_json()` - Get the camelcase JSON for use in the client
* `id` (int) - Task ID.
* `title` (string) - Task title.
* `canView` (bool) - If a task should be viewable on a given store.
* `content` (string) - Task content.
* `additionalInfo` (object) - Additional extensible information about the task.
* `actionLabel` (string) - The label used for the action button.
* `actionUrl` (string) - The URL used when clicking the task if no task card is required.
* `isComplete` (bool) - If the task has been completed or not.
* `time` (string) - Length of time to complete the task.
* `level` (integer) - A priority for task list sorting.
* `isActioned` (bool) - If a task has been actioned.
* `isDismissed` (bool) - If a task has been dismissed.
* `isDismissable` (bool) - Whether or not a task is dismissable.
* `isSnoozed` (bool) - If a task has been snoozed.
* `isSnoozeable` (bool) - Whether or not a task can be snoozed.
* `snoozedUntil` (int) - Timestamp in milliseconds that the task has been snoozed until.
```php
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
#### TaskList
TaskLists::add_task(
'extended', // The task list ID. See the TaskList section below for more information.
new MyTask(
$task_lists::get_list( 'extended' ), // The task list object.
)
);
```
**Arguments**
### Step 2 Register the task in JavaScript.
Next, you have to add your task to the tasks list in JavaScript.
```jsx
import { __ } from '@wordpress/i18n';
import {
WooOnboardingTask,
WooOnboardingTaskListItem,
} from '@woocommerce/onboarding';
const Task = ( { onComplete, task, query } ) => {
// Implement your task UI/feature here.
return (
<div>
</div>
);
};
registerPlugin( 'add-task-content', {
render: () => (
<WooOnboardingTask id="my-task">
{ ( {
onComplete,
query,
task,
} ) => <Task onComplete={ onComplete } task={ task } query={ query } /> }
</WooOnboardingTask>
)
```
### Example
You can find a complete example of how to add a custom task as a WordPress plugin in the [examples directory](../examples/extensions/add-task/).
## Models and classes
### TaskLists
The `TaskLists` class serves as a data store for tasks, providing functionality to create, initialize, add tasks, retrieve task lists, and perform other task management operations.
#### Methods
- `TaskLists::instance()`: Returns the class instance of the `TaskLists` interface.
- `TaskLists::init()`: Initializes the task lists. This method should be called to set up the necessary configurations and hooks for task management.
- `TaskLists::is_experiment_treatment($name)`: Checks if an experiment is the treatment or control. This is internally used by Woo.
- `TaskLists::init_default_lists()`: Initializes the default task lists. This method adds predefined task lists with their properties and tasks.
- `TaskLists::init_tasks()`: Initializes the tasks. This method should be called to initialize the tasks associated with the task lists.
- `TaskLists::set_active_task()`: Temporarily stores the active task to persist across page loads when necessary. This method is used to manage active tasks.
- `TaskLists::add_list($args)`: Adds a task list with the specified properties.
- `TaskLists::add_task($list_id, $args)`: Adds a task to the specified task list.
- `TaskLists::maybe_add_extended_tasks($extended_tasks)`: Adds default extended task lists.
- `TaskLists::get_lists()`: Returns an array of all task lists.
- `TaskLists::get_lists_by_ids($ids)`: Returns an array of task lists filtered by the specified list IDs.
- `TaskLists::get_list_ids()`: Returns an array of all task list IDs.
- `TaskLists::clear_lists()`: Clears all task lists.
- `TaskLists::get_visible()`: Returns an array of visible task lists.
- `TaskLists::get_list($id)`: Retrieves a task list by its ID.
- `TaskLists::get_task($id, $task_list_id = null)`: Retrieves a single task.
- `TaskLists::setup_tasks_remaining()`: Return the number of setup tasks remaining.
- `TaskLists::menu_task_count()`: Adds a badge to the homescreen menu item for remaining tasks.
- `TaskLists::task_list_preloaded_settings($settings)`: Adds visible list IDs to component settings.
### TaskList
The `TaskList` class represents a task list. It contains properties and methods for managing task list. We currently have three predefined task lists
- `setup`: The default task list
- `extended`: The "Things to do next" task list
- `secret_tasklist`: The "Secret" task list that is used for having tasks that are accessed by other means.
#### Example & Arguments
```php
$args = array(
'id' => 'my-list', // A unique task list ID.
'title' => 'My List', // Task list title.
'sort_by' => array( // An array of keys to sort the tasks by.
'id' => 'my-list', // A unique task list ID.
'title' => 'My List', // Task list title.
'sort_by' => array( // An array of keys to sort the tasks by.
array(
'key' => 'is_complete',
'order' => 'asc',
@ -85,29 +134,138 @@ $args = array(
'order' => 'asc',
),
),
)
$list = new TaskList( $args );
'tasks' => array( /* Array of Task objects */ ), // Optional: Initialize with pre-existing tasks.
'display_progress_header' => true, // Optional: Whether to display the progress header.
'event_prefix' => 'tasklist_', // Optional: Event prefix for task-related events.
'options' => array(
'use_completed_title' => true, // Optional: Whether to use a completed title for the task list.
),
'visible' => true, // Optional: Whether the task list is visible.
);
$task_list = new TaskList($args);
```
**Methods**
#### Methods
* `$task_list->is_hidden()` - Check if a task list is hidden
* `$task_list->is_visible()` - Check if a task list is visible (opposite value of `is_hidden()`)
* `$task_list->hide()` - Hide a task list
* `$task_list->unhide()` - Undo hiding of a task list
* `$task_list->is_complete()` - Check if a task list is complete
* `$task_list->add_task( $args )` - Add a task to a task list
* `$task_list->get_viewable_tasks()` - Get tasks that are marked as `can_view` for the store
* `$task_list->sort_tasks( $sort_by )` - Sort the tasks by the provided `sort_by` value or the task list `sort_by` property if no argument is passed.
* `$task_list->get_json()` - Get the camelcase JSON for use in the client
* `id` (int) - Task list ID.
* `title` (string) - Task list title.
* `isHidden` (bool) - If a task has been hidden.
* `isVisible` (bool) - If a task list is visible.
* `isComplete` (bool) - Whether or not all viewable tasks have been completed.
* `tasks` (array) - An array of `Task` objects.
- `$task_list::get_list_id()`: Returns the ID of the task list.
- `$task_list::get_title()`: Returns the title of the task list.
- `$task_list::get_tasks()`: Returns an array of tasks associated with the task list.
- `$task_list::add_task($task)`: Adds a task to the task list.
- `$task_list::remove_task($task_id)`: Removes a task from the task list based on its ID.
- `$task_list::has_task($task_id)`: Checks if the task list contains a task with the specified ID.
- `$task_list::get_task($task_id)`: Retrieves a task from the task list based on its ID.
- `$task_list::get_viewable_tasks()`: Returns an array of viewable tasks within the task list.
- `$task_list::is_visible()`: Checks if the task list is visible.
- `$task_list::is_hidden()`: Checks if the task list is hidden.
- `$task_list::is_complete()`: Checks if all tasks in the task list are complete.
- `$task_list::get_completed_count()`: Returns the count of completed tasks in the task list.
- `$task_list::get_total_count()`: Returns the total count of tasks in the task list.
- `$task_list::get_progress_percentage()`: Returns the progress percentage of the task list.
- `$task_list::get_sorted_tasks($sort_by)`: Returns the tasks sorted based on the specified sorting criteria.
- `$task_list::get_json()`: Returns the JSON representation of the task list.
- `$task_list->get_json()` - Get the camelcase JSON for use in the client
- `id` (int) - Task list ID.
- `title` (string) - Task list title.
- `isHidden` (bool) - If a task has been hidden.
- `isVisible` (bool) - If a task list is visible.
- `isComplete` (bool) - Whether or not all viewable tasks have been completed.
- `tasks` (array) - An array of `Task` objects.
#### Data store actions
### Task
The `Task` class represents a task. It contains properties and methods for managing tasks. You can see the predefined tasks in [this directory](https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks).
Please note that the `Task` class is abstract and intended to be extended by custom task classes.
#### Example
```php
<?php
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
class MyTask extends Task {
public function get_id() {
// Return a unique identifier for your task
}
public function get_title() {
// Return the title of your task
}
public function get_content() {
// Return the content/explanation of your task
}
public function get_time() {
// Return the estimated time to complete the task
}
// Implement other abstract methods as needed
}
// Add MyTask to "test-list" task list
TaskLists::add_task(
'test-list',
new MyTask(
TaskLists::get_list( 'test-list' )
)
);
```
### Methods
- `$task->get_id(): string`: Returns the ID of the task.
- `$task->get_title(): string`: Returns the title of the task.
- `$task->get_content(): string`: Returns the content of the task.
- `$task->get_time(): string`: Returns the estimated time to complete the task.
- `$task->get_parent_id(): string`: Returns the ID of the parent task list.
- `$task->get_parent_options(): array`: Returns the options of the parent task list.
- `$task->get_parent_option($option_name): mixed|null`: Returns the value of a specific option from the parent task list.
- `$task->prefix_event($event_name): string`: Returns the event name prefixed with the task list's event prefix.
- `$task->get_additional_info(): string`: Returns additional information about the task. Typically includes details, notes, or instructions related to the task itself.
- `$task->get_additional_data(): mixed|null`: Returns additional data associated with the task. It can be any type of data, such as arrays, objects, or simple values.
- `$task->get_action_label(): string`: Returns the label for the action button of the task.
- `$task->get_action_url(): string|null`: Returns the URL associated with the task's action.
- `$task->is_dismissable(): bool`: Checks if the task is dismissable.
- `$task->is_dismissed(): bool`: Checks if the task is dismissed.
- `$task->dismiss(): bool`: Dismisses the task.
- `$task->undo_dismiss(): bool`: Undoes the dismissal of the task.
- `$task->has_previously_completed(): bool`: Checks if the task has been completed in the past.
- `$task->possibly_track_completion(): void`: Tracks the completion of the task if necessary.
- `$task->set_active(): void`: Sets the task as the active task.
- `$task->is_active(): bool`: Checks if the task is the active task.
- `$task->can_view(): bool`: Checks if the task can be viewed based on store capabilities.
- `$task->is_complete(): bool`: Checks if the task is complete.
- `$task->is_visited(): bool`: Checks if the task has been visited.
- `$task->get_record_view_event(): bool`: Checks if the task view event should be recorded.
- `$task->convert_object_to_camelcase($data): object`: Converts an array's keys to camel case.
- `$task->mark_actioned(): bool`: Marks the task as actioned.
- `$task->is_actioned(): bool`: Checks if the task has been actioned.
- `$task->is_task_actioned($id): bool`: Checks if a specific task has been actioned.
- `$task->sort($a, $b, $sort_by): int`: Sorts tasks based on given sort criteria.
- `$task->get_json(): array`: Returns the task data as a JSON-formatted array.
- `id` (int) - Task ID.
- `title` (string) - Task title.
- `canView` (bool) - If a task should be viewable on a given store.
- `content` (string) - Task content.
- `additionalInfo` (object) - Additional extensible information about the task.
- `actionLabel` (string) - The label used for the action button.
- `actionUrl` (string) - The URL used when clicking the task if no task card is required.
- `isComplete` (bool) - If the task has been completed or not.
- `time` (string) - Length of time to complete the task.
- `level` (integer) - A priority for task list sorting.
- `isActioned` (bool) - If a task has been actioned.
- `isDismissed` (bool) - If a task has been dismissed.
- `isDismissable` (bool) - Whether or not a task is dismissable.
- `isSnoozed` (bool) - If a task has been snoozed.
- `isSnoozeable` (bool) - Whether or not a task can be snoozed.
- `snoozedUntil` (int) - Timestamp in milliseconds that the task has been snoozed until.
## Frontend
We use the `@woocommerce/onboarding` package to render the onboarding task lists on the frontend and use the `@woocommerce/data` package to interact with the onboarding store.
### Data store actions
Using the `@woocommerce/data` package, the following selectors and actions are available to interact with the task lists under the onboarding store.
@ -125,64 +283,63 @@ const { taskLists } = useSelect( ( select ) => {
} );
```
- `getTaskLists` - (select) Resolve any registered task lists with their nested tasks
- `hideTaskList( id )` - (dispatch) Hide a task list
- `actionTask( id )` - (dispatch) Mark a task as actioned
- `dismissTask( id )` - (dispatch) Dismiss a task
- `undoDismissTask( id )` - (dispatch) Undo task dismiss
- `optimisticallyCompleteTask( id )` - (dispatch) Optimistically mark a task as complete
* `getTaskLists` - (select) Resolve any registered task lists with their nested tasks
* `hideTaskList( id )` - (dispatch) Hide a task list
* `actionTask( id )` - (dispatch) Mark a task as actioned
* `snoozeTask( id )` - (dispatch) Snooze a task
* `dismissTask( id )` - (dispatch) Dismiss a task
* `optimisticallyCompleteTask( id )` - (dispatch) Optimistically mark a task as complete
### API Endpoints
The following REST endpoints are available to interact with tasks. For ease of use, we recommend using the data store actions above to interact with these endpoints.
- `/wc-admin/onboarding/tasks` (GET) - Retrieve all tasks and their statuses
- `/wc-admin/onboarding/tasks/{list_id}/hide` (POST) - Hide a given task list
- `/wc-admin/onboarding/tasks/{task_id}/unhide` (POST) - Un-hide a given task list
- `/wc-admin/onboarding/tasks/{task_id}/dismiss` (POST) - Dismiss a task
- `/wc-admin/onboarding/tasks/{task_id}/undo_dismiss` (POST) - Undo dismissal of a task
- `/wc-admin/onboarding/tasks/{task_id}/action` (POST) - Mark a task as actioned
### SlotFills
The task UI can be supplemented by registering plugins that fill the provided task slots.
The task UI can be supplemented by registering plugins that fill the provided task slots. Learn more about slot fills in the [SlotFill documentation](https://developer.wordpress.org/block-editor/reference-guides/slotfills/) and [here](https://developer.wordpress.org/block-editor/reference-guides/components/slot-fill/).
#### Task content
### Task content
A task list fill is required if no `action_url` is provided for the task. This is the content shown after a task list item has been clicked.
A task list fill is required if no `action_url` is provided for the task. This is the content shown after a task list item has been clicked.
```js
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
registerPlugin( 'my-task-plugin', {
scope: 'woocommerce-tasks',
render: () => (
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="my-task">
{ ( { onComplete, query } ) => (
<MyTask onComplete={ onComplete } />
{ ( { onComplete, query, task } ) => (
<MyTask onComplete={ onComplete } query={ query } task={ task } />
) }
</WooOnboardingTask>
),
),
} );
```
#### Task list item
The items shown in the list can be customized beyond the default task list item. This can allow for custom appearance or specific `onClick` behavior for your task.
### Task list item
The items shown in the list can be customized beyond the default task list item. This can allow for custom appearance or specific `onClick` behavior for your task. For example, we're using this to install and activate WooCommerce Payments when clicking on the WooCommerce Payment task
```js
import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
registerPlugin( 'my-task-list-item-plugin', {
scope: 'woocommerce-tasks',
render: () => (
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTaskListItem id="appearance">
{ ( { defaultTaskItem, onComplete } ) => (
<MyTaskListItem onComplete={ onComplete } />
) }
</WooOnboardingTaskListItem>
),
),
} );
```
### Endpoints
The following REST endpoints are available to interact with tasks. For ease of use, we recommend using the data store actions above to interact with these endpoints.
* `/wc-admin/onboarding/tasks` (GET) - Retrieve all tasks and their statuses
* `/wc-admin/onboarding/tasks/{list_id}/hide` (POST) - Hide a given task list
* `/wc-admin/onboarding/tasks/{task_id}/dismiss` (POST) - Dismiss a task
* `/wc-admin/onboarding/tasks/{task_id}/undo_dismiss` (POST) - Undo dismissal of a task
* `/wc-admin/onboarding/tasks/{task_id}/snooze` (POST) - Snooze a task for later
* `/wc-admin/onboarding/tasks/{task_id}/undo_snooze` (POST) - Undo snoozing of a task
* `/wc-admin/onboarding/tasks/{task_id}/action` (POST) - Mark a task as actioned

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Added async fetching for extensions and countries lists in new core profiler

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Add new REST endpoints at onboarding/plugins to support async plugin installation with real time error tracking.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Update tasklist documentation/example

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add WooCommerce Admin page class to body of every page

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Fixed the attributes table styling in TT3 tabs content area

View File

@ -595,12 +595,28 @@ ul.wc-tabs {
font-size: var(--wp--preset--font-size--small);
margin-left: 1em;
h2 {
// Hide repeated heading.
h2:first-of-type {
display: none;
}
// Attributes table styles.
table.woocommerce-product-attributes {
text-align: left;
tbody {
td, th {
padding: 0.2rem 0.2rem 0.2rem 0;
p {
margin: 0;
}
}
th {
text-align: left;
padding-right: 1rem;
}
}
}
}

View File

@ -87,6 +87,7 @@ class Init {
'Automattic\WooCommerce\Admin\API\OnboardingProfile',
'Automattic\WooCommerce\Admin\API\OnboardingTasks',
'Automattic\WooCommerce\Admin\API\OnboardingThemes',
'Automattic\WooCommerce\Admin\API\OnboardingPlugins',
'Automattic\WooCommerce\Admin\API\NavigationFavorites',
'Automattic\WooCommerce\Admin\API\Taxes',
'Automattic\WooCommerce\Admin\API\MobileAppMagicLink',

View File

@ -0,0 +1,324 @@
<?php
/**
* REST API Onboarding Profile Controller
*
* Handles requests to /onboarding/profile
*/
namespace Automattic\WooCommerce\Admin\API;
defined( 'ABSPATH' ) || exit;
use ActionScheduler;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsynPluginsInstallLogger;
use WC_REST_Data_Controller;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
/**
* Onboarding Plugins controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class OnboardingPlugins extends WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc-admin';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'onboarding/plugins';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install-async',
array(
array(
'methods' => 'POST',
'callback' => array( $this, 'install_async' ),
'permission_callback' => array( $this, 'can_install_plugins' ),
'args' => array(
'plugins' => array(
'description' => 'A list of plugins to install',
'type' => 'array',
'items' => 'string',
'sanitize_callback' => function ( $value ) {
return array_map(
function ( $value ) {
return sanitize_text_field( $value );
},
$value
);
},
'required' => true,
),
),
),
'schema' => array( $this, 'get_install_async_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/install-and-activate',
array(
array(
'methods' => 'POST',
'callback' => array( $this, 'install_and_activate' ),
'permission_callback' => array( $this, 'can_install_and_activate_plugins' ),
),
'schema' => array( $this, 'get_install_activate_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/scheduled-installs/(?P<job_id>\w+)',
array(
array(
'methods' => 'GET',
'callback' => array( $this, 'get_scheduled_installs' ),
'permission_callback' => array( $this, 'can_install_plugins' ),
),
'schema' => array( $this, 'get_install_async_schema' ),
)
);
}
/**
* Install and activate a plugin.
*
* @param WP_REST_Request $request WP Request object.
*
* @return WP_REST_Response
*/
public function install_and_activate( WP_REST_Request $request ) {
$response = array();
$response['install'] = PluginsHelper::install_plugins( $request->get_param( 'plugins' ) );
$response['activate'] = PluginsHelper::activate_plugins( $response['install']['installed'] );
return new WP_REST_Response( $response );
}
/**
* Queue plugin install request.
*
* @param WP_REST_Request $request WP_REST_Request object.
*
* @return array
*/
public function install_async( WP_REST_Request $request ) {
$plugins = $request->get_param( 'plugins' );
$job_id = uniqid();
WC()->queue()->add( 'woocommerce_plugins_install_async_callback', array( $plugins, $job_id ) );
$plugin_status = array();
foreach ( $plugins as $plugin ) {
$plugin_status[ $plugin ] = array(
'status' => 'pending',
'errors' => array(),
);
}
return array(
'job_id' => $job_id,
'status' => 'pending',
'plugins' => $plugin_status,
);
}
/**
* Returns current status of given job.
*
* @param WP_REST_Request $request WP_REST_Request object.
*
* @return array|WP_REST_Response
*/
public function get_scheduled_installs( WP_REST_Request $request ) {
$job_id = $request->get_param( 'job_id' );
$actions = WC()->queue()->search(
array(
'hook' => 'woocommerce_plugins_install_async_callback',
'search' => $job_id,
'orderby' => 'date',
'order' => 'DESC',
)
);
$actions = array_filter(
PluginsHelper::get_action_data( $actions ),
function( $action ) use ( $job_id ) {
return $action['job_id'] === $job_id;
}
);
if ( empty( $actions ) ) {
return new WP_REST_Response( null, 404 );
}
$response = array(
'job_id' => $actions[0]['job_id'],
'status' => $actions[0]['status'],
);
$option = get_option( 'woocommerce_onboarding_plugins_install_async_' . $job_id );
if ( isset( $option['plugins'] ) ) {
$response['plugins'] = $option['plugins'];
}
return $response;
}
/**
* Check whether the current user has permission to install plugins
*
* @return WP_Error|boolean
*/
public function can_install_plugins() {
if ( ! current_user_can( 'install_plugins' ) ) {
return new WP_Error(
'woocommerce_rest_cannot_update',
__( 'Sorry, you cannot manage plugins.', 'woocommerce' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* Check whether the current user has permission to install and activate plugins
*
* @return WP_Error|boolean
*/
public function can_install_and_activate_plugins() {
if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) {
return new WP_Error(
'woocommerce_rest_cannot_update',
__( 'Sorry, you cannot manage plugins.', 'woocommerce' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* JSON Schema for both install-async and scheduled-installs endpoints.
*
* @return array
*/
public function get_install_async_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'Install Async Schema',
'type' => 'object',
'properties' => array(
'type' => 'object',
'properties' => array(
'job_id' => 'integer',
'status' => array(
'type' => 'string',
'enum' => array( 'pending', 'complete', 'failed' ),
),
),
),
);
}
/**
* JSON Schema for install-and-activate endpoint.
*
* @return array
*/
public function get_install_activate_schema() {
$error_schema = array(
'type' => 'object',
'patternProperties' => array(
'^.*$' => array(
'type' => 'string',
),
),
'items' => array(
'type' => 'string',
),
);
$install_schema = array(
'type' => 'object',
'properties' => array(
'installed' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'results' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'errors' => array(
'type' => 'object',
'properties' => array(
'errors' => $error_schema,
'error_data' => $error_schema,
),
),
),
);
$activate_schema = array(
'type' => 'object',
'properties' => array(
'activated' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'active' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'errors' => array(
'type' => 'object',
'properties' => array(
'errors' => $error_schema,
'error_data' => $error_schema,
),
),
),
);
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'Install and Activate Schema',
'type' => 'object',
'properties' => array(
'type' => 'object',
'properties' => array(
'install' => $install_schema,
'activate' => $activate_schema,
),
),
);
}
}

View File

@ -7,6 +7,16 @@
namespace Automattic\WooCommerce\Admin;
use ActionScheduler;
use ActionScheduler_DBStore;
use ActionScheduler_QueueRunner;
use Automatic_Upgrader_Skin;
use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsyncPluginsInstallLogger;
use Automattic\WooCommerce\Admin\PluginsInstallLoggers\PluginsInstallLogger;
use Plugin_Upgrader;
use WP_Error;
use WP_Upgrader;
defined( 'ABSPATH' ) || exit;
if ( ! function_exists( 'get_plugins' ) ) {
@ -23,6 +33,7 @@ class PluginsHelper {
*/
public static function init() {
add_action( 'woocommerce_plugins_install_callback', array( __CLASS__, 'install_plugins' ), 10, 2 );
add_action( 'woocommerce_plugins_install_async_callback', array( __CLASS__, 'install_plugins_async_callback' ), 10, 2 );
add_action( 'woocommerce_plugins_activate_callback', array( __CLASS__, 'activate_plugins' ), 10, 2 );
}
@ -60,8 +71,9 @@ class PluginsHelper {
*/
public static function get_installed_plugin_slugs() {
return array_map(
function( $plugin_path ) {
function ( $plugin_path ) {
$path_parts = explode( '/', $plugin_path );
return $path_parts[0];
},
array_keys( get_plugins() )
@ -93,8 +105,9 @@ class PluginsHelper {
*/
public static function get_active_plugin_slugs() {
return array_map(
function( $plugin_path ) {
function ( $plugin_path ) {
$path_parts = explode( '/', $plugin_path );
return $path_parts[0];
},
get_option( 'active_plugins', array() )
@ -110,6 +123,7 @@ class PluginsHelper {
*/
public static function is_plugin_installed( $plugin ) {
$plugin_path = self::get_plugin_path_from_slug( $plugin );
return $plugin_path ? array_key_exists( $plugin_path, get_plugins() ) : false;
}
@ -122,6 +136,7 @@ class PluginsHelper {
*/
public static function is_plugin_active( $plugin ) {
$plugin_path = self::get_plugin_path_from_slug( $plugin );
return $plugin_path ? in_array( $plugin_path, get_option( 'active_plugins', array() ), true ) : false;
}
@ -142,20 +157,26 @@ class PluginsHelper {
/**
* Install an array of plugins.
*
* @param array $plugins Plugins to install.
* @param array $plugins Plugins to install.
* @param PluginsInstallLogger|null $logger an optional logger.
*
* @return array
*/
public static function install_plugins( $plugins ) {
public static function install_plugins( $plugins, PluginsInstallLogger $logger = null ) {
/**
* Filter the list of plugins to install.
*
* @param array $plugins A list of the plugins to install.
*
* @since 6.4.0
*/
$plugins = apply_filters( 'woocommerce_admin_plugins_pre_install', $plugins );
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new \WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ) );
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' )
);
}
require_once ABSPATH . 'wp-admin/includes/plugin.php';
@ -169,13 +190,15 @@ class PluginsHelper {
$installed_plugins = array();
$results = array();
$time = array();
$errors = new \WP_Error();
$errors = new WP_Error();
foreach ( $plugins as $plugin ) {
$slug = sanitize_key( $plugin );
$logger && $logger->install_requested( $plugin );
if ( isset( $existing_plugins[ $slug ] ) ) {
$installed_plugins[] = $plugin;
$logger && $logger->installed( $plugin, 0 );
continue;
}
@ -193,8 +216,14 @@ class PluginsHelper {
if ( is_wp_error( $api ) ) {
$properties = array(
/* translators: %s: plugin slug (example: woocommerce-services) */
'error_message' => sprintf( __( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ), $slug ),
'error_message' => sprintf(
// translators: %s: plugin slug (example: woocommerce-services).
__(
'The requested plugin `%s` could not be installed. Plugin API call failed.',
'woocommerce'
),
$slug
),
'api_error_message' => $api->get_error_message(),
'slug' => $slug,
);
@ -204,32 +233,40 @@ class PluginsHelper {
* Action triggered when a plugin API call failed.
*
* @param string $slug The plugin slug.
* @param \WP_Error $api The API response.
* @param WP_Error $api The API response.
*
* @since 6.4.0
*/
do_action( 'woocommerce_plugins_install_api_error', $slug, $api );
$errors->add(
$plugin,
sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ),
$slug
)
$error_message = sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ),
$slug
);
$errors->add( $plugin, $error_message );
$logger && $logger->add_error( $plugin, $error_message );
continue;
}
$upgrader = new \Plugin_Upgrader( new \Automatic_Upgrader_Skin() );
$result = $upgrader->install( $api->download_link );
$upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
$result = $upgrader->install( $api->download_link );
// result can be false or WP_Error.
$results[ $plugin ] = $result;
$time[ $plugin ] = round( ( microtime( true ) - $start_time ) * 1000 );
if ( is_wp_error( $result ) || is_null( $result ) ) {
$properties = array(
/* translators: %s: plugin slug (example: woocommerce-services) */
'error_message' => sprintf( __( 'The requested plugin `%s` could not be installed.', 'woocommerce' ), $slug ),
'error_message' => sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__(
'The requested plugin `%s` could not be installed.',
'woocommerce'
),
$slug
),
'slug' => $slug,
'api_version' => $api->version,
'api_download_link' => $api->download_link,
@ -243,26 +280,33 @@ class PluginsHelper {
*
* @param string $slug The plugin slug.
* @param object $api The plugin API object.
* @param \WP_Error|null $result The result of the plugin installation.
* @param \Plugin_Upgrader $upgrader The plugin upgrader.
* @param WP_Error|null $result The result of the plugin installation.
* @param Plugin_Upgrader $upgrader The plugin upgrader.
*
* @since 6.4.0
*/
*/
do_action( 'woocommerce_plugins_install_error', $slug, $api, $result, $upgrader );
$install_error_message = sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__( 'The requested plugin `%s` could not be installed. Upgrader install failed.', 'woocommerce' ),
$slug
);
$errors->add(
$plugin,
sprintf(
/* translators: %s: plugin slug (example: woocommerce-services) */
__( 'The requested plugin `%s` could not be installed. Upgrader install failed.', 'woocommerce' ),
$slug
)
$install_error_message
);
$logger && $logger->add_error( $plugin, $install_error_message );
continue;
}
$installed_plugins[] = $plugin;
$logger && $logger->installed( $plugin, $time[ $plugin ] );
}
$logger && $logger->complete();
$data = array(
'installed' => $installed_plugins,
'results' => $results,
@ -273,19 +317,40 @@ class PluginsHelper {
return $data;
}
/**
* Callback regsitered by OnboardingPlugins::install_async.
*
* It is used to call install_plugins with a custom logger.
*
* @param array $plugins A list of plugins to install.
* @param string $job_id An unique job I.D.
* @return bool
*/
public function install_plugins_async_callback( array $plugins, string $job_id ) {
$option_name = 'woocommerce_onboarding_plugins_install_async_' . $job_id;
$logger = new AsyncPluginsInstallLogger( $option_name );
self::install_plugins( $plugins, $logger );
return true;
}
/**
* Schedule plugin installation.
*
* @param array $plugins Plugins to install.
*
* @return string Job ID.
*/
public static function schedule_install_plugins( $plugins ) {
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new \WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' ),
404
);
}
$job_id = uniqid();
WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_install_callback', array( $plugins, $job_id ) );
WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_install_callback', array( $plugins ) );
return $job_id;
}
@ -294,11 +359,16 @@ class PluginsHelper {
* Activate the requested plugins.
*
* @param array $plugins Plugins.
*
* @return WP_Error|array Plugin Status
*/
public static function activate_plugins( $plugins ) {
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new \WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' ),
404
);
}
require_once ABSPATH . 'wp-admin/includes/plugin.php';
@ -310,12 +380,13 @@ class PluginsHelper {
* Filter the list of plugins to activate.
*
* @param array $plugins A list of the plugins to activate.
*
* @since 6.4.0
*/
$plugins = apply_filters( 'woocommerce_admin_plugins_pre_activate', $plugins );
$plugin_paths = self::get_installed_plugins_paths();
$errors = new \WP_Error();
$errors = new WP_Error();
$activated_plugins = array();
foreach ( $plugins as $plugin ) {
@ -337,7 +408,8 @@ class PluginsHelper {
* Action triggered when a plugin activation fails.
*
* @param string $slug The plugin slug.
* @param null|\WP_Error $result The result of the plugin activation.
* @param null|WP_Error $result The result of the plugin activation.
*
* @since 6.4.0
*/
do_action( 'woocommerce_plugins_activate_error', $slug, $result );
@ -366,15 +438,24 @@ class PluginsHelper {
* Schedule plugin activation.
*
* @param array $plugins Plugins to activate.
*
* @return string Job ID.
*/
public static function schedule_activate_plugins( $plugins ) {
if ( empty( $plugins ) || ! is_array( $plugins ) ) {
return new \WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 );
return new WP_Error(
'woocommerce_plugins_invalid_plugins',
__( 'Plugins must be a non-empty array.', 'woocommerce' ),
404
);
}
$job_id = uniqid();
WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_activate_callback', array( $plugins, $job_id ) );
WC()->queue()->schedule_single(
time() + 5,
'woocommerce_plugins_activate_callback',
array( $plugins, $job_id )
);
return $job_id;
}
@ -383,6 +464,7 @@ class PluginsHelper {
* Installation status.
*
* @param int $job_id Job ID.
*
* @return array Job data.
*/
public static function get_installation_status( $job_id = null ) {
@ -402,14 +484,14 @@ class PluginsHelper {
* Gets the plugin data for the first action.
*
* @param array $actions Array of AS actions.
*
* @return array Array of action data.
*/
public static function get_action_data( $actions ) {
$data = [];
$data = array();
foreach ( $actions as $action_id => $action ) {
$store = new \ActionScheduler_DBStore();
$status = $store->get_status( $action_id );
$store = new ActionScheduler_DBStore();
$args = $action->get_args();
$data[] = array(
'job_id' => $args[1],
@ -425,6 +507,7 @@ class PluginsHelper {
* Activation status.
*
* @param int $job_id Job ID.
*
* @return array Array of action data.
*/
public static function get_activation_status( $job_id = null ) {

View File

@ -0,0 +1,134 @@
<?php
namespace Automattic\WooCommerce\Admin\PluginsInstallLoggers;
/**
* A logger to log plugin installation progress in real time to an option.
*/
class AsyncPluginsInstallLogger implements PluginsInstallLogger {
/**
* Variable to store logs.
*
* @var string $option_name option name to store logs.
*/
private $option_name;
/**
* Constructor.
*
* @param string $option_name option name.
*/
public function __construct( string $option_name ) {
$this->option_name = $option_name;
add_option(
$this->option_name,
array(
'created_time' => time(),
'status' => 'pending',
'plugins' => array(),
),
'',
'no'
);
// Set status as failed in case we run out of exectuion time.
register_shutdown_function(
function () {
$error = error_get_last();
if ( isset( $error['type'] ) && E_ERROR === $error['type'] ) {
$option = $this->get();
$option['status'] = 'failed';
$this->update( $option );
}
}
);
}
/**
* Update the option.
*
* @param array $data New data.
*
* @return bool
*/
private function update( array $data ) {
return update_option( $this->option_name, $data );
}
/**
* Retreive the option.
*
* @return false|mixed|void
*/
private function get() {
return get_option( $this->option_name );
}
/**
* Add requested plugin.
*
* @param string $plugin_name plugin name.
*
* @return void
*/
public function install_requested( string $plugin_name ) {
$option = $this->get();
if ( ! isset( $option['plugins'][ $plugin_name ] ) ) {
$option['plugins'][ $plugin_name ] = array(
'status' => 'installing',
'errors' => array(),
'install_duration' => 0,
);
}
$this->update( $option );
}
/**
* Add installed plugin.
*
* @param string $plugin_name plugin name.
* @param int $duration time took to install plugin.
*
* @return void
*/
public function installed( string $plugin_name, int $duration ) {
$option = $this->get();
$option['plugins'][ $plugin_name ]['status'] = 'installed';
$option['plugins'][ $plugin_name ]['install_duration'] = $duration;
$this->update( $option );
}
/**
* Add an error.
*
* @param string $plugin_name plugin name.
* @param string|null $error_message error message.
*
* @return void
*/
public function add_error( string $plugin_name, string $error_message = null ) {
$option = $this->get();
$option['plugins'][ $plugin_name ]['errors'][] = $error_message;
$option['plugins'][ $plugin_name ]['status'] = 'failed';
$option['status'] = 'failed';
$this->update( $option );
}
/**
* Record completed_time.
*
* @return void
*/
public function complete() {
$option = $this->get();
$option['complete_time'] = time();
$option['status'] = 'complete';
$this->update( $option );
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Automattic\WooCommerce\Admin\PluginsInstallLoggers;
/**
* A logger used in PluginsHelper::install_plugins to log the installation progress.
*/
interface PluginsInstallLogger {
/**
* Called when a plugin install requested.
*
* @param string $plugin_name plugin name.
* @return mixed
*/
public function install_requested( string $plugin_name );
/**
* Called when a plugin installed successfully.
*
* @param string $plugin_name plugin name.
* @param int $duration # of seconds it took to install $plugin_name.
* @return mixed
*/
public function installed( string $plugin_name, int $duration);
/**
* Called when an error occurred while installing a plugin.
*
* @param string $plugin_name plugin name.
* @param string|null $error_message error message.
* @return mixed
*/
public function add_error( string $plugin_name, string $error_message = null);
/**
* Called when all plugins are processed.
*
* @return mixed
*/
public function complete();
}

View File

@ -0,0 +1,160 @@
<?php
/**
* Test the API controller class that handles the onboarding plugins REST endpoints.
*
* @package WooCommerce\Admin\Tests\Admin\API
*/
namespace Automattic\WooCommerce\Tests\Admin\API;
use WC_REST_Unit_Test_Case;
use WP_REST_Request;
/**
* OnboardingPlugins API controller test.
*
* @class OnboardingPluginsTest.
*/
class OnboardingPluginsTest extends WC_REST_Unit_Test_Case {
/**
* Endpoint.
*
* @var string
*/
const ENDPOINT = '/wc-admin/onboarding/plugins';
/**
* Set up.
*/
public function setUp(): void {
parent::setUp();
$this->useAdmin();
}
/**
* Use a user with administrator role.
*
* @return void
*/
public function useAdmin() {
// Register an administrator user and log in.
$this->user = $this->factory->user->create(
array(
'role' => 'administrator',
)
);
wp_set_current_user( $this->user );
}
/**
* Use a user without any permissions.
*
* @return void
*/
public function useUserWithoutPluginsPermission() {
$this->user = $this->factory->user->create();
wp_set_current_user( $this->user );
}
/**
* Request to install-async endpoint.
*
* @param string $endpoint Request endpoint.
* @param string $body Request body.
*
* @return mixed
*/
private function request( $endpoint, $body ) {
$request = new WP_REST_Request( 'POST', self::ENDPOINT . $endpoint );
$request->set_header( 'content-type', 'application/json' );
$request->set_body( $body );
$response = $this->server->dispatch( $request );
return $response;
}
/**
* Request to scheduled-installs endpoint.
*
* @param string $job_id job id.
*
* @return mixed
*/
private function get( $job_id ) {
$request = new WP_REST_Request( 'GET', self::ENDPOINT . '/scheduled-installs/' . $job_id );
return $this->server->dispatch( $request )->get_data();
}
/**
* Test to confirm install-async response format.
*
* @return void
*/
public function test_response_format() {
$data = $this->request(
'/install-async',
wp_json_encode(
array(
'plugins' => array( 'test' ),
)
)
)->get_data();
$this->assertArrayHasKey( 'job_id', $data );
$this->assertArrayHasKey( 'status', $data );
$this->assertArrayHasKey( 'plugins', $data );
$this->assertTrue( isset( $data['plugins']['test'] ) );
}
/**
* Test to confirm it queues an action scheduler job.
*
* @return void
*/
public function test_it_queues_action() {
$this->markTestSkipped( 'Skipping it for now until we find a better way of testing it.' );
$data = $this->request(
'/install-async',
wp_json_encode(
array(
'plugins' => array( 'test' ),
)
)
)->get_data();
$action_id = $data['job_id'];
$data = $this->get( $action_id );
$this->assertIsArray( $data );
$this->assertEquals( $action_id, $data['job_id'] );
}
/**
* Test it returns 404 when an unknown job id is given.
*
* @return void
*/
public function test_it_returns_404_with_unknown_job_id() {
$request = new WP_REST_Request( 'GET', self::ENDPOINT . '/scheduled-installs/i-do-not-exist' );
$response = $this->server->dispatch( $request );
$this->assertEquals( 404, $response->get_status() );
}
/**
* Test permissions.
*
* @return void
*/
public function test_permissions() {
$this->useUserWithoutPluginsPermission();
foreach ( array( '/install-and-activate', '/install-async' ) as $endpoint ) {
$response = $this->request(
$endpoint,
wp_json_encode(
array(
'plugins' => array( 'test' ),
)
)
);
$this->assertEquals( 403, $response->get_status() );
}
}
}

View File

@ -20,7 +20,7 @@ import {
} from '../../../core/github/repo';
import { WPIncrement } from '../../../core/version';
import { Logger } from '../../../core/logger';
import { getEnvVar } from '../../../core/environment';
import { isGithubCI } from '../../../core/environment';
const getNextReleaseBranch = async ( options: {
owner?: string;
@ -57,7 +57,7 @@ export const branchCommand = new Command( 'branch' )
)
.action( async ( options ) => {
const { source, branch, owner, name, dryRun } = options;
const isGithub = getEnvVar( 'CI' );
const isGithub = isGithubCI();
let nextReleaseBranch;

View File

@ -12,7 +12,7 @@ import { octokitWithAuth } from '../../../core/github/api';
import { setGithubMilestoneOutputs } from './utils';
import { WPIncrement } from '../../../core/version';
import { Logger } from '../../../core/logger';
import { getEnvVar } from '../../../core/environment';
import { isGithubCI } from '../../../core/environment';
export const milestoneCommand = new Command( 'milestone' )
.description( 'Create a milestone' )
@ -33,7 +33,7 @@ export const milestoneCommand = new Command( 'milestone' )
)
.action( async ( options ) => {
const { owner, name, dryRun, milestone } = options;
const isGithub = getEnvVar( 'CI' );
const isGithub = isGithubCI();
if ( milestone && isGithub ) {
Logger.error(

View File

@ -14,7 +14,7 @@ import {
getFutureDate,
} from './utils';
import { Logger } from '../../../core/logger';
import { getEnvVar } from '../../../core/environment';
import { isGithubCI } from '../../../core/environment';
export const verifyDayCommand = new Command( 'verify-day' )
.description( 'Verify if today is the code freeze day' )
@ -40,7 +40,7 @@ export const verifyDayCommand = new Command( 'verify-day' )
`Today is ${ isCodeFreezeDay ? 'indeed' : 'not' } code freeze day.`
);
if ( getEnvVar( 'CI' ) ) {
if ( isGithubCI() ) {
setOutput( 'freeze', isCodeFreezeDay.toString() );
}

View File

@ -0,0 +1,25 @@
/**
* Internal dependencies
*/
import { isGithubCI } from '../environment';
describe( 'isGithubCI', () => {
it( 'should return true if GITHUB_ACTIONS is true', () => {
process.env.GITHUB_ACTIONS = 'true';
expect( isGithubCI() ).toBe( true );
} );
it( 'should return false if GITHUB_ACTIONS is false', () => {
process.env.GITHUB_ACTIONS = 'false';
expect( isGithubCI() ).toBe( false );
} );
it( 'should return false if GITHUB_ACTIONS is not set', () => {
process.env.GITHUB_ACTIONS = undefined;
expect( isGithubCI() ).toBe( false );
} );
afterAll( () => {
delete process.env.GITHUB_ACTIONS;
} );
} );

View File

@ -36,7 +36,7 @@ describe( 'Logger', () => {
Logger.error( error );
expect( global.console.error ).toHaveBeenCalledWith(
chalk.red( error.message )
chalk.red( `${ error.message }\n${ error.stack }` )
);
} );