Merge branch 'trunk' into e2e/remove-daily-playwright-config
This commit is contained in:
commit
94b665817c
|
@ -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;
|
|
@ -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 }}"
|
||||
}
|
||||
})
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add error specific messages to product save functionality
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: fix
|
||||
|
||||
Fix double scrollbars on product editor page
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' )
|
||||
);
|
||||
},
|
||||
} );
|
||||
|
|
|
@ -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 );
|
||||
},
|
||||
} );
|
||||
|
||||
|
|
|
@ -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' )
|
||||
);
|
||||
},
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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 ) => {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 ] );
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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' );
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
),
|
||||
} );
|
||||
|
|
|
@ -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 |
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Added async fetching for extensions and countries lists in new core profiler
|
|
@ -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.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
|
||||
Update tasklist documentation/example
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add WooCommerce Admin page class to body of every page
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: enhancement
|
||||
|
||||
Fixed the attributes table styling in TT3 tabs content area
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 ) {
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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() );
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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() );
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
} );
|
||||
} );
|
|
@ -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 }` )
|
||||
);
|
||||
} );
|
||||
|
||||
|
|
Loading…
Reference in New Issue