diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index fe570a31e880..7224674f5cb4 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -232,6 +232,32 @@ jobs: name: double-zipped-woocommerce.${{ needs.code-freeze-prep.outputs.acceleratedVersion }} path: plugins/woocommerce/woocommerce.zip retention-days: 2 + + r2-upload-monthly: + name: Upload Beta to R2 + runs-on: ubuntu-20.04 + needs: [ code-freeze-prep, build-monthly ] + if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }} + steps: + - id: download + uses: actions/download-artifact@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: double-zipped-woocommerce.${{ needs.code-freeze-prep.outputs.monthlyVersion }}-beta.1 + path: download + + - run: ls -lah ${{steps.download.outputs.download-path}} + + - name: Upload release zip to Cloudflare R2 bucket + uses: ryand56/r2-upload-action@de3eabc2e3137ce07bc3805af441df55f535c64b + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + source-dir: ${{ steps.download.outputs.download-path }} + destination-dir: ./monthly/ slack-upload-monthly: name: Upload Beta to Slack @@ -278,6 +304,32 @@ jobs: run : | pnpm utils slack file "${{ secrets.CODE_FREEZE_BOT_TOKEN }}" "Here's the generated release build for ${{ needs.code-freeze-prep.outputs.monthlyVersion }}-beta.1" "${{ steps.download.outputs.download-path }}/woocommerce.zip" "${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}" --reply-ts ${{ needs.notify-slack.outputs.ts }} --filename "woocommerce.${{ needs.code-freeze-prep.outputs.monthlyVersion }}-beta.1.zip" + r2-upload-accelerated: + name: Upload Accelerated to R2 + runs-on: ubuntu-20.04 + needs: [ code-freeze-prep, build-a ] + if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }} + steps: + - id: download + uses: actions/download-artifact@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: double-zipped-woocommerce.${{ needs.code-freeze-prep.outputs.acceleratedVersion }} + path: download + + - run: ls -lah ${{steps.download.outputs.download-path}} + + - name: Upload release zip to Cloudflare R2 bucket + uses: ryand56/r2-upload-action@de3eabc2e3137ce07bc3805af441df55f535c64b + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET }} + source-dir: ${{ steps.download.outputs.download-path }} + destination-dir: ./accelerated/ + slack-upload-accelerated: name: Upload Accelerated to Slack runs-on: ubuntu-20.04 diff --git a/packages/js/explat/CHANGELOG.md b/packages/js/explat/CHANGELOG.md index 6749cf776237..030b3d1cdf63 100644 --- a/packages/js/explat/CHANGELOG.md +++ b/packages/js/explat/CHANGELOG.md @@ -1,13 +1,13 @@ -# Changelog +# Changelog This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.3.0](https://www.npmjs.com/package/@woocommerce/packages/js/explat/v/2.3.0) - 2022-07-08 +## [2.3.0](https://www.npmjs.com/package/@woocommerce/packages/js/explat/v/2.3.0) - 2022-07-08 - Patch - Fix fetchExperimentAssignment response - Minor - Remove PHP and Composer dependencies for packaged JS packages -## [2.2.0](https://www.npmjs.com/package/@woocommerce/packages/js/explat/v/2.2.0) - 2022-06-15 +## [2.2.0](https://www.npmjs.com/package/@woocommerce/packages/js/explat/v/2.2.0) - 2022-06-15 - Patch - Added useExperiment example - Patch - Standardize lint scripts: add lint:fix diff --git a/packages/js/explat/README.md b/packages/js/explat/README.md index c17c45828b0f..4d51ef29d9b4 100644 --- a/packages/js/explat/README.md +++ b/packages/js/explat/README.md @@ -1,6 +1,6 @@ # ExPlat -This packages includes a component and utility functions that can be used to run A/B Tests in WooCommerce dashboard and reports pages. +This packages includes a component and utility functions that can be used to run A/B Tests in WooCommerce dashboard, report pages, and frontend pages. ## Installation diff --git a/packages/js/explat/changelog/45131-add-wca-support-to-explat b/packages/js/explat/changelog/45131-add-wca-support-to-explat new file mode 100644 index 000000000000..ada80d5b1abe --- /dev/null +++ b/packages/js/explat/changelog/45131-add-wca-support-to-explat @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add support for WooCommerce Analytics. \ No newline at end of file diff --git a/packages/js/explat/changelog/add-wca-support-to-explat b/packages/js/explat/changelog/add-wca-support-to-explat new file mode 100644 index 000000000000..e99bdba62433 --- /dev/null +++ b/packages/js/explat/changelog/add-wca-support-to-explat @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Support running A/B testing in WooCommerce frontend diff --git a/packages/js/explat/src/anon.ts b/packages/js/explat/src/anon.ts index afa6da00fcf3..cc58b9770dfb 100644 --- a/packages/js/explat/src/anon.ts +++ b/packages/js/explat/src/anon.ts @@ -3,6 +3,11 @@ */ import cookie from 'cookie'; +/** + * Internal dependencies + */ +import { canTrack } from './assignment'; + let initializeAnonIdPromise: null | Promise< string | null > = null; const anonIdPollingIntervalMilliseconds = 50; const anonIdPollingIntervalMaxAttempts = 100; // 50 * 100 = 5000 = 5 seconds @@ -53,7 +58,7 @@ export const initializeAnonId = async (): Promise< string | null > => { }; export const getAnonId = async (): Promise< string | null > => { - if ( ! window.wcTracks?.isEnabled ) { + if ( ! canTrack ) { return null; } diff --git a/packages/js/explat/src/assignment.ts b/packages/js/explat/src/assignment.ts index 8aed6c147d53..523cd3523246 100644 --- a/packages/js/explat/src/assignment.ts +++ b/packages/js/explat/src/assignment.ts @@ -5,6 +5,9 @@ import { stringify } from 'qs'; import { applyFilters } from '@wordpress/hooks'; import apiFetch from '@wordpress/api-fetch'; +export const canTrack = + window.wcTracks?.isEnabled || window?._wca?.push !== undefined; + const EXPLAT_VERSION = '0.1.0'; type QueryParams = { @@ -83,7 +86,7 @@ export const fetchExperimentAssignment = async ( { experimentName: string; anonId: string | null; } ): Promise< unknown > => { - if ( ! window.wcTracks?.isEnabled ) { + if ( ! canTrack ) { throw new Error( `Tracking is disabled, can't fetch experimentAssignment` ); @@ -111,7 +114,7 @@ export const fetchExperimentAssignmentWithAuth = async ( { experimentName: string; anonId: string | null; } ): Promise< unknown > => { - if ( ! window.wcTracks?.isEnabled ) { + if ( ! canTrack ) { throw new Error( `Tracking is disabled, can't fetch experimentAssignment` ); diff --git a/packages/js/explat/src/error.ts b/packages/js/explat/src/error.ts index 919e1fb7b0de..860a69b8c190 100644 --- a/packages/js/explat/src/error.ts +++ b/packages/js/explat/src/error.ts @@ -26,7 +26,7 @@ export const logError = ( if ( isDevelopmentMode ) { console.error( '[ExPlat] ', error.message, error ); // eslint-disable-line no-console } else { - if ( ! window.wcTracks?.isEnabled ) { + if ( ! window.wcTracks?.isEnabled && ! window?._wca?.push ) { throw new Error( `Tracking is disabled, can't send error to the server` ); diff --git a/packages/js/explat/src/index.ts b/packages/js/explat/src/index.ts index 2a77b5ec6cdc..0d3e884f0cb5 100644 --- a/packages/js/explat/src/index.ts +++ b/packages/js/explat/src/index.ts @@ -12,6 +12,7 @@ import { logError } from './error'; import { fetchExperimentAssignment, fetchExperimentAssignmentWithAuth, + canTrack, } from './assignment'; import { getAnonId, initializeAnonId } from './anon'; declare global { @@ -20,11 +21,14 @@ declare global { isEnabled: boolean; enable?: ( cb: () => void ) => void; }; + _wca: { + push?: ( cb: () => void ) => void; + }; } } export const initializeExPlat = (): void => { - if ( window.wcTracks?.isEnabled ) { + if ( canTrack ) { initializeAnonId().catch( ( e ) => logError( { message: e.message } ) ); } }; diff --git a/packages/js/explat/src/test/assignment-test.js b/packages/js/explat/src/test/assignment-test.js index 424039726f3b..860f671592cc 100644 --- a/packages/js/explat/src/test/assignment-test.js +++ b/packages/js/explat/src/test/assignment-test.js @@ -1,3 +1,7 @@ +// Define that tracking is enabled before import +// so that assignments can get the correct value. +global.wcTracks.isEnabled = true; + /** * External dependencies */ @@ -16,7 +20,6 @@ global.fetch = jest.fn().mockImplementation( () => status: 200, } ) ); -global.wcTracks.isEnabled = true; const fetchMock = jest.spyOn( global, 'fetch' ); diff --git a/packages/js/product-editor/changelog/fix-product-editor-crashes-text-area-toolbar b/packages/js/product-editor/changelog/fix-product-editor-crashes-text-area-toolbar new file mode 100644 index 000000000000..dc087c7cac6e --- /dev/null +++ b/packages/js/product-editor/changelog/fix-product-editor-crashes-text-area-toolbar @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Product Editor: Workaround a Gutenberg bug that resulted in a crash when clicking in the margin of the editor when the summary or description fields were focused, by reverting the changes that were made in #44166. diff --git a/packages/js/product-editor/src/blocks/generic/tab/editor.scss b/packages/js/product-editor/src/blocks/generic/tab/editor.scss index a8d2affc91ff..7cb557a48a87 100644 --- a/packages/js/product-editor/src/blocks/generic/tab/editor.scss +++ b/packages/js/product-editor/src/blocks/generic/tab/editor.scss @@ -4,19 +4,6 @@ } } -.wp-block-woocommerce-product-tab { - padding-left: calc( 2 * $gap ); - padding-right: calc( 2 * $gap ); - - @include breakpoint(">782px") { - padding-left: 0; - padding-right: 0; - max-width: 650px; - margin-left: auto; - margin-right: auto; - } -} - .woocommerce-product-tabs { .wp-block-woocommerce-product-tab__button:focus:not( :disabled ) { box-shadow: none; diff --git a/packages/js/product-editor/src/blocks/generic/text-area/edit.tsx b/packages/js/product-editor/src/blocks/generic/text-area/edit.tsx index 4f07ab6b4b32..498fe23c55f6 100644 --- a/packages/js/product-editor/src/blocks/generic/text-area/edit.tsx +++ b/packages/js/product-editor/src/blocks/generic/text-area/edit.tsx @@ -18,6 +18,7 @@ import type { TextAreaBlockEditProps, } from './types'; import AligmentToolbarButton from './toolbar/toolbar-button-alignment'; +import { useClearSelectedBlockOnBlur } from '../../../hooks/use-clear-selected-block-on-blur'; import useProductEntityProp from '../../../hooks/use-product-entity-prop'; import { Label } from '../../../components/label/label'; @@ -61,6 +62,11 @@ export function TextAreaBlockEdit( { postType, } ); + // This is a workaround to hide the toolbar when the block is blurred. + // This is a temporary solution until using Gutenberg 18 with the + // fix from https://github.com/WordPress/gutenberg/pull/59800 + const { handleBlur: hideToolbar } = useClearSelectedBlockOnBlur(); + function setAlignment( value: TextAreaBlockEditAttributes[ 'align' ] ) { setAttributes( { align: value } ); } @@ -120,6 +126,7 @@ export function TextAreaBlockEdit( { placeholder={ placeholder } required={ required } disabled={ disabled } + onBlur={ hideToolbar } /> ) } @@ -130,6 +137,7 @@ export function TextAreaBlockEdit( { placeholder={ placeholder } required={ required } disabled={ disabled } + onBlur={ hideToolbar } /> ) } diff --git a/packages/js/product-editor/src/blocks/product-fields/summary/edit.tsx b/packages/js/product-editor/src/blocks/product-fields/summary/edit.tsx index dce9da2ededd..c8b7769d044a 100644 --- a/packages/js/product-editor/src/blocks/product-fields/summary/edit.tsx +++ b/packages/js/product-editor/src/blocks/product-fields/summary/edit.tsx @@ -23,6 +23,7 @@ import { ParagraphRTLControl } from './paragraph-rtl-control'; import { SummaryAttributes } from './types'; import { ALIGNMENT_CONTROLS } from './constants'; import { ProductEditorBlockEditProps } from '../../../types'; +import { useClearSelectedBlockOnBlur } from '../../../hooks/use-clear-selected-block-on-blur'; export function SummaryBlockEdit( { attributes, @@ -44,6 +45,11 @@ export function SummaryBlockEdit( { attributes.property ); + // This is a workaround to hide the toolbar when the block is blurred. + // This is a temporary solution until using Gutenberg 18 with the + // fix from https://github.com/WordPress/gutenberg/pull/59800 + const { handleBlur: hideToolbar } = useClearSelectedBlockOnBlur(); + function handleAlignmentChange( value: SummaryAttributes[ 'align' ] ) { setAttributes( { align: value } ); } @@ -114,6 +120,7 @@ export function SummaryBlockEdit( { } ) } dir={ direction } allowedFormats={ allowedFormats } + onBlur={ hideToolbar } /> diff --git a/packages/js/product-editor/src/components/block-editor/style.scss b/packages/js/product-editor/src/components/block-editor/style.scss index 6d6d1c06585b..dc9dae5c50ee 100644 --- a/packages/js/product-editor/src/components/block-editor/style.scss +++ b/packages/js/product-editor/src/components/block-editor/style.scss @@ -147,7 +147,17 @@ .block-editor-block-list__layout { &.is-root-container { + padding-left: 0; + padding-right: 0; padding-bottom: 128px; + margin-left: calc(2 * $gap); + margin-right: calc(2 * $gap); + + @include breakpoint(">782px") { + max-width: 650px; + margin-left: auto; + margin-right: auto; + } } .block-editor-block-list__block { diff --git a/packages/js/product-editor/src/hooks/index.ts b/packages/js/product-editor/src/hooks/index.ts index 11ad28dc86c4..b62f5e483730 100644 --- a/packages/js/product-editor/src/hooks/index.ts +++ b/packages/js/product-editor/src/hooks/index.ts @@ -10,3 +10,4 @@ export { useProductTemplate as __experimentalUseProductTemplate } from './use-pr export { useProductScheduled as __experimentalUseProductScheduled } from './use-product-scheduled'; export { useProductManager as __experimentalUseProductManager } from './use-product-manager'; export { useMetaboxHiddenProduct as __experimentalUseMetaboxHiddenProduct } from './use-metabox-hidden-product'; +export { useClearSelectedBlockOnBlur as __experimentalClearSelectedBlockOnBlur } from './use-clear-selected-block-on-blur'; diff --git a/packages/js/product-editor/src/hooks/use-clear-selected-block-on-blur/index.ts b/packages/js/product-editor/src/hooks/use-clear-selected-block-on-blur/index.ts new file mode 100644 index 000000000000..8c5930bc2f52 --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-clear-selected-block-on-blur/index.ts @@ -0,0 +1 @@ +export * from './use-clear-selected-block-on-blur'; diff --git a/packages/js/product-editor/src/hooks/use-clear-selected-block-on-blur/use-clear-selected-block-on-blur.ts b/packages/js/product-editor/src/hooks/use-clear-selected-block-on-blur/use-clear-selected-block-on-blur.ts new file mode 100644 index 000000000000..67514bde4315 --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-clear-selected-block-on-blur/use-clear-selected-block-on-blur.ts @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +// This is a workaround to hide the toolbar when the block is blurred. +// This is a temporary solution until using Gutenberg 18 with the +// fix from https://github.com/WordPress/gutenberg/pull/59800 +export const useClearSelectedBlockOnBlur = () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore No types for this exist yet. + const { clearSelectedBlock } = useDispatch( blockEditorStore ); + + function handleBlur( event: { + relatedTarget: ( EventTarget & Element ) | null; + } ) { + const isToolbar = event?.relatedTarget?.closest( + '.block-editor-block-contextual-toolbar' + ); + + if ( ! isToolbar ) { + clearSelectedBlock(); + } + } + + return { + handleBlur, + }; +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx index 3a65704b9bbc..40ac15801257 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx @@ -128,7 +128,6 @@ export const Layout = () => { > + + + + +); +export default lessonPLan; diff --git a/plugins/woocommerce-admin/client/customize-store/design-without-ai/services.ts b/plugins/woocommerce-admin/client/customize-store/design-without-ai/services.ts index c47b93230a1d..4b23307f2679 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-without-ai/services.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-without-ai/services.ts @@ -32,7 +32,7 @@ import { FONT_PAIRINGS_WHEN_AI_IS_OFFLINE, FONT_PAIRINGS_WHEN_USER_DID_NOT_ALLOW_TRACKING, } from '../assembler-hub/sidebar/global-styles/font-pairing-variations/constants'; -import { DesignWithoutAIStateMachineContext } from './types'; +import { DesignWithoutAIStateMachineContext, Theme } from './types'; const assembleSite = async () => { await updateTemplate( { @@ -51,9 +51,89 @@ const browserPopstateHandler = }; }; -const installAndActivateTheme = async () => { +const getActiveThemeWithRetries = async (): Promise< Theme[] | null > => { + let retries = 3; + + while ( retries > 0 ) { + const activeThemes = ( await resolveSelect( 'core' ).getEntityRecords( + 'root', + 'theme', + { status: 'active' }, + true + ) ) as Theme[]; + if ( activeThemes ) { + return activeThemes; + } + + retries--; + } + + return null; +}; + +const getCurrentGlobalStylesId = async (): Promise< number | null > => { + const activeThemes = await getActiveThemeWithRetries(); + if ( ! activeThemes ) { + return null; + } + + const currentThemeLinks = activeThemes[ 0 ]?._links; + const url = currentThemeLinks?.[ 'wp:user-global-styles' ]?.[ 0 ]?.href; + const globalStylesObject = ( await apiFetch( { url } ) ) as { id: number }; + + return globalStylesObject.id; +}; + +const updateGlobalStylesWithDefaultValues = async ( + context: DesignWithoutAIStateMachineContext +) => { + // We are using the first color palette and font pairing that are displayed on the color/font picker on the sidebar. + const colorPalette = COLOR_PALETTES[ 0 ]; + + const allowTracking = + ( await resolveSelect( OPTIONS_STORE_NAME ).getOption( + 'woocommerce_allow_tracking' + ) ) === 'yes'; + + const fontPairing = + context.isFontLibraryAvailable && allowTracking + ? FONT_PAIRINGS_WHEN_AI_IS_OFFLINE[ 0 ] + : FONT_PAIRINGS_WHEN_USER_DID_NOT_ALLOW_TRACKING[ 0 ]; + + const globalStylesId = await getCurrentGlobalStylesId(); + if ( ! globalStylesId ) { + return; + } + + // @ts-expect-error No types for this exist yet. + const { saveEntityRecord } = dispatch( coreStore ); + + await saveEntityRecord( + 'root', + 'globalStyles', + { + id: globalStylesId, + styles: mergeBaseAndUserConfigs( + colorPalette?.styles || {}, + fontPairing?.styles || {} + ), + settings: mergeBaseAndUserConfigs( + colorPalette?.settings || {}, + fontPairing?.settings || {} + ), + }, + { + throwOnError: true, + } + ); +}; + +const installAndActivateTheme = async ( + context: DesignWithoutAIStateMachineContext +) => { try { await setTheme( THEME_SLUG ); + await updateGlobalStylesWithDefaultValues( context ); } catch ( error ) { recordEvent( 'customize_your_store__no_ai_install_and_activate_theme_error', @@ -155,56 +235,6 @@ const createProducts = async () => { } }; -const updateGlobalStylesWithDefaultValues = async ( - context: DesignWithoutAIStateMachineContext -) => { - // We are using the first color palette and font pairing that are displayed on the color/font picker on the sidebar. - const colorPalette = COLOR_PALETTES[ 0 ]; - - const allowTracking = - ( await resolveSelect( OPTIONS_STORE_NAME ).getOption( - 'woocommerce_allow_tracking' - ) ) === 'yes'; - - const fontPairing = - context.isFontLibraryAvailable && allowTracking - ? FONT_PAIRINGS_WHEN_AI_IS_OFFLINE[ 0 ] - : FONT_PAIRINGS_WHEN_USER_DID_NOT_ALLOW_TRACKING[ 0 ]; - - // @ts-expect-error No types for this exist yet. - const { invalidateResolutionForStoreSelector } = dispatch( coreStore ); - invalidateResolutionForStoreSelector( - '__experimentalGetCurrentGlobalStylesId' - ); - - const globalStylesId = await resolveSelect( - coreStore - // @ts-expect-error No types for this exist yet. - ).__experimentalGetCurrentGlobalStylesId(); - - // @ts-expect-error No types for this exist yet. - const { saveEntityRecord } = dispatch( coreStore ); - - await saveEntityRecord( - 'root', - 'globalStyles', - { - id: globalStylesId, - styles: mergeBaseAndUserConfigs( - colorPalette?.styles || {}, - fontPairing?.styles || {} - ), - settings: mergeBaseAndUserConfigs( - colorPalette?.settings || {}, - fontPairing?.settings || {} - ), - }, - { - throwOnError: true, - } - ); -}; - export const services = { assembleSite, browserPopstateHandler, diff --git a/plugins/woocommerce-admin/client/customize-store/design-without-ai/state-machine.tsx b/plugins/woocommerce-admin/client/customize-store/design-without-ai/state-machine.tsx index 1fde98aae772..54bcbfaadda7 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-without-ai/state-machine.tsx +++ b/plugins/woocommerce-admin/client/customize-store/design-without-ai/state-machine.tsx @@ -140,26 +140,6 @@ export const designWithNoAiStateMachineDefinition = createMachine( }, }, }, - setGlobalStyles: { - initial: 'pending', - states: { - pending: { - invoke: { - src: 'updateGlobalStylesWithDefaultValues', - onDone: { - target: 'success', - }, - onError: { - actions: - 'redirectToIntroWithError', - }, - }, - }, - success: { - type: 'final', - }, - }, - }, installFontFamilies: { initial: 'checkFontLibrary', states: { diff --git a/plugins/woocommerce-admin/client/customize-store/design-without-ai/types.ts b/plugins/woocommerce-admin/client/customize-store/design-without-ai/types.ts index 93bfd680f105..90b6cf7e610c 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-without-ai/types.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-without-ai/types.ts @@ -11,3 +11,9 @@ export type DesignWithoutAIStateMachineContext = { flowType: FlowType.noAI; isFontLibraryAvailable: boolean; }; + +export interface Theme { + _links: { + 'wp:user-global-styles': { href: string }[]; + }; +} diff --git a/plugins/woocommerce-admin/client/customize-store/intro/index.tsx b/plugins/woocommerce-admin/client/customize-store/intro/index.tsx index 2e453e6d2877..4ede587486d5 100644 --- a/plugins/woocommerce-admin/client/customize-store/intro/index.tsx +++ b/plugins/woocommerce-admin/client/customize-store/intro/index.tsx @@ -224,6 +224,9 @@ export const Intro: CustomizeStoreComponent = ( { sendEvent, context } ) => { setOpenDesignChangeWarningModal={ setOpenDesignChangeWarningModal } + redirectToCYSFlow={ () => + sendEvent( 'DESIGN_WITHOUT_AI' ) + } sendEvent={ sendEvent } /> diff --git a/plugins/woocommerce-admin/client/customize-store/intro/intro-banners.tsx b/plugins/woocommerce-admin/client/customize-store/intro/intro-banners.tsx index 519901d64ff6..19e04974cd3f 100644 --- a/plugins/woocommerce-admin/client/customize-store/intro/intro-banners.tsx +++ b/plugins/woocommerce-admin/client/customize-store/intro/intro-banners.tsx @@ -2,7 +2,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { addQueryArgs } from '@wordpress/url'; import classNames from 'classnames'; import { Button } from '@wordpress/components'; import { getNewPath } from '@woocommerce/navigation'; @@ -17,7 +16,7 @@ import { useSelect } from '@wordpress/data'; */ import { Intro } from '.'; import { IntroSiteIframe } from './intro-site-iframe'; -import { ADMIN_URL, getAdminSetting } from '~/utils/admin-settings'; +import { getAdminSetting } from '~/utils/admin-settings'; import { navigateOrParent } from '../utils'; import { ThemeSwitchWarningModal } from '~/customize-store/intro/warning-modals'; @@ -219,7 +218,11 @@ export const ThemeHasModsBanner = ( { ); }; -export const NoAIBanner = () => { +export const NoAIBanner = ( { + redirectToCYSFlow, +}: { + redirectToCYSFlow: () => void; +} ) => { const [ isModalOpen, setIsModalOpen ] = useState( false ); interface Theme { stylesheet?: string; @@ -230,10 +233,6 @@ export const NoAIBanner = () => { }, [] ); const isDefaultTheme = currentTheme?.stylesheet === 'twentytwentyfour'; - const customizeStoreDesignUrl = addQueryArgs( `${ ADMIN_URL }admin.php`, { - page: 'wc-admin', - path: '/customize-store/design', - } ); return ( <> @@ -249,7 +248,7 @@ export const NoAIBanner = () => { if ( ! isDefaultTheme ) { setIsModalOpen( true ); } else { - window.location.href = customizeStoreDesignUrl; + redirectToCYSFlow(); } } } showAIDisclaimer={ false } @@ -257,7 +256,7 @@ export const NoAIBanner = () => { { isModalOpen && ( ) } diff --git a/plugins/woocommerce-admin/client/customize-store/intro/warning-modals.tsx b/plugins/woocommerce-admin/client/customize-store/intro/warning-modals.tsx index a6c706e684ea..5369582a73e3 100644 --- a/plugins/woocommerce-admin/client/customize-store/intro/warning-modals.tsx +++ b/plugins/woocommerce-admin/client/customize-store/intro/warning-modals.tsx @@ -197,10 +197,10 @@ export const StartOverWarningModal = ( { export const ThemeSwitchWarningModal = ( { setIsModalOpen, - customizeStoreDesignUrl, + redirectToCYSFlow, }: { setIsModalOpen: ( arg0: boolean ) => void; - customizeStoreDesignUrl: string; + redirectToCYSFlow: () => void; } ) => { return ( +

