diff --git a/.github/workflows/milestoned.yml b/.github/workflows/milestoned.yml new file mode 100644 index 00000000000..1098ba24068 --- /dev/null +++ b/.github/workflows/milestoned.yml @@ -0,0 +1,26 @@ +name: Milestone Manager + +on: + pull_request_target: + types: [milestoned] + +permissions: {} + +jobs: + remove-milestone-from-unmerged-prs: + name: "Remove Milestone from Unmerged PRs" + if: github.event.pull_request.merged != true + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/github-script@v6 + with: + script: | + github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + milestone: null, + }); diff --git a/.github/workflows/pull-request-post-merge-processing.yml b/.github/workflows/pull-request-post-merge-processing.yml index 7e8073f6a1c..dea0dab0de6 100644 --- a/.github/workflows/pull-request-post-merge-processing.yml +++ b/.github/workflows/pull-request-post-merge-processing.yml @@ -38,9 +38,7 @@ jobs: with: php-version: '7.4' - name: 'Run the script to assign a milestone' - if: | - !github.event.pull_request.milestone && - github.event.pull_request.base.ref == 'trunk' + if: github.event.pull_request.base.ref == 'trunk' run: php assign-milestone-to-merged-pr.php env: PULL_REQUEST_ID: ${{ github.event.pull_request.node_id }} diff --git a/.github/workflows/scripts/stalebot.js b/.github/workflows/scripts/stalebot.js new file mode 100644 index 00000000000..83b28292d49 --- /dev/null +++ b/.github/workflows/scripts/stalebot.js @@ -0,0 +1,29 @@ +/** + * Set the stalebot start date given a cron schedule. + */ + +// You need to install this dependency as part of your workflow. +const core = require( '@actions/core' ); + +const ScheduleStartDate = () => { + let scheduleStartDate; + + switch ( process.env.CRON_SCHEDULE ) { + case '21 1 * * *': + scheduleStartDate = '2022-01-01'; + break; + case '31 2 * * *': + scheduleStartDate = '2023-01-01'; + break; + case '41 3 * * *': + scheduleStartDate = '2023-08-01'; + break; + default: + scheduleStartDate = '2018-01-01'; + break; + } + + core.setOutput( 'stale-start-date', scheduleStartDate ); +}; + +ScheduleStartDate(); diff --git a/.github/workflows/stalebot.yml b/.github/workflows/stalebot.yml index c3e9f03bee9..719213623f1 100644 --- a/.github/workflows/stalebot.yml +++ b/.github/workflows/stalebot.yml @@ -1,7 +1,10 @@ -name: 'Close stale needs-feedback issues' +name: 'Process stale needs-feedback issues' on: schedule: - - cron: '21 0 * * *' + - cron: '11 0 * * *' + - cron: '21 1 * * *' + - cron: '31 2 * * *' + - cron: '41 3 * * *' permissions: {} @@ -13,10 +16,20 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v8 + - name: Install Actions Core + run: npm --prefix .github/workflows/scripts install @actions/core + + - name: Get start date + id: startdate + run: node .github/workflows/scripts/stalebot.js + env: + CRON_SCHEDULE: ${{ github.event.schedule }} + - name: Scan issues + uses: actions/stale@v8 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - operations-per-run: 40 + operations-per-run: 8 + start-date: steps.startdate.outputs.stale-start-date stale-issue-message: "As a part of this repository's maintenance, this issue is being marked as stale due to inactivity. Please feel free to comment on it in case we missed something.\n\n###### After 7 days with no activity this issue will be automatically be closed." close-issue-message: 'This issue was closed because it has been 14 days with no activity.' days-before-issue-stale: 7 diff --git a/packages/js/product-editor/changelog/dev-35143_add_preview_to_downloads_edit b/packages/js/product-editor/changelog/dev-35143_add_preview_to_downloads_edit new file mode 100644 index 00000000000..240bdf8e190 --- /dev/null +++ b/packages/js/product-editor/changelog/dev-35143_add_preview_to_downloads_edit @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Add preview and replace button to downloads edit #40835 diff --git a/packages/js/product-editor/changelog/fix-currency-number-select-in-focus b/packages/js/product-editor/changelog/fix-currency-number-select-in-focus new file mode 100644 index 00000000000..96b68aedadc --- /dev/null +++ b/packages/js/product-editor/changelog/fix-currency-number-select-in-focus @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix selection in currency and number fields to only select if field still has focus. diff --git a/packages/js/product-editor/changelog/update-add-post-type-context-product-editor b/packages/js/product-editor/changelog/update-add-post-type-context-product-editor new file mode 100644 index 00000000000..a0ef51e308b --- /dev/null +++ b/packages/js/product-editor/changelog/update-add-post-type-context-product-editor @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Allow plugins to access PostTypeContext and blocks (through core/block-editor data store). diff --git a/packages/js/product-editor/src/blocks/product-fields/downloads/edit-downloads-modal/edit-downloads-modal.tsx b/packages/js/product-editor/src/blocks/product-fields/downloads/edit-downloads-modal/edit-downloads-modal.tsx index 95b71a35b91..4fb180cb385 100644 --- a/packages/js/product-editor/src/blocks/product-fields/downloads/edit-downloads-modal/edit-downloads-modal.tsx +++ b/packages/js/product-editor/src/blocks/product-fields/downloads/edit-downloads-modal/edit-downloads-modal.tsx @@ -1,13 +1,17 @@ /** * External dependencies */ +import { ChangeEvent } from 'react'; import { __, sprintf } from '@wordpress/i18n'; import { createElement, useState } from '@wordpress/element'; import { trash } from '@wordpress/icons'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { recordEvent } from '@woocommerce/tracks'; +import { ImageGallery, ImageGalleryItem } from '@woocommerce/components'; +import { uploadMedia } from '@wordpress/media-utils'; import { Button, + FormFileUpload, Modal, BaseControl, // @ts-expect-error `__experimentalInputControl` does exist. @@ -20,18 +24,39 @@ import { import { EditDownloadsModalProps } from './types'; import { UnionIcon } from './union-icon'; +export interface Image { + id: number; + src: string; + name: string; + alt: string; +} + export const EditDownloadsModal: React.FC< EditDownloadsModalProps > = ( { downloableItem, + maxUploadFileSize = 10000000, onCancel, onChange, onRemove, onSave, + onUploadSuccess, + onUploadError, } ) => { const { createNotice } = useDispatch( 'core/notices' ); const [ isCopingToClipboard, setIsCopingToClipboard ] = useState< boolean >( false ); + const [ isFileUploading, setIsFileUploading ] = + useState< boolean >( false ); - const { file = '', name = '' } = downloableItem; + const { allowedMimeTypes } = useSelect( ( select ) => { + const { getEditorSettings } = select( 'core/editor' ); + return getEditorSettings(); + } ); + + const allowedTypes = allowedMimeTypes + ? Object.values( allowedMimeTypes ) + : []; + + const { id = 0, file = '', name = '' } = downloableItem; const onCopySuccess = () => { createNotice( @@ -40,6 +65,15 @@ export const EditDownloadsModal: React.FC< EditDownloadsModalProps > = ( { ); }; + const isImage = ( filename = '' ) => { + if ( ! filename ) return; + const imageExtensions = [ 'jpg', 'jpeg', 'png', 'gif', 'webp' ]; + const fileExtension = ( + filename.split( '.' ).pop() || '' + ).toLowerCase(); + return imageExtensions.includes( fileExtension ); + }; + async function copyTextToClipboard( text: string ) { if ( 'clipboard' in navigator ) { await navigator.clipboard.writeText( text ); @@ -61,6 +95,21 @@ export const EditDownloadsModal: React.FC< EditDownloadsModalProps > = ( { setIsCopingToClipboard( false ); } + async function handleFormFileUploadChange( + event: ChangeEvent< HTMLInputElement > + ) { + setIsFileUploading( true ); + const filesList = event.currentTarget.files as FileList; + await uploadMedia( { + allowedTypes, + filesList, + maxUploadFileSize, + onFileChange: onUploadSuccess, + onError: onUploadError, + } ); + setIsFileUploading( false ); + } + return ( = ( { } } className="woocommerce-edit-downloads-modal" > +
+ { isImage( file ) && ( + + + + ) } + ( +
+

{ name }

+ +
+ ) } + /> +
void; onRemove: () => void; onSave: () => void; onChange: ( name: string ) => void; + onUploadSuccess( files: MediaItem[] ): void; + onUploadError( error: unknown ): void; }; diff --git a/packages/js/product-editor/src/blocks/product-fields/downloads/edit.tsx b/packages/js/product-editor/src/blocks/product-fields/downloads/edit.tsx index 8edeca5d64f..ec2a836c1de 100644 --- a/packages/js/product-editor/src/blocks/product-fields/downloads/edit.tsx +++ b/packages/js/product-editor/src/blocks/product-fields/downloads/edit.tsx @@ -143,6 +143,39 @@ export function Edit( { } } + function handleFileReplace( files: MediaItem | MediaItem[] ) { + if ( + ! Array.isArray( files ) || + ! files?.length || + files[ 0 ]?.id === undefined + ) { + return; + } + + if ( ! downloads.length ) { + setDownloadable( true ); + } + + const uploadedFile = { + id: stringifyId( files[ 0 ].id ), + file: files[ 0 ].url, + name: + files[ 0 ].title || + files[ 0 ].alt || + files[ 0 ].caption || + getFileName( files[ 0 ].url ), + }; + const stringifyIds = downloads.map( ( download ) => { + if ( download.file === selectedDownload?.file ) { + return stringifyEntityId( uploadedFile ); + } + return stringifyEntityId( download ); + } ); + + setDownloads( stringifyIds ); + setSelectedDownload( uploadedFile ); + } + function removeDownload( download: ProductDownload ) { const otherDownloads = downloads.reduce< ProductDownload[] >( function removeDownloadElement( @@ -336,6 +369,8 @@ export function Edit( { setDownloads( newDownloads ); setSelectedDownload( null ); } } + onUploadSuccess={ handleFileReplace } + onUploadError={ handleUploadError } /> ) } diff --git a/packages/js/product-editor/src/components/block-editor/block-editor.tsx b/packages/js/product-editor/src/components/block-editor/block-editor.tsx index f58e44659e1..526c6507cbb 100644 --- a/packages/js/product-editor/src/components/block-editor/block-editor.tsx +++ b/packages/js/product-editor/src/components/block-editor/block-editor.tsx @@ -5,6 +5,7 @@ import { synchronizeBlocksWithTemplate, Template } from '@wordpress/blocks'; import { createElement, useMemo, useLayoutEffect } from '@wordpress/element'; import { useDispatch, useSelect, select as WPSelect } from '@wordpress/data'; import { uploadMedia } from '@wordpress/media-utils'; +import { PluginArea } from '@wordpress/plugins'; import { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore No types for this exist yet. @@ -32,6 +33,7 @@ import { */ import { useConfirmUnsavedProductChanges } from '../../hooks/use-confirm-unsaved-product-changes'; import { ProductEditorContext } from '../../types'; +import { PostTypeContext } from '../../contexts/post-type-context'; type BlockEditorProps = { context: Partial< ProductEditorContext >; @@ -120,6 +122,11 @@ export function BlockEditor( { + { /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ } + + { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } + + diff --git a/packages/js/product-editor/src/components/editor/editor.tsx b/packages/js/product-editor/src/components/editor/editor.tsx index 042b0afb4ae..e5b3f177871 100644 --- a/packages/js/product-editor/src/components/editor/editor.tsx +++ b/packages/js/product-editor/src/components/editor/editor.tsx @@ -7,7 +7,6 @@ import { Fragment, useState, } from '@wordpress/element'; -import { PluginArea } from '@wordpress/plugins'; import { LayoutContextProvider, useExtendLayout, @@ -90,8 +89,6 @@ export function Editor( { postId: product.id, } } /> - { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } - } /> diff --git a/packages/js/product-editor/src/contexts/post-type-context/index.ts b/packages/js/product-editor/src/contexts/post-type-context/index.ts new file mode 100644 index 00000000000..968e2962631 --- /dev/null +++ b/packages/js/product-editor/src/contexts/post-type-context/index.ts @@ -0,0 +1,6 @@ +/** + * External dependencies + */ +import { createContext } from '@wordpress/element'; + +export const PostTypeContext = createContext( 'product' ); diff --git a/packages/js/product-editor/src/hooks/use-currency-input-props.ts b/packages/js/product-editor/src/hooks/use-currency-input-props.ts index b4aabcd0d09..d3716ed1ccc 100644 --- a/packages/js/product-editor/src/hooks/use-currency-input-props.ts +++ b/packages/js/product-editor/src/hooks/use-currency-input-props.ts @@ -8,7 +8,7 @@ import { useContext } from '@wordpress/element'; * Internal dependencies */ import { useProductHelper } from './use-product-helper'; -import { formatCurrencyDisplayValue } from '../utils'; +import { deferSelectInFocus, formatCurrencyDisplayValue } from '../utils'; export type CurrencyInputProps = { prefix: string; @@ -51,18 +51,7 @@ export const useCurrencyInputProps = ( { return sanitizePrice( String( val ) ); }, onFocus( event: React.FocusEvent< HTMLInputElement > ) { - // In some browsers like safari .select() function inside - // the onFocus event doesn't work as expected because it - // conflicts with onClick the first time user click the - // input. Using setTimeout defers the text selection and - // avoid the unexpected behaviour. - setTimeout( - function deferSelection( element: HTMLInputElement ) { - element.select(); - }, - 0, - event.currentTarget - ); + deferSelectInFocus( event.currentTarget ); if ( onFocus ) { onFocus( event ); } diff --git a/packages/js/product-editor/src/hooks/use-number-input-props.ts b/packages/js/product-editor/src/hooks/use-number-input-props.ts index 55c07270f96..a0397f0ee42 100644 --- a/packages/js/product-editor/src/hooks/use-number-input-props.ts +++ b/packages/js/product-editor/src/hooks/use-number-input-props.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import { useProductHelper } from './use-product-helper'; +import { deferSelectInFocus } from '../utils'; export type NumberInputProps = { value: string; @@ -28,18 +29,7 @@ export const useNumberInputProps = ( { const numberInputProps: NumberInputProps = { value: formatNumber( value ), onFocus( event: React.FocusEvent< HTMLInputElement > ) { - // In some browsers like safari .select() function inside - // the onFocus event doesn't work as expected because it - // conflicts with onClick the first time user click the - // input. Using setTimeout defers the text selection and - // avoid the unexpected behaviour. - setTimeout( - function deferSelection( element: HTMLInputElement ) { - element.select(); - }, - 0, - event.currentTarget - ); + deferSelectInFocus( event.currentTarget ); if ( onFocus ) { onFocus( event ); } diff --git a/packages/js/product-editor/src/index.ts b/packages/js/product-editor/src/index.ts index 878a63d638a..ad5ae2a28fb 100644 --- a/packages/js/product-editor/src/index.ts +++ b/packages/js/product-editor/src/index.ts @@ -20,5 +20,6 @@ export * from './utils'; * Hooks */ export * from './hooks'; +export { PostTypeContext } from './contexts/post-type-context'; export { useValidation, useValidations } from './contexts/validation-context'; export * from './contexts/validation-context/types'; diff --git a/packages/js/product-editor/src/utils/defer-select-in-focus.ts b/packages/js/product-editor/src/utils/defer-select-in-focus.ts new file mode 100644 index 00000000000..8f840918a14 --- /dev/null +++ b/packages/js/product-editor/src/utils/defer-select-in-focus.ts @@ -0,0 +1,17 @@ +export function deferSelectInFocus( element: HTMLInputElement ) { + // In some browsers like safari .select() function inside + // the onFocus event doesn't work as expected because it + // conflicts with onClick the first time user click the + // input. Using setTimeout defers the text selection and + // avoid the unexpected behaviour. + setTimeout( + function deferSelection( originalElement: HTMLInputElement ) { + if ( element.ownerDocument.activeElement === originalElement ) { + // We still have focus, so select the content. + originalElement.select(); + } + }, + 0, + element + ); +} diff --git a/packages/js/product-editor/src/utils/index.ts b/packages/js/product-editor/src/utils/index.ts index 472530f0533..2a1a8a2c252 100644 --- a/packages/js/product-editor/src/utils/index.ts +++ b/packages/js/product-editor/src/utils/index.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import { AUTO_DRAFT_NAME } from './constants'; +import { deferSelectInFocus } from './defer-select-in-focus'; import { formatCurrencyDisplayValue } from './format-currency-display-value'; import { getCheckboxTracks } from './get-checkbox-tracks'; import { getCurrencySymbolProps } from './get-currency-symbol-props'; @@ -30,6 +31,7 @@ export * from './sift'; export { AUTO_DRAFT_NAME, + deferSelectInFocus, formatCurrencyDisplayValue, getCheckboxTracks, getCurrencySymbolProps, diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx index 6763dbb9781..616d930a2c8 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx @@ -66,7 +66,7 @@ const MAX_PAGE_COUNT = 100; export const BlockEditor = ( {} ) => { const history = useHistory(); const settings = useSiteEditorSettings(); - const [ blocks, onChange ] = useEditorBlocks(); + const [ blocks, , onChange ] = useEditorBlocks(); const urlParams = useQuery(); const { currentState } = useContext( CustomizeStoreContext ); 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 d174987c34e..496e19287ef 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx @@ -145,35 +145,15 @@ export const Layout = () => { { ! isMobileViewport && ( -
+
{ canvasResizer } { !! canvasSize.width && ( { if ( placement === 'left' ) { - return [ -15, 35 ]; + return [ 0, 20 ]; } return [ 52, 16 ]; }, diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/resizable-frame.jsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/resizable-frame.jsx index b788f3f0ddd..a5a61fa0b45 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/resizable-frame.jsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/resizable-frame.jsx @@ -189,12 +189,10 @@ function ResizableFrame( { }, }; const currentResizeHandleVariant = ( () => { - if ( isResizing ) { + if ( isResizing || isHandleVisibleByDefault ) { return 'active'; } - return shouldShowHandle || isHandleVisibleByDefault - ? 'visible' - : 'hidden'; + return shouldShowHandle ? 'visible' : 'hidden'; } )(); const resizeHandler = ( @@ -246,6 +244,13 @@ function ResizableFrame( { if ( definition === 'fullWidth' ) setFrameSize( { width: '100%', height: '100%' } ); } } + whileHover={ { + scale: 1.005, + transition: { + duration: 0.5, + ease: 'easeOut', + }, + } } transition={ frameTransition } size={ frameSize } enable={ { diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-palette-variations/constants.ts b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-palette-variations/constants.ts index 032c29e0652..164d4cc8b20 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-palette-variations/constants.ts +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-palette-variations/constants.ts @@ -1187,6 +1187,13 @@ export const COLOR_PALETTES = [ text: 'var(--wp--preset--color--background)', }, }, + ':visited': { + color: { + text: color.styles.elements?.button + ? color.styles.elements.button.color + : 'var(--wp--preset--color--background)', + }, + }, color: { background: 'var(--wp--preset--color--primary)', text: 'var(--wp--preset--color--background)', diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-panel.jsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-panel.jsx index c9199f3a039..7386224f1cb 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-panel.jsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/color-panel.jsx @@ -26,8 +26,17 @@ export const ColorPanel = () => { const [ rawSettings ] = useGlobalSetting( '' ); const settings = useSettingsForBlockElement( rawSettings ); - const onChange = ( ...props ) => { - setStyle( ...props ); + const onChange = ( _style ) => { + setStyle( { + ..._style, + blocks: { + ..._style.blocks, + // Reset the "core/button" color that may have been set via predefined color palette to ensure it uses the custom button color. + 'core/button': { + color: {}, + }, + }, + } ); setUserConfig( ( currentConfig ) => ( { ...currentConfig, settings: mergeBaseAndUserConfigs( currentConfig.settings, { diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx index de1ee31a38b..b047f1f1ba2 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx @@ -4,10 +4,14 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { createInterpolateElement } from '@wordpress/element'; +import { createInterpolateElement, useContext } from '@wordpress/element'; import { Link } from '@woocommerce/components'; import { PanelBody } from '@wordpress/components'; import { recordEvent } from '@woocommerce/tracks'; +// @ts-ignore No types for this exist yet. +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +// @ts-ignore No types for this exist yet. +import { unlock } from '@wordpress/edit-site/build-module/lock-unlock'; /** * Internal dependencies @@ -16,7 +20,14 @@ import { SidebarNavigationScreen } from './sidebar-navigation-screen'; import { ADMIN_URL } from '~/utils/admin-settings'; import { ColorPalette, ColorPanel } from './global-styles'; +const { GlobalStylesContext } = unlock( blockEditorPrivateApis ); + const SidebarNavigationScreenColorPaletteContent = () => { + // @ts-ignore No types for this exist yet. + const { user } = useContext( GlobalStylesContext ); + const hasCreatedOwnColors = !! ( + user.settings.color && user.settings.color.palette.hasCreatedOwnColors + ); // Wrap in a BlockEditorProvider to ensure that the Iframe's dependencies are // loaded. This is necessary because the Iframe component waits until // the block editor store's `__internalIsInitialized` is true before @@ -34,7 +45,7 @@ const SidebarNavigationScreenColorPaletteContent = () => { diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx index 49698cc01bd..e0347c1e954 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx @@ -29,7 +29,7 @@ import { findPatternByBlock } from './utils'; import BlockPatternList from '../block-pattern-list'; const SUPPORTED_FOOTER_PATTERNS = [ - 'woocommerce-blocks/footer-simple-menu-and-cart', + 'woocommerce-blocks/footer-simple-menu', 'woocommerce-blocks/footer-with-3-menus', 'woocommerce-blocks/footer-large', ]; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss b/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss index daf6f619a2e..8209809df56 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss @@ -415,6 +415,11 @@ color: $gray-900; } } + + .color-block-support-panel { + border-top: 0; + padding: 0; + } } .woocommerce-customize-store_color-palette-container { diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx index bf0d11162b2..4c253200c42 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx @@ -116,8 +116,7 @@ export const ApiCallLoader = () => { return ( { loaderSteps.slice( 0, -1 ).map( ( step, index ) => ( diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/footer.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/footer.ts index b3c5a321656..5aa83b2e3fa 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/footer.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/footer.ts @@ -5,8 +5,8 @@ import { z } from 'zod'; const footerChoices = [ { - slug: 'woocommerce-blocks/footer-simple-menu-and-cart', - label: 'Footer with Simple Menu and Cart', + slug: 'woocommerce-blocks/footer-simple-menu', + label: 'Footer with Simple Menu', }, { slug: 'woocommerce-blocks/footer-with-3-menus', diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts index 2bda420e4ed..821e38b59d8 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts @@ -218,11 +218,11 @@ export const updateStorePatterns = async ( woocommerce_blocks_allow_ai_connection: true, } ); - const response: { + const { images } = await apiFetch< { ai_content_generated: boolean; - additional_errors?: unknown[]; - } = await apiFetch( { - path: '/wc/store/patterns', + images: Array< unknown >; + } >( { + path: '/wc/private/ai/images', method: 'POST', data: { business_description: @@ -230,6 +230,63 @@ export const updateStorePatterns = async ( }, } ); + const [ response ] = await Promise.all< { + ai_content_generated: boolean; + product_content: Array< { + title: string; + description: string; + image: { + src: string; + alt: string; + }; + } >; + additional_errors?: unknown[]; + } >( [ + apiFetch( { + path: '/wc/private/ai/products', + method: 'POST', + data: { + business_description: + context.businessInfoDescription.descriptionText, + images, + }, + } ), + apiFetch( { + path: '/wc/private/ai/patterns', + method: 'POST', + data: { + business_description: + context.businessInfoDescription.descriptionText, + images, + }, + } ), + ] ); + + const productContents = response.product_content.map( + ( product, index ) => { + return apiFetch( { + path: '/wc/private/ai/product', + method: 'POST', + data: { + products_information: product, + index, + }, + } ); + } + ); + + await Promise.all( [ + ...productContents, + apiFetch( { + path: '/wc/private/ai/business-description', + method: 'POST', + data: { + business_description: + context.businessInfoDescription.descriptionText, + }, + } ), + ] ); + if ( ! response.ai_content_generated ) { throw new Error( 'AI content not generated: ' + response.additional_errors @@ -260,6 +317,12 @@ const updateGlobalStyles = async ( { ( pairing ) => pairing.title === fontPairingName ); + // @ts-ignore No types for this exist yet. + const { invalidateResolutionForStoreSelector } = dispatch( coreStore ); + invalidateResolutionForStoreSelector( + '__experimentalGetCurrentGlobalStylesId' + ); + const globalStylesId = await resolveSelect( coreStore // @ts-ignore No types for this exist yet. @@ -299,6 +362,7 @@ const updateTemplate = async ( { // Ensure that the patterns are up to date because we populate images and content in previous step. invalidateResolutionForStoreSelector( 'getBlockPatterns' ); + invalidateResolutionForStoreSelector( '__experimentalGetTemplateForLink' ); const patterns = ( await resolveSelect( coreStore @@ -349,7 +413,6 @@ export const assembleSite = async ( } ); recordEvent( 'customize_your_store_ai_update_global_styles_success' ); } catch ( error ) { - // TODO handle error // eslint-disable-next-line no-console console.error( error ); recordEvent( @@ -358,6 +421,7 @@ export const assembleSite = async ( error: error instanceof Error ? error.message : 'unknown', } ); + throw error; } try { @@ -368,12 +432,12 @@ export const assembleSite = async ( } ); recordEvent( 'customize_your_store_ai_update_template_success' ); } catch ( error ) { - // TODO handle error // eslint-disable-next-line no-console console.error( error ); recordEvent( 'customize_your_store_ai_update_template_response_error', { error: error instanceof Error ? error.message : 'unknown', } ); + throw error; } }; diff --git a/plugins/woocommerce-admin/client/marketing/data-multichannel/types.ts b/plugins/woocommerce-admin/client/marketing/data-multichannel/types.ts index 6d3c0e93170..c216e92170d 100644 --- a/plugins/woocommerce-admin/client/marketing/data-multichannel/types.ts +++ b/plugins/woocommerce-admin/client/marketing/data-multichannel/types.ts @@ -58,7 +58,7 @@ export type Campaign = { cost: { value: string; currency: string; - }; + } | null; }; export type CampaignsPage = { diff --git a/plugins/woocommerce-admin/client/marketing/hooks/useCampaigns.ts b/plugins/woocommerce-admin/client/marketing/hooks/useCampaigns.ts index d23312d00b0..12836d68495 100644 --- a/plugins/woocommerce-admin/client/marketing/hooks/useCampaigns.ts +++ b/plugins/woocommerce-admin/client/marketing/hooks/useCampaigns.ts @@ -46,11 +46,15 @@ export const useCampaigns = ( page = 1, perPage = 5 ): UseCampaignsType => { ( el ) => el.slug === campaign.channel ); + const cost = campaign.cost + ? `${ campaign.cost.currency } ${ campaign.cost.value }` + : ''; + return { id: `${ campaign.channel }|${ campaign.id }`, title: campaign.title, description: '', - cost: `${ campaign.cost.currency } ${ campaign.cost.value }`, + cost, manageUrl: campaign.manage_url, icon: channel?.icon || '', channelName: channel?.title || '', diff --git a/plugins/woocommerce-admin/client/marketing/overview-multichannel/Campaigns/Campaigns.tsx b/plugins/woocommerce-admin/client/marketing/overview-multichannel/Campaigns/Campaigns.tsx index c1ee9a81e90..d737526177d 100644 --- a/plugins/woocommerce-admin/client/marketing/overview-multichannel/Campaigns/Campaigns.tsx +++ b/plugins/woocommerce-admin/client/marketing/overview-multichannel/Campaigns/Campaigns.tsx @@ -20,6 +20,7 @@ import { TablePlaceholder, Link, } from '@woocommerce/components'; +import { isWCAdmin } from '@woocommerce/navigation'; /** * Internal dependencies @@ -138,7 +139,16 @@ export const Campaigns = () => { - + { el.title } diff --git a/plugins/woocommerce/changelog/2023-11-03-05-13-10-305725 b/plugins/woocommerce/changelog/2023-11-03-05-13-10-305725 new file mode 100644 index 00000000000..9eba385c1bb --- /dev/null +++ b/plugins/woocommerce/changelog/2023-11-03-05-13-10-305725 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix save button is still disabled after updating logo settings diff --git a/plugins/woocommerce/changelog/41092-fix-preload-jetpack-data-without-jetpack-plugin b/plugins/woocommerce/changelog/41092-fix-preload-jetpack-data-without-jetpack-plugin deleted file mode 100644 index b2b85e42970..00000000000 --- a/plugins/woocommerce/changelog/41092-fix-preload-jetpack-data-without-jetpack-plugin +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Preload Jetpack-related data from the Jetpack Connection package \ No newline at end of file diff --git a/plugins/woocommerce/changelog/41181-rename-footer-pattern b/plugins/woocommerce/changelog/41181-rename-footer-pattern new file mode 100644 index 00000000000..3d3b1c25f9a --- /dev/null +++ b/plugins/woocommerce/changelog/41181-rename-footer-pattern @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Rename the reference to the 'Footer with Simple Menu and Cart' pattern \ No newline at end of file diff --git a/plugins/woocommerce/changelog/41182-fix-marketing-campaign-link b/plugins/woocommerce/changelog/41182-fix-marketing-campaign-link new file mode 100644 index 00000000000..e4a2795e8f6 --- /dev/null +++ b/plugins/woocommerce/changelog/41182-fix-marketing-campaign-link @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix marketing campaign link not navigating to the right page. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/add-stalebot-schedules b/plugins/woocommerce/changelog/add-stalebot-schedules new file mode 100644 index 00000000000..49268dd5210 --- /dev/null +++ b/plugins/woocommerce/changelog/add-stalebot-schedules @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Add stalebot schedules to allow processing of all issues diff --git a/plugins/woocommerce/changelog/e2e-display-correct-tax-checkout b/plugins/woocommerce/changelog/e2e-display-correct-tax-checkout new file mode 100644 index 00000000000..59f51148725 --- /dev/null +++ b/plugins/woocommerce/changelog/e2e-display-correct-tax-checkout @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Adds e2e tests for tax display in store, cart and checkout diff --git a/plugins/woocommerce/changelog/fix-31146-extensions-page-undefined-property b/plugins/woocommerce/changelog/fix-31146-extensions-page-undefined-property new file mode 100644 index 00000000000..8e24ef63f36 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-31146-extensions-page-undefined-property @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Avoid the PHP error with an undefined property on the WooCommerce > Extensions page. diff --git a/plugins/woocommerce/changelog/fix-cys-initial-pattern-population b/plugins/woocommerce/changelog/fix-cys-initial-pattern-population new file mode 100644 index 00000000000..16cf8c00097 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-cys-initial-pattern-population @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix CYS initial pattern population bug diff --git a/plugins/woocommerce/changelog/fix-cys-ui-nov-3 b/plugins/woocommerce/changelog/fix-cys-ui-nov-3 new file mode 100644 index 00000000000..88609f2f700 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-cys-ui-nov-3 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix cys ui issues diff --git a/plugins/woocommerce/changelog/fix-daily-and-pr-tests b/plugins/woocommerce/changelog/fix-daily-and-pr-tests new file mode 100644 index 00000000000..a44daa2eb9c --- /dev/null +++ b/plugins/woocommerce/changelog/fix-daily-and-pr-tests @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix for PR tests and daily tests diff --git a/plugins/woocommerce/changelog/fix-null-allowed-in-multichannel-campaign b/plugins/woocommerce/changelog/fix-null-allowed-in-multichannel-campaign new file mode 100644 index 00000000000..d59f8babd1a --- /dev/null +++ b/plugins/woocommerce/changelog/fix-null-allowed-in-multichannel-campaign @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Allow null value in cost field for multichannel campaign. diff --git a/plugins/woocommerce/changelog/fix-pattern-route-performance b/plugins/woocommerce/changelog/fix-pattern-route-performance new file mode 100644 index 00000000000..e164c17f998 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-pattern-route-performance @@ -0,0 +1,4 @@ +Significance: minor +Type: performance + +use multiple endpoints to improve performance diff --git a/plugins/woocommerce/changelog/pr-32288 b/plugins/woocommerce/changelog/pr-32288 new file mode 100644 index 00000000000..7665551be4a --- /dev/null +++ b/plugins/woocommerce/changelog/pr-32288 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fixed warning on wc_get_product_variation_attributes when product does not exist diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-addons.php b/plugins/woocommerce/includes/admin/class-wc-admin-addons.php index 6f00b9dbfa0..9a470d80346 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-addons.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-addons.php @@ -1263,7 +1263,7 @@ class WC_Admin_Addons { 'title' => $locale->title, 'description' => $locale->description, 'image' => ( 'http' === substr( $locale->image, 0, 4 ) ) ? $locale->image : WC()->plugin_url() . $locale->image, - 'image_alt' => $locale->image_alt, + 'image_alt' => $locale->image_alt ?? '', 'actions' => $promotion_actions, ); } diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index aa4825986d1..69ce0e4cba1 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -685,7 +685,7 @@ function wc_get_product_id_by_sku( $sku ) { */ function wc_get_product_variation_attributes( $variation_id ) { // Build variation data from meta. - $all_meta = get_post_meta( $variation_id ); + $all_meta = is_array( get_post_meta( $variation_id ) ) ? get_post_meta( $variation_id ) : array(); $parent_id = wp_get_post_parent_id( $variation_id ); $parent_attributes = array_filter( (array) get_post_meta( $parent_id, '_product_attributes', true ) ); $found_parent_attributes = array(); diff --git a/plugins/woocommerce/tests/e2e-pw/global-setup.js b/plugins/woocommerce/tests/e2e-pw/global-setup.js index 3003372f419..a919e106f46 100644 --- a/plugins/woocommerce/tests/e2e-pw/global-setup.js +++ b/plugins/woocommerce/tests/e2e-pw/global-setup.js @@ -224,7 +224,7 @@ module.exports = async ( config ) => { } } - !process.env.BASE_URL || process.env.BASE_URL === 'localhost' && await site.useCartCheckoutShortcodes( baseURL, userAgent, admin ); + await site.useCartCheckoutShortcodes( baseURL, userAgent, admin ); await adminContext.close(); await customerContext.close(); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/shopper/checkout-calculate-tax.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/shopper/checkout-calculate-tax.spec.js new file mode 100644 index 00000000000..41132ac0dc9 --- /dev/null +++ b/plugins/woocommerce/tests/e2e-pw/tests/shopper/checkout-calculate-tax.spec.js @@ -0,0 +1,695 @@ +const { test, expect } = require( '@playwright/test' ); +const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default; +const { admin, customer } = require( '../../test-data/data' ); + +const productName = 'Taxed products are awesome'; +const productPrice = '100.00'; +const messyProductPrice = '13.47'; +const secondProductName = 'Other products are also awesome'; + +let productId, productId2, nastyTaxId, seventeenTaxId, sixTaxId, countryTaxId, stateTaxId, cityTaxId, zipTaxId, shippingTaxId, shippingZoneId, shippingMethodId; + +test.describe( 'Shopper Tax Display Tests', () => { + + test.beforeAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'yes', + } ); + await api.post( 'products', { + name: productName, + type: 'simple', + regular_price: productPrice, + } ) + .then( ( response ) => { + productId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "US", + "state": "*", + "cities": "*", + "postcodes": "*", + "rate": "25", + "name": "Nasty Tax", + "shipping": false + } ) + .then( ( response ) => { + nastyTaxId = response.data.id; + } ); + } ); + + test.beforeEach( async ( { page, context } ) => { + // Shopping cart is very sensitive to cookies, so be explicit + await context.clearCookies(); + + // all tests use the first product + await page.goto( `/shop/?add-to-cart=${ productId }`, { waitUntil: 'networkidle' } ); + } ); + + test.afterAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'excl' + } ); + await api.put( 'settings/tax/woocommerce_price_display_suffix', { + value: '', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'no', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'itemized', + } ); + await api.delete( `products/${ productId }`, { + force: true, + } ); + await api.delete( `taxes/${ nastyTaxId }`, { + force: true, + } ); + } ); + + test( 'checks that taxes are calculated properly on totals, inclusive tax displayed properly', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'incl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'incl' + } ); + + await test.step( 'Load shop page and confirm price display', async() => { + await page.goto( '/shop/' ); + await expect( page.getByRole( 'heading', { name: 'Shop' } ) ).toBeVisible(); + await expect( page.getByRole('link', { name: 'Placeholder Taxed products are awesome $125.00' }).first() ).toBeVisible(); + } ); + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$125.00 (incl. tax)' } ) ).toHaveCount(2); + await expect( page.getByRole( 'row', { name: 'Subtotal $125.00 (incl. tax)'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $125.00 (includes $25.00 Nasty Tax)' } ) ).toBeVisible(); + } ); + + await test.step( 'Load checkout page and confirm price display', async() => { + await page.goto( '/checkout/' ); + await expect( page.getByRole( 'heading', { name: 'Checkout' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Taxed products are awesome × 1 $125.00 (incl. tax)' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Subtotal $125.00 (incl. tax)' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $125.00 (includes $25.00 Nasty Tax)'} ) ).toBeVisible(); + } ); + } ); + + test( 'checks that taxes are calculated and displayed correctly exclusive on shop, cart and checkout', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'excl' + } ); + + await test.step( 'Load shop page and confirm price display', async() => { + await page.goto( '/shop/' ); + await expect( page.getByRole( 'heading', { name: 'Shop' } ) ).toBeVisible(); + await expect( page.getByRole('link', { name: 'Placeholder Taxed products are awesome $100.00' }).first() ).toBeVisible(); + } ); + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$100.00' } ) ).toHaveCount(3); + await expect( page.getByRole( 'row', { name: 'Subtotal $100.00'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Tax $25.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $125.00' } ) ).toBeVisible(); + } ); + + await test.step( 'Load checkout page and confirm price display', async() => { + await page.goto( '/checkout/' ); + await expect( page.getByRole( 'heading', { name: 'Checkout' } ) ).toBeVisible(); + + await page.locator( '#billing_first_name' ).fill( customer.billing.us.first_name ); + await page.locator( '#billing_last_name' ).fill( customer.billing.us.last_name ); + await page.locator( '#billing_address_1' ).fill( customer.billing.us.address ); + await page.locator( '#billing_city' ).fill( customer.billing.us.city ); + await page.locator( '#billing_country' ).selectOption( customer.billing.us.country ); + await page.locator( '#billing_state' ).selectOption( customer.billing.us.state ); + await page.locator( '#billing_postcode' ).fill( customer.billing.us.zip ); + await page.locator( '#billing_phone' ).fill( customer.billing.us.phone ); + await page.locator( '#billing_email' ).fill( customer.billing.us.email ); + + await expect( page.getByRole( 'row', { name: 'Taxed products are awesome × 1 $100.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Subtotal $100.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Tax $25.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $125.00' } ) ).toBeVisible(); + } ); + } ); + + test( 'checks that display suffix is shown', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_price_display_suffix', { + value: 'excluding VAT', + } ); + + await test.step( 'Load shop page and confirm price suffix display', async() => { + await page.goto( '/shop/' ); + await expect( page.getByRole( 'heading', { name: 'Shop' } ) ).toBeVisible(); + await expect( page.getByRole('link', { name: 'Placeholder Taxed products are awesome $100.00 excluding VAT' }).first() ).toBeVisible(); + } ); + } ); +} ); + +test.describe( 'Shopper Tax Rounding', () => { + + test.beforeAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'yes', + } ); + await api.post( 'products', { + name: productName, + type: 'simple', + regular_price: messyProductPrice, + } ) + .then( ( response ) => { + productId = response.data.id; + } ); + await api.post( 'products', { + name: secondProductName, + type: 'simple', + regular_price: messyProductPrice, + } ) + .then( ( response ) => { + productId2 = response.data.id; + } ); + await api.post( 'taxes', { + "country": "US", + "state": "*", + "cities": "*", + "postcodes": "*", + "rate": "17", + "name": "Seventeen Tax", + "shipping": false, + "compound": true, + "priority": 1 + } ) + .then( ( response ) => { + seventeenTaxId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "US", + "state": "*", + "cities": "*", + "postcodes": "*", + "rate": "6", + "name": "Six Tax", + "shipping": false, + "compound": true, + "priority": 2 + } ) + .then( ( response ) => { + sixTaxId = response.data.id; + } ); + } ); + + test.beforeEach( async ( { page, context } ) => { + // Shopping cart is very sensitive to cookies, so be explicit + await context.clearCookies(); + + // all tests use the first product + await page.goto( `/shop/?add-to-cart=${ productId }`, { waitUntil: 'networkidle' } ); + await page.goto( `/shop/?add-to-cart=${ productId2 }`, { waitUntil: 'networkidle' } ); + await page.goto( `/shop/?add-to-cart=${ productId2 }`, { waitUntil: 'networkidle' } ); + } ); + + test.afterAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'excl' + } ); + await api.put( 'settings/tax/woocommerce_tax_round_at_subtotal', { + value: 'no', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'no', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'itemized' + } ); + await api.delete( `products/${ productId }`, { + force: true, + } ); + await api.delete( `products/${ productId2 }`, { + force: true, + } ); + await api.delete( `taxes/${ seventeenTaxId }`, { + force: true, + } ); + await api.delete( `taxes/${ sixTaxId }`, { + force: true, + } ); + } ); + + test( 'checks rounding at subtotal level', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_round_at_subtotal', { + value: 'yes', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'single', + } ); + + await test.step( 'Load shop page and confirm price display', async() => { + await page.goto( '/shop/' ); + await expect( page.getByRole( 'heading', { name: 'Shop' } ) ).toBeVisible(); + await expect( page.getByRole('link', { name: 'Placeholder Taxed products are awesome $13.47' }).first() ).toBeVisible(); + } ); + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$13.47' } ) ).toHaveCount(3); + await expect( page.getByRole( 'row', { name: 'Subtotal $40.41'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Tax $9.71 ' } ) ).toBeVisible() + await expect( page.getByRole( 'row', { name: 'Total $50.12 ' } ) ).toBeVisible(); + } ); + } ); + + test( 'checks rounding off at subtotal level', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_shop', { + value: 'excl', + } ); + await api.put( 'settings/tax/woocommerce_tax_round_at_subtotal', { + value: 'no', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'itemized', + } ); + + await test.step( 'Load shop page and confirm price display', async() => { + await page.goto( '/shop/' ); + await expect( page.getByRole( 'heading', { name: 'Shop' } ) ).toBeVisible(); + await expect( page.getByRole('link', { name: 'Placeholder Taxed products are awesome $13.47' }).first() ).toBeVisible(); + } ); + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$13.47' } ) ).toHaveCount(3); + await expect( page.getByRole( 'row', { name: 'Subtotal $40.41'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Seventeen Tax $6.87 ' } ) ).toBeVisible() + await expect( page.getByRole( 'row', { name: 'Six Tax $2.84 ' } ) ).toBeVisible() + await expect( page.getByRole( 'row', { name: 'Total $50.12 ' } ) ).toBeVisible(); + } ); + } ); +} ); + +test.describe( 'Shopper Tax Levels', () => { + + test.beforeAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'yes', + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.post( 'products', { + name: productName, + type: 'simple', + regular_price: productPrice, + } ) + .then( ( response ) => { + productId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "US", + "state": "*", + "cities": "*", + "postcodes": "*", + "rate": "10", + "name": "Country Tax", + "shipping": false, + "priority": 1 + } ) + .then( ( response ) => { + countryTaxId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "*", + "state": "CA", + "cities": "*", + "postcodes": "*", + "rate": "5", + "name": "State Tax", + "shipping": false, + "priority": 2 + } ) + .then( ( response ) => { + stateTaxId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "*", + "state": "*", + "cities": "Sacramento", + "postcodes": "*", + "rate": "2.5", + "name": "City Tax", + "shipping": false, + "priority": 3 + } ) + .then( ( response ) => { + cityTaxId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "*", + "state": "*", + "cities": "*", + "postcodes": "55555", + "rate": "1.25", + "name": "Zip Tax", + "shipping": false, + "priority": 4 + } ) + .then( ( response ) => { + zipTaxId = response.data.id; + } ); + } ); + + test.beforeEach( async ( { page, context } ) => { + // Shopping cart is very sensitive to cookies, so be explicit + await context.clearCookies(); + + // all tests use the first product + await page.goto( `/shop/?add-to-cart=${ productId }`, { waitUntil: 'networkidle' } ); + } ); + + test.afterAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'itemized' + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'excl', + } ); + await api.delete( `products/${ productId }`, { + force: true, + } ); + + await api.delete( `taxes/${ countryTaxId }`, { + force: true, + } ); + await api.delete( `taxes/${ stateTaxId }`, { + force: true, + } ); + await api.delete( `taxes/${ cityTaxId }`, { + force: true, + } ); + await api.delete( `taxes/${ zipTaxId }`, { + force: true, + } ); + } ); + + test( 'checks applying taxes of 4 different levels', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'itemized', + } ); + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$100.00' } ) ).toHaveCount(3); + await expect( page.getByRole( 'row', { name: 'Subtotal $100.00'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Country Tax $10.00 ' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'State Tax $5.00 ' } ) ).toBeVisible() + await expect( page.getByRole( 'row', { name: 'Total $115.00 ' } ) ).toBeVisible(); + } ); + + await test.step( 'Load checkout page and confirm taxes displayed', async() => { + await page.goto( '/checkout/' ); + await expect( page.getByRole( 'heading', { name: 'Checkout', exact: true } ) ).toBeVisible(); + + await page.getByLabel('First name *').first().fill( customer.billing.us.first_name ); + await page.getByLabel('Last name *').first().fill( customer.billing.us.last_name ); + await page.getByPlaceholder('House number and street name').first().fill( customer.billing.us.address ); + await page.getByLabel('Town / City *').first().pressSequentially( 'Sacramento' ); + await page.getByLabel('ZIP Code *').first().pressSequentially( '55555' ); + await page.getByLabel('Phone *').first().fill( customer.billing.us.phone ); + await page.getByLabel('Email address *').first().fill( customer.billing.us.email ); + + await expect( page.getByRole( 'row', { name: 'Subtotal $100.00'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Country Tax $10.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'State Tax $5.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'City Tax $2.50' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Zip Tax $1.25' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $118.75 ' } ) ).toBeVisible(); + } ); + } ); + + test( 'checks applying taxes of 2 different levels (2 excluded)', async ( { page, baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/tax/woocommerce_tax_total_display', { + value: 'itemized', + } ); + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$100.00' } ) ).toHaveCount(3); + await expect( page.getByRole( 'row', { name: 'Subtotal $100.00'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Country Tax $10.00 ' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'State Tax $5.00 ' } ) ).toBeVisible() + await expect( page.getByRole( 'row', { name: 'Total $115.00 ' } ) ).toBeVisible(); + } ); + + await test.step( 'Load checkout page and confirm taxes displayed', async() => { + await page.goto( '/checkout/' ); + await expect( page.getByRole( 'heading', { name: 'Checkout', exact: true } ) ).toBeVisible(); + + await page.getByLabel('First name *').first().fill( customer.billing.us.first_name ); + await page.getByLabel('Last name *').first().fill( customer.billing.us.last_name ); + await page.getByPlaceholder('House number and street name').first().fill( customer.billing.us.address ); + await page.getByLabel('Town / City *').first().pressSequentially( customer.billing.us.city ); + await page.getByLabel('ZIP Code *').first().pressSequentially( customer.billing.us.zip ); + await page.getByLabel('Phone *').first().fill( customer.billing.us.phone ); + await page.getByLabel('Email address *').first().fill( customer.billing.us.email ); + + await expect( page.getByRole( 'row', { name: 'Subtotal $100.00'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Country Tax $10.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'State Tax $5.00' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'City Tax $2.50' } ) ).not.toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Zip Tax $1.25' } ) ).not.toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $115.00 ' } ) ).toBeVisible(); + } ); + } ); +} ); + +test.describe( 'Shipping Tax', () => { + + test.beforeAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'yes', + } ); + await api.post( 'products', { + name: productName, + type: 'simple', + regular_price: productPrice, + } ) + .then( ( response ) => { + productId = response.data.id; + } ); + await api.post( 'taxes', { + "country": "US", + "state": "*", + "cities": "*", + "postcodes": "*", + "rate": "15", + "name": "Shipping Tax", + "shipping": true + } ) + .then( ( response ) => { + shippingTaxId = response.data.id; + } ); + await api.post( 'shipping/zones', { + name: 'All', + } ) + .then( ( response ) => { + shippingZoneId = response.data.id; + } ); + await api.post( `shipping/zones/${ shippingZoneId }/methods`, { + method_id: 'flat_rate', + } ) + .then( ( response ) => { + shippingMethodId = response.data.id; + } ); + await api.put( `shipping/zones/${ shippingZoneId }/methods/${ shippingMethodId }`, { + settings: { + cost: '20.00', + } + } ); + await api.put( 'payment_gateways/cod' , { + enabled: true + } ); + await api.put( 'settings/tax/woocommerce_tax_display_cart', { + value: 'incl', + } ); + } ); + + test.beforeEach( async ( { page, context } ) => { + // Shopping cart is very sensitive to cookies, so be explicit + await context.clearCookies(); + + // all tests use the first product + await page.goto( `/shop/?add-to-cart=${ productId }`, { waitUntil: 'networkidle' } ); + } ); + + test.afterAll( async ( { baseURL } ) => { + const api = new wcApi( { + url: baseURL, + consumerKey: process.env.CONSUMER_KEY, + consumerSecret: process.env.CONSUMER_SECRET, + version: 'wc/v3', + } ); + await api.put( 'settings/general/woocommerce_calc_taxes', { + value: 'no', + } ); + await api.delete( `products/${ productId }`, { + force: true, + } ); + await api.delete( `taxes/${ shippingTaxId }`, { + force: true, + } ); + await api.put( 'payment_gateways/cod' , { + enabled: false + } ); + await api.delete( `shipping/zones/${ shippingZoneId }`, { + force: true, + } ); + } ); + + test( 'checks that tax is applied to shipping as well as order', async ( { page, baseURL } ) => { + + await test.step( 'Load cart page and confirm price display', async() => { + await page.goto( '/cart/' ); + await expect( page.getByRole( 'heading', { name: 'Cart', exact: true } ) ).toBeVisible(); + await expect( page.getByRole( 'cell', { name: '$115.00 (incl. tax)' } ) ).toHaveCount(2); + await expect( page.getByRole( 'row', { name: 'Subtotal $115.00 (incl. tax)'} ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Shipping Flat rate: $23.00 (incl. tax) Shipping to CA.' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $138.00 (includes $18.00 Shipping Tax)' } ) ).toBeVisible(); + } ); + + await test.step( 'Load checkout page and confirm price display', async() => { + await page.goto( '/checkout/' ); + await expect( page.getByRole( 'heading', { name: 'Checkout' } ) ).toBeVisible(); + + await page.getByRole('textbox', { name: 'First name *' }).fill( customer.billing.us.first_name ); + await page.getByRole('textbox', { name: 'Last name *' }).fill( customer.billing.us.last_name ); + await page.getByRole('textbox', { name: 'Street address *' }).fill( customer.billing.us.address ); + await page.getByRole('textbox', { name: 'Town / City *' }).type( customer.billing.us.city ); + await page.getByRole('textbox', { name: 'ZIP Code *' }).type( customer.billing.us.zip ); + await page.getByLabel('Phone *').fill( customer.billing.us.phone ); + await page.getByLabel('Email address *').fill( customer.billing.us.email ); + + await expect( page.getByRole( 'row', { name: 'Taxed products are awesome × 1 $115.00 (incl. tax)' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Subtotal $115.00 (incl. tax)' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Shipping Flat rate: $23.00 (incl. tax)' } ) ).toBeVisible(); + await expect( page.getByRole( 'row', { name: 'Total $138.00 (includes $18.00 Shipping Tax)'} ) ).toBeVisible(); + } ); + + } ); + +} );