+ { __( "What's next?", 'woocommerce' ) } +

+
+
+ +
+

+ { __( 'Add your products', 'woocommerce' ) } +

+

+ { __( + 'Start stocking your virtual shelves by adding or importing your products, or edit the sample products.', + 'woocommerce' + ) } +

+ +
+
-
- { editor } -
-
-

- { __( 'Fine-tune your design', 'woocommerce' ) } -

-

- { __( - 'Head to the Editor to change your images and text, add more pages, and make any further customizations.', - 'woocommerce' - ) } -

- + +
+

+ { __( 'Fine-tune your design', 'woocommerce' ) } +

+

+ { __( + 'Head to the Editor to change your images and text, add more pages, and make any further customizations.', + 'woocommerce' + ) } +

+ +
-

- { __( - 'Continue setting up your store', - 'woocommerce' - ) } -

-

- { __( - 'Go back to the Home screen to complete your store setup and start selling', - 'woocommerce' - ) } -

- + +
+

+ { __( + 'Continue setting up your store', + 'woocommerce' + ) } +

+

+ { __( + 'Go back to the Home screen to complete your store setup and start selling', + 'woocommerce' + ) } +

+ +
diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/style.scss b/plugins/woocommerce-admin/client/customize-store/transitional/style.scss index a295fb3ff7e6..a6ed6213129e 100644 --- a/plugins/woocommerce-admin/client/customize-store/transitional/style.scss +++ b/plugins/woocommerce-admin/client/customize-store/transitional/style.scss @@ -30,11 +30,22 @@ } .woocommerce-customize-store__transitional-content { - flex: 1; display: flex; flex-direction: column; align-items: center; - padding-top: 40px; + justify-content: center; + padding: 40px; + + .woocommerce-customize-store__transitional-buttons { + display: flex; + align-items: center; + margin-top: 20px; + gap: 20px; + + .woocommerce-customize-store__transitional-preview-button { + flex: 1; + } + } } .woocommerce-customize-store__transitional-heading { @@ -58,7 +69,16 @@ line-height: 24px; /* 150% */ letter-spacing: -0.1px; margin: 4px 0 0; - width: 560px; + max-width: 560px; + } + + .woocommerce-customize-store__transitional-main-actions-title { + font-size: 20px; + font-style: normal; + font-weight: 500; + line-height: 24px; /* 120% */ + margin-top: 100px; + margin-bottom: 40px; } .woocommerce-customize-store__transitional-main-actions { @@ -67,109 +87,45 @@ gap: 20px; flex-direction: row; - .components-button { - padding: 8px 16px; - height: 40px; + @media only screen and (max-width: 600px) { + flex-direction: column; } - } - .woocommerce-customize-store__transitional-site-preview-container { - border-radius: 16px; - margin-top: 50px; - background: #f6f7f7; - box-shadow: 0 6px 6px 0 rgba(0, 0, 0, 0.02), 0 13px 10px 0 rgba(0, 0, 0, 0.03), 0 15px 20px 0 rgba(0, 0, 0, 0.04); - width: 600px; - height: 371px; - - div { - position: relative; - border-radius: 24px; + h3 { + margin: 0; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; /* 150% */ + letter-spacing: -0.32px; } - .woocommerce-customize-store__edit-site-editor { - height: 100%; + .components-button { + text-decoration: none; } - .woocommerce-customize-store__block-editor { - position: relative; + .woocommerce-customize-store__transitional-action { display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; } - .interface-navigable-region { - overflow: hidden; + .woocommerce-customize-store__transitional-action__icon, + .woocommerce-customize-store__transitional-action__content { + flex: 1; } - .auto-block-preview__container, - .block-editor-block-preview__content { - width: 588px; - height: 363px; - position: relative; + .woocommerce-customize-store__transitional-action__icon { + text-align: right; + max-width: 40px; + padding-right: 16px; } - iframe { - border-radius: 24px; - width: 1176px; - height: 726px; - transform: scale(0.5); - left: -50%; - position: relative; - top: -50%; - } - - &.is-loading { - @include placeholder(); - - iframe { - visibility: hidden; - opacity: 0.5; - } - } - } - - .woocommerce-customize-store__transitional-actions { - display: flex; - flex-direction: row; - margin-top: 50px; - gap: 40px; - - .woocommerce-customize-store__transitional-action { - display: flex; - flex-direction: column; - width: 280px; - - h3 { - color: $gray-900; - font-size: 16px; - font-style: normal; - font-weight: 500; - line-height: 24px; /* 150% */ - margin: 0; - } + .woocommerce-customize-store__transitional-action__content { + max-width: 250px; + padding-right: 2px; p { - margin: 5px 0 0; - color: $gray-700; - font-size: 13px; - font-style: normal; - font-weight: 400; - line-height: 16px; /* 123.077% */ - height: 48px; - } - - .components-button { - margin-top: 16px; - padding: 0; - margin-left: 0; - height: 20px; - width: fit-content; - - &:hover { - background: transparent; - } + margin-top: 5px; + margin-bottom: 16px; } } } diff --git a/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx b/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx index 8c8859c1f442..6d82ba2edf57 100644 --- a/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx +++ b/plugins/woocommerce-admin/client/customize-store/transitional/test/index.test.tsx @@ -48,10 +48,15 @@ describe( 'Transitional', () => { expect( screen.getByRole( 'button', { - name: /Preview store/i, + name: /View store/i, } ) ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { + name: /Go to Products/i, + } ) + ).toBeInTheDocument(); expect( screen.getByRole( 'button', { name: /Go to the Editor/i, @@ -65,14 +70,14 @@ describe( 'Transitional', () => { ).toBeInTheDocument(); } ); - it( 'should record an event when clicking on "Preview store" button', () => { + it( 'should record an event when clicking on "View store" button', () => { window.open = jest.fn(); // @ts-ignore render( ); screen .getByRole( 'button', { - name: /Preview store/i, + name: /View store/i, } ) .click(); diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx index eeb8e7a25c28..55283701496e 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx @@ -9,6 +9,7 @@ import { } from '@wordpress/element'; import classnames from 'classnames'; import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -119,7 +120,22 @@ export default function ProductListContent( props: { } ), } } /> - { index === bannerPosition && } + { index === bannerPosition && ( + { + const customizeStoreDesignUrl = + addQueryArgs( + `${ ADMIN_URL }admin.php`, + { + page: 'wc-admin', + path: '/customize-store/design', + } + ); + window.location.href = + customizeStoreDesignUrl; + } } + /> + ) } ) ) } diff --git a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx index d27148a23a18..2d2528505172 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx @@ -161,7 +161,9 @@ export default function Products( props: ProductsProps ) { { isModalOpen && ( { + window.location.href = customizeStoreDesignUrl; + } } /> ) } get( CheckoutFields::class ); + + return array( + 'fields_count' => count( $additional_fields_controller->get_additional_fields() ), + 'fields_names' => array_keys( $additional_fields_controller->get_additional_fields() ), + ); + } /** * Get info about the cart & checkout pages. * @@ -1104,6 +1119,8 @@ public static function get_cart_checkout_info() { $pickup_location_data = self::get_pickup_location_data(); + $additional_fields_data = self::get_checkout_additional_fields_data(); + return array( 'cart_page_contains_cart_shortcode' => self::post_contains_text( $cart_page_id, @@ -1119,6 +1136,7 @@ public static function get_cart_checkout_info() { 'checkout_page_contains_checkout_block' => $checkout_block_data['page_contains_block'], 'checkout_block_attributes' => $checkout_block_data['block_attributes'], 'pickup_location' => $pickup_location_data, + 'additional_fields' => $additional_fields_data, ); } diff --git a/plugins/woocommerce/src/Admin/API/Plugins.php b/plugins/woocommerce/src/Admin/API/Plugins.php index d396c3d2259e..37f66733c379 100644 --- a/plugins/woocommerce/src/Admin/API/Plugins.php +++ b/plugins/woocommerce/src/Admin/API/Plugins.php @@ -592,25 +592,22 @@ public function connect_square() { } /** - * Returns a URL that can be used to by WCPay to verify business details with Stripe. + * Returns a URL that can be used to by WCPay to verify business details. * * @return WP_Error|array Connect URL. */ public function connect_wcpay() { - if ( ! class_exists( 'WC_Payments_Account' ) ) { + if ( ! class_exists( 'WC_Payments' ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error communicating with the WooPayments plugin.', 'woocommerce' ), 500 ); } - $args = WooCommercePayments::is_account_partially_onboarded() ? [ - 'wcpay-login' => '1', - '_wpnonce' => wp_create_nonce( 'wcpay-login' ), - ] : [ - 'wcpay-connect' => 'WCADMIN_PAYMENT_TASK', - '_wpnonce' => wp_create_nonce( 'wcpay-connect' ), - ]; + // Redirect to the WooPayments overview page if the merchant started onboarding but left KYC immediately. + // Redirect to the connect page if they haven't started onboarding. + $path = WooCommercePayments::is_account_partially_onboarded() ? '/payments/overview' : '/payments/connect'; + // Point to the WooPayments Connect page rather than straight to the onboarding flow. return( array( - 'connectUrl' => add_query_arg( $args, admin_url() ), + 'connectUrl' => add_query_arg( 'from', 'WCADMIN_PAYMENT_TASK', admin_url( 'admin.php?page=wc-admin&path=' . $path ) ), ) ); } diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Payments.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Payments.php index cc337a062171..9589273c1a84 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Payments.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Payments.php @@ -4,6 +4,7 @@ use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; +use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage; /** * Payments Task @@ -12,6 +13,7 @@ class Payments extends Task { /** * Used to cache is_complete() method result. + * * @var null */ private $is_complete_result = null; @@ -94,4 +96,25 @@ function( $gateway ) { return ! empty( $enabled_gateways ); } + + /** + * Action URL. + * + * @return string + */ + public function get_action_url() { + // Check if the WooPayments plugin is active and the store is supported. + if ( WooCommercePayments::is_supported() && WooCommercePayments::is_wcpay_active() ) { + // Point to the WooPayments Connect page. + return add_query_arg( 'from', 'WCADMIN_PAYMENT_TASK', admin_url( 'admin.php?page=wc-admin&path=/payments/connect' ) ); + } + + // Check if there is an active WooPayments incentive via the welcome page. + if ( WcPayWelcomePage::instance()->must_be_visible() ) { + // Point to the WooPayments welcome page. + return add_query_arg( 'from', 'WCADMIN_PAYMENT_TASK', admin_url( 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' ) ); + } + + return admin_url( 'admin.php?page=wc-admin&task=payments' ); + } } diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php index 96db431bb888..ef290ce80770 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php @@ -109,7 +109,7 @@ public function can_view() { } /** - * Check if the plugin was requested during onboarding. + * Check if the WooPayments plugin was requested during onboarding. * * @return bool */ @@ -123,7 +123,7 @@ public static function is_requested() { } /** - * Check if the plugin is installed. + * Check if the WooPayments plugin is installed. * * @return bool */ @@ -133,7 +133,16 @@ public static function is_installed() { } /** - * Check if WooCommerce Payments is connected. + * Check if the WooPayments plugin is active. + * + * @return bool + */ + public static function is_wcpay_active() { + return class_exists( '\WC_Payments' ); + } + + /** + * Check if WooPayments is connected. * * @return bool */ @@ -149,7 +158,7 @@ public static function is_connected() { } /** - * Check if WooCommerce Payments needs setup. + * Check if WooPayments needs setup. * Errored data or payments not enabled. * * @return bool diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php index 40a8d628b6de..82010cc3d895 100644 --- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php +++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php @@ -333,6 +333,26 @@ public function register_checkout_field( $options ) { $this->fields_locations[ $field_data['location'] ][] = $field_data['id']; } + /** + * Deregister a checkout field. + * + * @param string $field_id The field ID. + * + * @internal + */ + public function deregister_checkout_field( $field_id ) { + if ( empty( $this->additional_fields[ $field_id ] ) ) { + return; + } + + $location = $this->get_field_location( $field_id ); + + // Remove the field from the fields_locations array. + $this->fields_locations[ $location ] = array_diff( $this->fields_locations[ $location ], array( $field_id ) ); + + // Remove the field from the additional_fields array. + unset( $this->additional_fields[ $field_id ] ); + } /** * Validates the "base" options (id, label, location) and shows warnings if they're not supplied. * @@ -381,13 +401,6 @@ private function validate_options( $options ) { return false; } - // Hidden fields are not supported right now. They will be registered with hidden => false. - if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) { - $message = sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id ); - _doing_it_wrong( '__experimental_woocommerce_blocks_register_checkout_field', esc_html( $message ), '8.6.0' ); - // Don't return here unlike the other fields because this is not an issue that will prevent registration. - } - if ( ! empty( $options['type'] ) ) { if ( ! in_array( $options['type'], $this->supported_field_types, true ) ) { $message = sprintf( @@ -413,6 +426,13 @@ private function validate_options( $options ) { return false; } + // Hidden fields are not supported right now. They will be registered with hidden => false. + if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) { + $message = sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id ); + _doing_it_wrong( '__experimental_woocommerce_blocks_register_checkout_field', esc_html( $message ), '8.6.0' ); + // Don't return here unlike the other fields because this is not an issue that will prevent registration. + } + return true; } diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/functions.php b/plugins/woocommerce/src/Blocks/Domain/Services/functions.php index 5cab4b197755..ee667a6509cb 100644 --- a/plugins/woocommerce/src/Blocks/Domain/Services/functions.php +++ b/plugins/woocommerce/src/Blocks/Domain/Services/functions.php @@ -18,7 +18,7 @@ function __experimental_woocommerce_blocks_register_checkout_field( $options ) { if ( ! $woocommerce_blocks_loaded_ran ) { add_action( 'woocommerce_blocks_loaded', - function() use ( $options ) { + function () use ( $options ) { __experimental_woocommerce_blocks_register_checkout_field( $options ); } ); @@ -27,7 +27,25 @@ function() use ( $options ) { $checkout_fields = Package::container()->get( CheckoutFields::class ); $result = $checkout_fields->register_checkout_field( $options ); if ( is_wp_error( $result ) ) { - throw new \Exception( $result->get_error_message() ); + throw new \Exception( esc_attr( $result->get_error_message() ) ); + } + } +} + + +if ( ! function_exists( '__internal_woocommerce_blocks_deregister_checkout_field' ) ) { + /** + * Deregister a checkout field. + * + * @param string $field_id Field ID. + * @throws \Exception If field deregistration fails. + * @internal + */ + function __internal_woocommerce_blocks_deregister_checkout_field( $field_id ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore,PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.FunctionDoubleUnderscore + $checkout_fields = Package::container()->get( CheckoutFields::class ); + $result = $checkout_fields->deregister_checkout_field( $field_id ); + if ( is_wp_error( $result ) ) { + throw new \Exception( esc_attr( $result->get_error_message() ) ); } } } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php index 69c52cb64279..c700e243ac34 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php @@ -311,6 +311,10 @@ private function process_pre_update_option( $value, $option, $old_value ) { return $value; } + if ( $old_value === $value ) { + return $value; + } + $this->order_cache->flush(); if ( ! $this->data_synchronizer->check_orders_table_exists() ) { $this->data_synchronizer->create_database_tables(); diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php index c1a3edc61aa6..7bb47ab96d3c 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php @@ -442,7 +442,7 @@ public function get_sync_status() { * * @return int */ - public function get_current_orders_pending_sync_count_cached() : int { + public function get_current_orders_pending_sync_count_cached(): int { return $this->get_current_orders_pending_sync_count( true ); } @@ -483,14 +483,14 @@ public function get_current_orders_pending_sync_count( $use_cache = false ): int return 0; } + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.NotPrepared -- + // -- $order_post_type_placeholder, $orders_table, self::PLACEHOLDER_ORDER_POST_TYPE are all safe to use in queries. if ( ! $this->get_table_exists() ) { $count = $wpdb->get_var( - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared. $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts where post_type in ( $order_post_type_placeholder )", $order_post_types ) - // phpcs:enable ); return $count; } @@ -499,30 +499,28 @@ public function get_current_orders_pending_sync_count( $use_cache = false ): int $missing_orders_count_sql = $wpdb->prepare( " SELECT COUNT(1) FROM $wpdb->posts posts -INNER JOIN $orders_table orders ON posts.id=orders.id -WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "' - AND orders.status not in ( 'auto-draft' ) +RIGHT JOIN $orders_table orders ON posts.ID=orders.id +WHERE (posts.post_type IS NULL OR posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "') + AND orders.status NOT IN ( 'auto-draft' ) AND orders.type IN ($order_post_type_placeholder)", $order_post_types ); $operator = '>'; } else { - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared. $missing_orders_count_sql = $wpdb->prepare( " SELECT COUNT(1) FROM $wpdb->posts posts -LEFT JOIN $orders_table orders ON posts.id=orders.id +LEFT JOIN $orders_table orders ON posts.ID=orders.id WHERE posts.post_type in ($order_post_type_placeholder) AND posts.post_status != 'auto-draft' AND orders.id IS NULL", $order_post_types ); - // phpcs:enable + $operator = '<'; } - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $missing_orders_count_sql is prepared. $sql = $wpdb->prepare( " SELECT( @@ -604,10 +602,9 @@ public function get_ids_of_orders_pending_sync( int $type, int $limit ) { $order_post_types = wc_get_order_types( 'cot-migration' ); $order_post_type_placeholders = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) ); - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.NotPrepared switch ( $type ) { case self::ID_TYPE_MISSING_IN_ORDERS_TABLE: - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared. $sql = $wpdb->prepare( " SELECT posts.ID FROM $wpdb->posts posts @@ -619,23 +616,22 @@ public function get_ids_of_orders_pending_sync( int $type, int $limit ) { ORDER BY posts.ID ASC", $order_post_types ); - // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare break; case self::ID_TYPE_MISSING_IN_POSTS_TABLE: $sql = $wpdb->prepare( " -SELECT posts.ID FROM $wpdb->posts posts -INNER JOIN $orders_table orders ON posts.id=orders.id -WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "' -AND orders.status not in ( 'auto-draft' ) +SELECT orders.id FROM $wpdb->posts posts +RIGHT JOIN $orders_table orders ON posts.ID=orders.id +WHERE (posts.post_type IS NULL OR posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "') +AND orders.status NOT IN ( 'auto-draft' ) AND orders.type IN ($order_post_type_placeholders) -ORDER BY posts.id ASC", +ORDER BY posts.ID ASC", $order_post_types ); break; case self::ID_TYPE_DIFFERENT_UPDATE_DATE: $operator = $this->custom_orders_table_is_authoritative() ? '>' : '<'; - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholders is prepared. + $sql = $wpdb->prepare( " SELECT orders.id FROM $orders_table orders @@ -647,7 +643,6 @@ public function get_ids_of_orders_pending_sync( int $type, int $limit ) { ", $order_post_types ); - // phpcs:enable break; case self::ID_TYPE_DELETED_FROM_ORDERS_TABLE: return $this->get_deleted_order_ids( true, $limit ); @@ -656,7 +651,7 @@ public function get_ids_of_orders_pending_sync( int $type, int $limit ) { default: throw new \Exception( 'Invalid $type, must be one of the ID_TYPE_... constants.' ); } - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:enable // phpcs:ignore WordPress.DB return array_map( 'intval', $wpdb->get_col( $sql . " LIMIT $limit" ) ); @@ -701,7 +696,7 @@ public function cleanup_synchronization_state() { * * @param array $batch Batch details. */ - public function process_batch( array $batch ) : void { + public function process_batch( array $batch ): void { if ( empty( $batch ) ) { return; } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php index b1d32144ede8..ecae3c7f2979 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php @@ -589,11 +589,24 @@ public function backfill_post_record( $order ) { } self::$backfilling_order_ids[] = $order->get_id(); + + // Attempt to create the backup post if missing. + if ( $order->get_id() && is_null( get_post( $order->get_id() ) ) ) { + if ( ! $this->maybe_create_backup_post( $order, 'backfill' ) ) { + // translators: %d is an order ID. + $this->error_logger->warning( sprintf( __( 'Unable to create backup post for order %d.', 'woocommerce' ), $order->get_id() ) ); + return; + } + } + $this->update_order_meta_from_object( $order ); $order_class = get_class( $order ); $post_order = new $order_class(); $post_order->set_id( $order->get_id() ); - $cpt_data_store->read( $post_order ); + + if ( $cpt_data_store->order_exists( $order->get_id() ) ) { + $cpt_data_store->read( $post_order ); + } // This compares the order data to the post data and set changes array for props that are changed. $post_order->set_props( $order->get_data() ); @@ -1822,20 +1835,10 @@ private function generate_select_clause_for_props( $table_alias, $props ) { * @since 6.8.0 */ protected function persist_order_to_db( &$order, bool $force_all_fields = false ) { - $context = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update'; - $data_sync = wc_get_container()->get( DataSynchronizer::class ); + $context = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update'; if ( 'create' === $context ) { - $post_id = wp_insert_post( - array( - 'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE, - 'post_status' => 'draft', - 'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0, - 'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ), - 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ), - ) - ); - + $post_id = $this->maybe_create_backup_post( $order, 'create' ); if ( ! $post_id ) { throw new \Exception( esc_html__( 'Could not create order in posts table.', 'woocommerce' ) ); } @@ -1871,6 +1874,37 @@ protected function persist_order_to_db( &$order, bool $force_all_fields = false $this->set_custom_taxonomies( $order, $default_taxonomies ); } + /** + * Takes care of creating the backup post in the posts table (placeholder or actual order post, depending on sync settings). + * + * @since 8.8.0 + * + * @param \WC_Abstract_Order $order The order. + * @param string $context The context: either 'create' or 'backfill'. + * @return int The new post ID. + */ + protected function maybe_create_backup_post( &$order, string $context ): int { + $data_sync = wc_get_container()->get( DataSynchronizer::class ); + + $data = array( + 'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE, + 'post_status' => 'draft', + 'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0, + 'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ), + 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ), + ); + + if ( 'backfill' === $context ) { + if ( ! $order->get_id() ) { + return 0; + } + + $data['import_id'] = $order->get_id(); + } + + return wp_insert_post( $data ); + } + /** * Set default taxonomies for the order. * diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php index 1a6a6e82ae23..f0efe4a61e58 100644 --- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php +++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php @@ -76,6 +76,13 @@ class FeaturesController { */ private $force_allow_enabling_plugins = false; + /** + * List of plugins excluded from feature compatibility warnings in UI. + * + * @var string[] + */ + private $plugins_excluded_from_compatibility_ui; + /** * Creates a new instance of the class. */ @@ -180,10 +187,10 @@ private function get_feature_definitions() { 'is_experimental' => true, 'disable_ui' => false, 'is_legacy' => true, - 'disabled' => function() { + 'disabled' => function () { return version_compare( get_bloginfo( 'version' ), '6.2', '<' ); }, - 'desc_tip' => function() { + 'desc_tip' => function () { $string = ''; if ( version_compare( get_bloginfo( 'version' ), '6.2', '<' ) ) { $string = __( @@ -262,6 +269,8 @@ private function get_feature_definitions() { final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) { $this->proxy = $proxy; $this->plugin_util = $plugin_util; + + $this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui(); } /** @@ -285,7 +294,7 @@ public function get_features( bool $include_experimental = false, bool $include_ if ( ! $include_experimental ) { $features = array_filter( $features, - function( $feature ) { + function ( $feature ) { return ! $feature['is_experimental']; } ); @@ -422,7 +431,7 @@ private function feature_exists( string $feature_id ): bool { * @param bool $enabled_features_only True to return only names of enabled plugins. * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids. */ - public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false ) : array { + public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false ): array { $this->verify_did_woocommerce_init( __FUNCTION__ ); $features = $this->get_feature_definitions(); @@ -457,7 +466,7 @@ public function get_compatible_features_for_plugin( string $plugin_name, bool $e * @param bool $active_only True to return only active plugins. * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names. */ - public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false ) : array { + public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false ): array { $this->verify_did_woocommerce_init( __FUNCTION__ ); $woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only ); @@ -572,7 +581,7 @@ private function process_updated_option( string $option, $old_value, $value ) { $is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches ); $features_with_custom_keys = array_filter( $this->get_feature_definitions(), - function( $feature ) { + function ( $feature ) { return ! empty( $feature['option_key'] ); } ); @@ -649,13 +658,16 @@ private function add_feature_settings( $settings, $current_section ): array { $features = $this->get_features( true ); - $feature_ids = array_keys( $features ); - usort( $feature_ids, function( $feature_id_a, $feature_id_b ) use ( $features ) { - return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 ); - } ); + $feature_ids = array_keys( $features ); + usort( + $feature_ids, + function ( $feature_id_a, $feature_id_b ) use ( $features ) { + return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 ); + } + ); $experimental_feature_ids = array_filter( $feature_ids, - function( $feature_id ) use ( $features ) { + function ( $feature_id ) use ( $features ) { return $features[ $feature_id ]['is_experimental'] ?? false; } ); @@ -709,7 +721,7 @@ function( $feature_id ) use ( $features ) { if ( $this->verify_did_woocommerce_init() ) { // Allow feature setting properties to be determined dynamically just before being rendered. $feature_settings = array_map( - function( $feature_setting ) { + function ( $feature_setting ) { foreach ( $feature_setting as $prop => $value ) { if ( is_callable( $value ) ) { $feature_setting[ $prop ] = call_user_func( $value ); @@ -894,6 +906,8 @@ private function filter_plugins_list( $list ): array { private function get_incompatible_plugins( $feature_id, $list ) { $incompatibles = array(); + $list = array_diff_key( $list, array_flip( $this->plugins_excluded_from_compatibility_ui ) ); + // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput foreach ( array_keys( $list ) as $plugin_name ) { if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) { @@ -903,7 +917,7 @@ private function get_incompatible_plugins( $feature_id, $list ) { $compatibility = $this->get_compatible_features_for_plugin( $plugin_name ); $incompatible_with = array_filter( array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ), - function( $feature_id ) { + function ( $feature_id ) { return ! $this->is_legacy_feature( $feature_id ); } ); @@ -941,12 +955,13 @@ private function maybe_display_feature_incompatibility_warning(): void { } $incompatible_plugins = false; + $relevant_plugins = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui ); - foreach ( $this->plugin_util->get_woocommerce_aware_plugins( true ) as $plugin ) { + foreach ( $relevant_plugins as $plugin ) { $compatibility = $this->get_compatible_features_for_plugin( $plugin, true ); $incompatible_with = array_filter( array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ), - function( $feature_id ) { + function ( $feature_id ) { return ! $this->is_legacy_feature( $feature_id ); } ); @@ -1060,6 +1075,10 @@ private function maybe_invalidate_cached_plugin_data(): void { private function handle_plugin_list_rows( $plugin_file, $plugin_data ) { global $wp_list_table; + if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) { + return; + } + if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification return; } @@ -1078,7 +1097,7 @@ private function handle_plugin_list_rows( $plugin_file, $plugin_data ) { $incompatible_features = array_values( array_filter( $incompatible_features, - function( $feature_id ) { + function ( $feature_id ) { return ! $this->is_legacy_feature( $feature_id ); } ) diff --git a/plugins/woocommerce/src/Utilities/PluginUtil.php b/plugins/woocommerce/src/Utilities/PluginUtil.php index 1e2664f85931..59a2f155db4c 100644 --- a/plugins/woocommerce/src/Utilities/PluginUtil.php +++ b/plugins/woocommerce/src/Utilities/PluginUtil.php @@ -36,12 +36,21 @@ class PluginUtil { */ private $woocommerce_aware_active_plugins = null; + /** + * List of plugins excluded from feature compatibility warnings in UI. + * + * @var string[] + */ + private $plugins_excluded_from_compatibility_ui; + /** * Creates a new instance of the class. */ public function __construct() { self::add_action( 'activated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 ); self::add_action( 'deactivated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 ); + + $this->plugins_excluded_from_compatibility_ui = array( 'woocommerce-legacy-rest-api/woocommerce-legacy-rest-api.php' ); } /** @@ -180,10 +189,11 @@ private function handle_plugin_de_activation(): void { * * @return string Warning string. */ - public function generate_incompatible_plugin_feature_warning( string $feature_id, array $plugin_feature_info ) : string { + public function generate_incompatible_plugin_feature_warning( string $feature_id, array $plugin_feature_info ): string { $feature_warning = ''; $incompatibles = array_merge( $plugin_feature_info['incompatible'], $plugin_feature_info['uncertain'] ); $incompatibles = array_filter( $incompatibles, 'is_plugin_active' ); + $incompatibles = array_values( array_diff( $incompatibles, $this->get_plugins_excluded_from_compatibility_ui() ) ); $incompatible_count = count( $incompatibles ); if ( $incompatible_count > 0 ) { if ( 1 === $incompatible_count ) { @@ -232,4 +242,15 @@ public function generate_incompatible_plugin_feature_warning( string $feature_id return $feature_warning; } + + /** + * Get the names of the plugins that are excluded from the feature compatibility UI. + * These plugins won't be considered as incompatible with any existing feature for the purposes + * of displaying compatibility warning in UI, even if they declare incompatibilities explicitly. + * + * @return string[] Plugin names relative to the root plugins directory. + */ + public function get_plugins_excluded_from_compatibility_ui() { + return $this->plugins_excluded_from_compatibility_ui; + } } diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js index 0b7856502000..5ceedfd35ae9 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js @@ -30,14 +30,14 @@ test.describe( 'Payment setup task', () => { } ); } ); - test( 'Can visit the payment setup task from the homescreen if the setup wizard has been skipped', async ( { + test( 'Can visit the WooPayments Connect page instead of setup task for supported countries', async ( { page, } ) => { await page.goto( 'wp-admin/admin.php?page=wc-admin' ); await page.locator( 'text=Get paid' ).click(); await expect( page.locator( '.woocommerce-layout__header-wrapper > h1' ) - ).toHaveText( 'Get paid' ); + ).toHaveText( 'WooPayments' ); } ); test( 'Saving valid bank account transfer details enables the payment method', async ( { @@ -85,6 +85,45 @@ test.describe( 'Payment setup task', () => { ).toHaveClass( 'wc-payment-gateway-method-toggle-enabled' ); } ); + test( 'Can visit the payment setup task from the homescreen if the setup wizard has been skipped', async ( { + baseURL, + page, + } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + // ensure store address is a non supported country + await api.post( 'settings/general/batch', { + update: [ + { + id: 'woocommerce_store_address', + value: 'addr 1', + }, + { + id: 'woocommerce_store_city', + value: 'San Francisco', + }, + { + id: 'woocommerce_default_country', + // Morocco: Unsupported country:region + value: 'MA:maagd', + }, + { + id: 'woocommerce_store_postcode', + value: '80000', + }, + ], + } ); + await page.goto( 'wp-admin/admin.php?page=wc-admin' ); + await page.locator( 'text=Get paid' ).click(); + await expect( + page.locator( '.woocommerce-layout__header-wrapper > h1' ) + ).toHaveText( 'Get paid' ); + } ); + test( 'Enabling cash on delivery enables the payment method', async ( { page, baseURL, diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php new file mode 100644 index 000000000000..3484f8e3a990 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php @@ -0,0 +1,1729 @@ +register_fields(); + $this->controller = Package::container()->get( CheckoutFields::class ); + + $fixtures = new FixtureData(); + $fixtures->shipping_add_flat_rate(); + $fixtures->payments_enable_bacs(); + $this->products = array( + $fixtures->get_simple_product( + array( + 'name' => 'Test Product 1', + 'stock_status' => 'instock', + 'regular_price' => 10, + 'weight' => 10, + ) + ), + $fixtures->get_simple_product( + array( + 'name' => 'Test Product 2', + 'stock_status' => 'instock', + 'regular_price' => 10, + 'weight' => 10, + ) + ), + ); + $this->reset_session(); + } + + /** + * Tear down Rest API server and remove fields. + */ + protected function tearDown(): void { + parent::tearDown(); + global $wp_rest_server; + $wp_rest_server = null; + $this->unregister_fields(); + } + + /** + * Register fields for testing. + */ + private function register_fields() { + $this->fields = array( + array( + 'id' => 'plugin-namespace/gov-id', + 'label' => 'Government ID', + 'location' => 'address', + 'type' => 'text', + 'required' => true, + 'attributes' => array( + 'title' => 'This is a gov id', + 'autocomplete' => 'gov-id', + 'autocapitalize' => 'none', + 'maxLength' => '30', + ), + 'sanitize_callback' => function ( $value ) { + return trim( $value ); + }, + 'validate_callback' => function ( $value ) { + return strlen( $value ) > 3; + }, + ), + array( + 'id' => 'plugin-namespace/job-function', + 'label' => 'What is your main role at your company?', + 'location' => 'contact', + 'required' => true, + 'type' => 'select', + 'options' => array( + array( + 'label' => 'Director', + 'value' => 'director', + ), + array( + 'label' => 'Engineering', + 'value' => 'engineering', + ), + array( + 'label' => 'Customer Support', + 'value' => 'customer-support', + ), + array( + 'label' => 'Other', + 'value' => 'other', + ), + ), + ), + array( + 'id' => 'plugin-namespace/leave-on-porch', + 'label' => __( 'Please leave my package on the porch if I\'m not home', 'woocommerce' ), + 'location' => 'additional', + 'type' => 'checkbox', + ), + ); + array_map( '__experimental_woocommerce_blocks_register_checkout_field', $this->fields ); + } + + /** + * Unregister fields after testing. + */ + private function unregister_fields() { + array_map( '__internal_woocommerce_blocks_deregister_checkout_field', array_column( $this->fields, 'id' ) ); + } + + /** + * delete the current user, and empties the cart. + */ + private function reset_session() { + wp_set_current_user( 0 ); + $customer = get_user_by( 'email', 'testaccount@test.com' ); + + if ( $customer ) { + wp_delete_user( $customer->ID ); + } + \wc_empty_cart(); + wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 ); + wc()->cart->add_to_cart( $this->products[1]->get_id(), 1 ); + } + + /** + * Test if suite valid fields register without an error. + */ + public function test_fields_register_without_error() { + // We first unregister existing fields. + $this->unregister_fields(); + // add our callbacks. + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->never(); + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + // register fields. + $this->register_fields(); + // remove our callbacks. + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + } + + /** + * Test that fields are registered in correct locations. + */ + public function test_fields_in_correct_locations() { + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'plugin-namespace/gov-id', $data['schema']['properties']['billing_address']['properties'] ); + $this->assertArrayHasKey( 'plugin-namespace/gov-id', $data['schema']['properties']['shipping_address']['properties'] ); + $this->assertArrayHasKey( 'plugin-namespace/job-function', $data['schema']['properties']['additional_fields']['properties'] ); + $this->assertArrayHasKey( 'plugin-namespace/leave-on-porch', $data['schema']['properties']['additional_fields']['properties'] ); + } + + /** + * Ensures registered fields show up in address schema. + */ + public function test_additional_fields_schema() { + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( + 'description' => 'Government ID', + 'type' => 'string', + 'context' => array( + 'view', + 'edit', + ), + 'required' => true, + ), + $data['schema']['properties']['billing_address']['properties']['plugin-namespace/gov-id'], + print_r( $data['schema']['properties']['billing_address']['properties'], true ) + ); + } + + /** + * Ensures select fields show an enum in the schema. + */ + public function test_select_enum_in_schema() { + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( + 'description' => 'What is your main role at your company?', + 'type' => 'string', + 'enum' => array( 'director', 'engineering', 'customer-support', 'other' ), + 'context' => array( + 'view', + 'edit', + ), + 'required' => true, + ), + $data['schema']['properties']['additional_fields']['properties']['plugin-namespace/job-function'], + print_r( $data['schema']['properties']['additional_fields'], true ) + ); + } + + /** + * Ensures checkbox fields show up in the schema as optional booleans. + */ + public function test_checkbox_in_schema() { + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( + 'description' => __( 'Please leave my package on the porch if I\'m not home', 'woocommerce' ), + 'type' => 'boolean', + 'context' => array( + 'view', + 'edit', + ), + 'required' => false, + ), + $data['schema']['properties']['additional_fields']['properties']['plugin-namespace/leave-on-porch'], + print_r( $data['schema']['properties']['additional_fields'], true ) + ); + } + + /** + * Ensures optional fields show up in the schema as optional. + */ + public function test_optional_field_in_schema() { + $id = 'plugin-namespace/optional-field'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Optional Field', + 'location' => 'additional', + 'type' => 'text', + 'required' => false, + ) + ); + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( + 'description' => 'Optional Field', + 'type' => 'string', + 'context' => array( + 'view', + 'edit', + ), + 'required' => false, + ), + $data['schema']['properties']['additional_fields']['properties'][ $id ], + print_r( $data['schema']['properties']['additional_fields'], true ) + ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + } + + /** + * Ensure an error is triggered when a field is registered without an ID. + */ + public function test_missing_id_in_registration() { + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + 'A checkout field cannot be registered without an id.', + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'label' => 'Invalid ID', + 'location' => 'additional', + 'type' => 'text', + 'required' => false, + ) + ); + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertEquals( \count( $this->controller->get_additional_fields() ), count( $this->fields ), \sprintf( 'An unexpected field is registered' ) ); + } + + /** + * Ensure an error is triggered when a field is registered with an invalid ID. + */ + public function test_invalid_id_in_registration() { + $id = 'invalid-id'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( \sprintf( 'Unable to register field with id: "%s". A checkout field id must consist of namespace/name.', $id ) ), + + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid ID', + 'location' => 'additional', + 'type' => 'text', + 'required' => false, + ) + ); + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered without a label. + */ + public function test_missing_label_in_registration() { + $id = 'plugin-namespace/missing-label'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( \sprintf( 'Unable to register field with id: "%s". The field label is required.', $id ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'location' => 'additional', + 'type' => 'text', + 'required' => false, + ) + ); + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered without a location key. + */ + public function test_missing_location_in_registration() { + $id = 'plugin-namespace/missing-location'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( \sprintf( 'Unable to register field with id: "%s". The field location is required.', $id ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Missing Location', + 'type' => 'text', + ) + ); + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered with an invalid location key (contact, address, additional). + */ + public function test_invalid_location_in_registration() { + $id = 'plugin-namespace/invalid-location'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( \sprintf( 'Unable to register field with id: "%s". The field location is invalid.', $id ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Location', + 'location' => 'invalid', + 'type' => 'text', + 'required' => false, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered with an existing id. + */ + public function test_already_registered_field() { + $id = 'plugin-namespace/gov-id'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( \sprintf( 'Unable to register field with id: "%s". The field is already registered.', $id ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Government ID', + 'location' => 'address', + 'type' => 'text', + 'required' => true, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + } + + /** + * Ensure an error is triggered when a field is registered with an invalid type (text, select, checkbox). + */ + public function test_invalid_type_in_registration() { + $id = 'plugin-namespace/invalid-type'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( + sprintf( + 'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.', + $id, + 'invalid', + implode( ', ', array( 'text', 'select', 'checkbox' ) ) + ) + ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Type', + 'location' => 'additional', + 'type' => 'invalid', + 'required' => false, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered with an invalid sanitize callback. + */ + public function test_invalid_sanitize_in_registration() { + $id = 'plugin-namespace/invalid-sanitize'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Unable to register field with id: "%s". %s', $id, 'The sanitize_callback must be a valid callback.' ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Sanitize', + 'location' => 'additional', + 'type' => 'text', + 'sanitize_callback' => 'invalid_sanitize_callback', + 'required' => false, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered with an invalid validate callback. + */ + public function test_invalid_validate_in_registration() { + $id = 'plugin-namespace/invalid-validate'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Unable to register field with id: "%s". %s', $id, 'The validate_callback must be a valid callback.' ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Validate', + 'location' => 'additional', + 'type' => 'text', + 'validate_callback' => 'invalid_validate_callback', + 'required' => false, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field didn't register. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered with an invalid attributes prop. + */ + public function test_invalid_attribute_in_registration() { + $id = 'plugin-namespace/invalid-attribute'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'An invalid attributes value was supplied when registering field with id: "%s". %s', $id, 'Attributes must be a non-empty array.' ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Attribute', + 'location' => 'additional', + 'attributes' => 'invalid', + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures it's registered without attributes. + $this->assertEmpty( $this->controller->get_additional_fields()[ $id ]['attributes'] ); + + // Fields should makes it to Store API. + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( + 'description' => 'Invalid Attribute', + 'type' => 'string', + 'context' => array( + 'view', + 'edit', + ), + 'required' => false, + ), + $data['schema']['properties']['additional_fields']['properties'][ $id ], + print_r( $data['schema']['properties']['additional_fields'], true ) + ); + + // Unregister the field. + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered anymore. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered if a field is registered with invalid attributes values. + */ + public function test_invalid_attributes_values_in_registration() { + $id = 'plugin-namespace/invalid-attribute-values'; + $invalid_attributes = array( 'invalidAttribute' ); + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Invalid attribute found when registering field with id: "%s". Attributes: %s are not allowed.', $id, implode( ', ', $invalid_attributes ) ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Attribute Values', + 'location' => 'additional', + 'attributes' => array( + 'title' => 'title', + 'maxLength' => '20', + 'autocomplete' => 'gov-id', + 'autocapitalize' => 'none', + 'invalidAttribute' => 'invalidAttribute', + ), + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures it's registered without invalid attributes. + $this->assertArrayNotHasKey( 'invalidAttribute', $this->controller->get_additional_fields()[ $id ]['attributes'] ); + + // Fields should still be registered regardless of the error. + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( + 'description' => 'Invalid Attribute Values', + 'type' => 'string', + 'context' => array( + 'view', + 'edit', + ), + 'required' => false, + ), + $data['schema']['properties']['additional_fields']['properties'][ $id ], + print_r( $data['schema']['properties']['additional_fields'], true ) + ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered anymore. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a select is registered without options prop. + */ + public function test_missing_select_options_in_registration() { + $id = 'plugin-namespace/missing-options'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Unable to register field with id: "%s". %s', $id, 'Fields of type "select" must have an array of "options".' ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Missing Options', + 'location' => 'additional', + 'type' => 'select', + 'required' => false, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a select is registered with an invalid options array. + */ + public function test_invalid_select_options_in_registration() { + $id = 'plugin-namespace/invalid-options'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Unable to register field with id: "%s". %s', $id, 'Fields of type "select" must have an array of "options" and each option must contain a "value" and "label" member.' ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Invalid Options', + 'location' => 'additional', + 'type' => 'select', + 'options' => array( // numeric array instead of associative array. + 'invalidValue', + ), + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a select is registered with duplicate options. + */ + public function test_duplicate_select_options_in_registration() { + $id = 'plugin-namespace/duplicate-options'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Duplicate key found when registering field with id: "%s". The value in each option of "select" fields must be unique. Duplicate value "%s" found. The duplicate key will be removed.', $id, 'duplicate' ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Duplicate Options', + 'location' => 'additional', + 'type' => 'select', + 'options' => array( + array( + 'label' => 'Option 1', + 'value' => 'duplicate', + ), + array( + 'label' => 'Option 2', + 'value' => 'duplicate', + ), + ), + 'required' => true, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Fields should still be registered regardless of the error, but with no duplicate values. + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( + array( 'duplicate' ), + $data['schema']['properties']['additional_fields']['properties'][ $id ]['enum'], + print_r( $data['schema']['properties']['additional_fields'], true ) + ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered anymore. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure a select has an extra empty option if it's optional. + */ + public function test_optional_select_has_empty_value() { + $id = 'plugin-namespace/optional-select'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Optional Select', + 'location' => 'additional', + 'type' => 'select', + 'options' => array( + array( + 'label' => 'Option 1', + 'value' => 'option-1', + ), + array( + 'label' => 'Option 2', + 'value' => 'option-2', + ), + ), + ) + ); + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $this->assertEquals( + array( '', 'option-1', 'option-2' ), + $data['schema']['properties']['additional_fields']['properties'][ $id ]['enum'], + print_r( $data['schema']['properties']['additional_fields']['properties'][ $id ], true ) + ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a checkbox is registered as required. + */ + public function test_invalid_required_prop_checkbox() { + $id = 'plugin-namespace/checkbox-only-optional'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Registering checkbox fields as required is not supported. "%s" will be registered as optional.', $id ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Checkbox Only Optional', + 'location' => 'additional', + 'type' => 'checkbox', + 'required' => true, + ) + ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + // Fields should still be registered regardless of the error, but with required as optional. + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( + false, + $data['schema']['properties']['additional_fields']['properties'][ $id ]['required'], + print_r( $data['schema']['properties']['additional_fields']['properties'][ $id ], true ) + ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensure an error is triggered when a field is registered with hidden set to true. + */ + public function test_register_hidden_field_error() { + $id = 'plugin-namespace/hidden-field'; + $doing_it_wrong_mocker = \Mockery::mock( 'ActionCallback' ); + $doing_it_wrong_mocker->shouldReceive( 'doing_it_wrong_run' )->withArgs( + array( + '__experimental_woocommerce_blocks_register_checkout_field', + \esc_html( sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id ) ), + ) + )->once(); + + add_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ), + 10, + 2 + ); + + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Hidden Field', + 'location' => 'address', + 'type' => 'text', + 'hidden' => true, + ) + ); + + // Fields should still be registered regardless of the error, but not hidden. + $request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( $id, $data['schema']['properties']['billing_address']['properties'] ); + + \remove_action( + 'doing_it_wrong_run', + array( + $doing_it_wrong_mocker, + 'doing_it_wrong_run', + ) + ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures that placing an order with the correct values actually work. + */ + public function test_placing_order_with_valid_fields() { + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'my-gov-id', + + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + 'plugin-namespace/job-function' => 'engineering', + 'plugin-namespace/leave-on-porch' => true, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status(), print_r( $data, true ) ); + } + + /** + * Ensures that placing an order with an invalid text value fails + */ + public function test_placing_order_with_invalid_text() { + $id = 'plugin-namespace/gov-id'; + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + $id => array( 'array-instead-of-text' ), + + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + $id => array( 'array-instead-of-text' ), + + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + 'plugin-namespace/job-function' => 'engineering', + 'plugin-namespace/leave-on-porch' => true, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); + $this->assertEquals( \sprintf( 'Invalid %s provided.', $id ), $data['data']['params']['billing_address'], print_r( $data, true ) ); + } + + /** + * Ensures that a string is sanitized correctly via the provided sanitize callback. + */ + public function test_placing_order_sanitize_text() { + $id = 'plugin-namespace/sanitize-text'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Sanitize Text', + 'location' => 'additional', + 'type' => 'text', + 'sanitize_callback' => function ( $value ) { + return 'sanitized-' . $value; + }, + ) + ); + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + $id => 'value', + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status(), print_r( $data, true ) ); + $this->assertEquals( 'sanitized-value', $data['additional_fields'][ $id ], print_r( $data, true ) ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures that the provided validate callback works and prevents an order. + */ + public function test_placing_order_validate_text() { + $id = 'plugin-namespace/validate-text'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Validate Text', + 'location' => 'additional', + 'type' => 'text', + 'validate_callback' => function ( $value ) { + if ( 'invalid' === $value ) { + return new \WP_Error( 'invalid_value', 'Invalid value provided.' ); + } + return true; + }, + ) + ); + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + $id => 'invalid', + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); + $this->assertEquals( 'Invalid value provided.', $data['data']['params']['additional_fields'], print_r( $data, true ) ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures sanitize filters are being called. + */ + public function test_sanitize_filter() { + $id = 'plugin-namespace/filter-sanitize'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Filter Sanitize', + 'location' => 'additional', + 'type' => 'text', + ) + ); + + add_filter( + '__experimental_woocommerce_blocks_sanitize_additional_field', + function ( $value, $key ) use ( $id ) { + if ( $key === $id ) { + return 'sanitized-' . $value; + } + return $value; + }, + 10, + 2 + ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + $id => 'value', + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status(), print_r( $data, true ) ); + $this->assertEquals( 'sanitized-value', $data['additional_fields'][ $id ], print_r( $data, true ) ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures validate filters are being called. + */ + public function test_validate_filter() { + $id = 'plugin-namespace/filter-validate'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => 'Filter Validate', + 'location' => 'contact', + 'type' => 'text', + 'required' => true, + ) + ); + + add_action( + '__experimental_woocommerce_blocks_validate_additional_field', + function ( \WP_Error $errors, $key, $value ) use ( $id ) { + if ( $key === $id && 'invalid' === $value ) { + $errors->add( 'my_invalid_value', 'Invalid value provided.' ); + } + }, + 10, + 3 + ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'my-gov-id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + $id => 'invalid', + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); + $this->assertEquals( 'Invalid value provided.', $data['data']['params']['additional_fields'], print_r( $data, true ) ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures an error is returned when required fields in Address are missing. + */ + public function test_place_order_required_address_field() { + $id = 'plugin-namespace/my-required-field'; + $label = 'My Required Field'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => $label, + 'location' => 'address', + 'type' => 'text', + 'required' => true, + ) + ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'gov id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'gov id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array(), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); + $this->assertEquals( \sprintf( 'There was a problem with the provided shipping address: %s is required', $label ), $data['message'], print_r( $data, true ) ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures an error is returned when required fields in Contact are missing. + */ + public function test_place_order_required_contact_field() { + $this->markTestSkipped( 'Additional fields aren\'t validated due to a bug #45496.' ); + $id = 'plugin-namespace/my-required-contact-field'; + $label = 'My Required Field'; + \__experimental_woocommerce_blocks_register_checkout_field( + array( + 'id' => $id, + 'label' => $label, + 'location' => 'contact', + 'type' => 'text', + 'required' => true, + ) + ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'gov id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'gov id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array(), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); + // Error should be updated once we got this working. + $this->assertEquals( \sprintf( 'There was a problem with the provided shipping address: %s is required', $label ), $data['message'], print_r( $data, true ) ); + + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); + + // Ensures the field isn't registered. + $this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) ); + } + + /** + * Ensures that placing an order with an invalid select value fails + */ + public function test_placing_order_with_invalid_select() { + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'my-gov-id', + + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'my-gov-id', + + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + 'plugin-namespace/job-function' => 'invalid-prop', + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'plugin-namespace/job-function is not one of director, engineering, customer-support, and other.', $data['data']['params']['additional_fields'], print_r( $data, true ) ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php index 6416ed92aa24..cabc50f1804a 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php +++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php @@ -16,6 +16,7 @@ */ class Cart extends ControllerTestCase { + /** * Setup test product data. Called before every test. */ @@ -65,6 +66,7 @@ protected function setUp(): void { ); wc_empty_cart(); + $this->reset_customer_state(); $this->keys = array(); $this->keys[] = wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 ); $this->keys[] = wc()->cart->add_to_cart( $this->products[1]->get_id() ); @@ -77,6 +79,21 @@ protected function setUp(): void { wc()->session->set( 'store_api_draft_order', $order->get_id() ); } + /** + * Resets customer state and remove any existing data from previous tests. + */ + private function reset_customer_state() { + wc()->customer->set_billing_country( 'US' ); + wc()->customer->set_shipping_country( 'US' ); + wc()->customer->set_billing_state( '' ); + wc()->customer->set_shipping_state( '' ); + wc()->customer->set_billing_postcode( '' ); + wc()->customer->set_shipping_postcode( '' ); + wc()->customer->set_shipping_city( '' ); + wc()->customer->set_billing_city( '' ); + wc()->customer->set_shipping_address_1( '' ); + wc()->customer->set_billing_address_1( '' ); + } /** * Test getting cart. */ diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php index df6241ccc7a0..59304c6b0f49 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php +++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php @@ -50,7 +50,7 @@ protected function setUp(): void { array( 'endpoint' => CheckoutSchema::IDENTIFIER, 'namespace' => 'extension_namespace', - 'schema_callback' => function() { + 'schema_callback' => function () { return array( 'extension_key' => array( 'description' => 'Test key', @@ -86,9 +86,8 @@ protected function setUp(): void { ), ); wc_empty_cart(); - $this->keys = array(); - $this->keys[] = wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 ); - $this->keys[] = wc()->cart->add_to_cart( $this->products[1]->get_id(), 1 ); + wc()->cart->add_to_cart( $this->products[0]->get_id(), 2 ); + wc()->cart->add_to_cart( $this->products[1]->get_id(), 1 ); } /** diff --git a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php index 7a914db10d44..56667d49bb85 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php @@ -39,7 +39,7 @@ public function setUp(): void { add_action( 'woocommerce_register_feature_definitions', - function( $features_controller ) { + function ( $features_controller ) { $this->reset_features_list( $this->sut ); $features = array( @@ -187,7 +187,7 @@ public function test_get_features_including_experimental_and_values() { // No option for experimental2. $actual = array_map( - function( $feature ) { + function ( $feature ) { return array_intersect_key( $feature, array( 'is_enabled' => '' ) @@ -491,7 +491,7 @@ public function test_get_compatible_features_for_registered_plugin() { public function test_get_compatible_enabled_features_for_registered_plugin() { add_action( 'woocommerce_register_feature_definitions', - function( $features_controller ) { + function ( $features_controller ) { $this->reset_features_list( $this->sut ); $features = array( @@ -813,6 +813,10 @@ public function set_active_plugins( $plugins ) { public function get_woocommerce_aware_plugins( bool $active_only = false ): array { return $this->active_plugins; } + + public function get_plugins_excluded_from_compatibility_ui() { + return array(); + } }; $this->register_legacy_proxy_function_mocks( @@ -828,7 +832,7 @@ public function get_woocommerce_aware_plugins( bool $active_only = false ): arra add_action( 'woocommerce_register_feature_definitions', - function( $features_controller ) use ( $local_sut ) { + function ( $features_controller ) use ( $local_sut ) { $this->reset_features_list( $local_sut ); $features = array( @@ -897,6 +901,10 @@ public function set_active_plugins( $plugins ) { public function get_woocommerce_aware_plugins( bool $active_only = false ): array { return $this->active_plugins; } + + public function get_plugins_excluded_from_compatibility_ui() { + return array(); + } }; // phpcs:enable @@ -912,7 +920,7 @@ public function get_woocommerce_aware_plugins( bool $active_only = false ): arra add_action( 'woocommerce_register_feature_definitions', - function( $features_controller ) use ( $local_sut ) { + function ( $features_controller ) use ( $local_sut ) { $this->reset_features_list( $local_sut ); $features = array(