Merge branch 'trunk' into e2e/remove-obw-tests

# Conflicts:
#	plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/complete-onboarding-wizard.spec.js
This commit is contained in:
Jon Lane 2023-09-19 15:57:23 -07:00
commit 6458ad4657
112 changed files with 3364 additions and 867 deletions

View File

@ -0,0 +1,175 @@
module.exports = async ( { context, core, github } ) => {
const {
API_RESULT,
E2E_RESULT,
k6_RESULT,
PLUGINS_BLOCKS_PATH,
PLUGIN_TESTS_RESULT,
GITHUB_REF_NAME,
GITHUB_RUN_ID,
} = process.env;
const {
selectEmoji,
readContextBlocksFromJsonFiles,
} = require( './utils' );
const URL_GITHUB_RUN_LOG = `https://github.com/woocommerce/woocommerce/actions/runs/${ GITHUB_RUN_ID }`;
const create_blockGroup_header = async () => {
const getRunStartDate = async () => {
const response = await github.rest.actions.getWorkflowRun( {
owner: context.repo.owner,
repo: context.repo.repo,
run_id: GITHUB_RUN_ID,
} );
const runStartedAt = new Date( response.data.run_started_at );
const intlDateTimeFormatOptions = {
dateStyle: 'full',
timeStyle: 'long',
};
const date = new Intl.DateTimeFormat(
'en-US',
intlDateTimeFormatOptions
).format( runStartedAt );
return date;
};
const readableDate = await getRunStartDate();
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: 'Daily test results',
emoji: true,
},
},
{
type: 'divider',
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*Run started:* ${ readableDate }`,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*Branch:* \`${ GITHUB_REF_NAME }\``,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*GitHub run logs:* <${ URL_GITHUB_RUN_LOG }|${ GITHUB_RUN_ID }>`,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: '*Test reports dashboard:* <https://woocommerce.github.io/woocommerce-test-reports/daily/|Daily smoke tests>',
},
],
},
{
type: 'divider',
},
];
return blocks;
};
const create_blockGroup_nightlySite = () => {
const emoji_API = selectEmoji( API_RESULT );
const emoji_E2E = selectEmoji( E2E_RESULT );
const emoji_k6 = selectEmoji( k6_RESULT );
const url_API =
'https://woocommerce.github.io/woocommerce-test-reports/daily/nightly-site/api';
const url_E2E =
'https://woocommerce.github.io/woocommerce-test-reports/daily/nightly-site/e2e';
const url_k6 = URL_GITHUB_RUN_LOG;
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `<${ URL_GITHUB_RUN_LOG }|*Smoke tests on daily build*>`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `<${ url_API }|API> ${ emoji_API }\t<${ url_E2E }|E2E> ${ emoji_E2E }\t<${ url_k6 }|k6> ${ emoji_k6 }`,
},
],
},
{
type: 'divider',
},
];
return blocks;
};
const create_blockGroups_plugins = () => {
const pluginTestsSkipped = PLUGIN_TESTS_RESULT === 'skipped';
const blocks_pluginTestsSkipped = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: ':warning: *Plugin tests were not run!*',
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Head over to the <${ URL_GITHUB_RUN_LOG }|GitHub workflow run log> to see what went wrong.`,
},
],
},
{
type: 'divider',
},
];
return pluginTestsSkipped
? blocks_pluginTestsSkipped
: readContextBlocksFromJsonFiles( PLUGINS_BLOCKS_PATH );
};
const blockGroup_header = await create_blockGroup_header();
const blockGroup_nightlySite = create_blockGroup_nightlySite();
const blockGroups_plugins = create_blockGroups_plugins();
const blocks_all = [
...blockGroup_header,
...blockGroup_nightlySite,
...blockGroups_plugins.flat(),
];
const payload = {
text: 'Daily test results',
blocks: blocks_all,
};
const payload_stringified = JSON.stringify( payload );
core.setOutput( 'payload', payload_stringified );
};

View File

@ -0,0 +1,37 @@
module.exports = ( { core } ) => {
const { UPLOAD_RESULT, E2E_RESULT, PLUGIN_NAME, PLUGIN_SLUG } = process.env;
const { selectEmoji } = require( './utils' );
const fs = require( 'fs' );
const emoji_UPLOAD = selectEmoji( UPLOAD_RESULT );
const emoji_E2E = selectEmoji( E2E_RESULT );
const reportURL = `https://woocommerce.github.io/woocommerce-test-reports/daily/${ PLUGIN_SLUG }/e2e`;
const blockGroup = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `<${ reportURL }|*${ PLUGIN_NAME }*>`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `"Upload plugin" test ${ emoji_UPLOAD }\tOther E2E tests ${ emoji_E2E }`,
},
],
},
{
type: 'divider',
},
];
const blockGroup_stringified = JSON.stringify( blockGroup );
const path = `/tmp/${ PLUGIN_SLUG }.json`;
fs.writeFileSync( path, blockGroup_stringified );
core.setOutput( 'path', path );
};

View File

@ -0,0 +1,26 @@
const fs = require( 'fs' );
const path = require( 'path' );
/**
* @param {string} blocksDir
* @returns {any[][]}
*/
const readContextBlocksFromJsonFiles = ( blocksDir ) => {
const jsonsDir = path.resolve( blocksDir );
const jsons = fs.readdirSync( jsonsDir );
let contextBlocks = [];
for ( const json of jsons ) {
const jsonPath = path.resolve( jsonsDir, json );
const contextBlock = require( jsonPath );
contextBlocks.push( contextBlock );
}
return contextBlocks;
};
module.exports = {
readContextBlocksFromJsonFiles,
};

View File

@ -0,0 +1,7 @@
const { readContextBlocksFromJsonFiles } = require( './get-context-blocks' );
const { selectEmoji } = require( './select-emoji' );
module.exports = {
readContextBlocksFromJsonFiles,
selectEmoji,
};

View File

@ -0,0 +1,26 @@
const emojis = {
PASSED: ':workflow-passed:',
FAILED: ':workflow-failed:',
SKIPPED: ':workflow-skipped:',
CANCELLED: ':workflow-cancelled:',
UNKNOWN: ':grey_question:',
};
const selectEmoji = ( result ) => {
switch ( result ) {
case 'success':
return emojis.PASSED;
case 'failure':
return emojis.FAILED;
case 'skipped':
return emojis.SKIPPED;
case 'cancelled':
return emojis.CANCELLED;
default:
return emojis.UNKNOWN;
}
};
module.exports = {
selectEmoji,
};

View File

@ -8,6 +8,7 @@ env:
API_ARTIFACT: api-daily--run-${{ github.run_number }}
E2E_ARTIFACT: e2e-daily--run-${{ github.run_number }}
FORCE_COLOR: 1
PLUGIN_SLACK_BLOCKS_ARTIFACT: plugin-blocks
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -21,6 +22,8 @@ jobs:
runs-on: ubuntu-20.04
permissions:
contents: read
outputs:
test-result: ${{ steps.run-api-composite-action.outputs.result }}
env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-report
@ -42,38 +45,31 @@ jobs:
install-filters: woocommerce
build: false
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Update site to nightly version
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: ${{ env.API_ARTIFACT }}
tests: update-woocommerce.spec.js
env:
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
UPDATE_WC: nightly
- name: Run API tests.
working-directory: plugins/woocommerce
- name: Run API tests
id: run-api-composite-action
uses: ./.github/actions/tests/run-api-tests
with:
report-name: ${{ env.API_ARTIFACT }}
env:
USER_KEY: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
USER_SECRET: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
run: pnpm exec playwright test --config=tests/api-core-tests/playwright.config.js
- name: Generate API Test report.
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive API test report
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: ${{ env.API_ARTIFACT }}
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore
retention-days: 5
e2e-tests:
name: E2E tests on nightly build
runs-on: ubuntu-20.04
permissions:
contents: read
outputs:
test-result: ${{ steps.run-e2e-composite-action.outputs.result }}
# needs: [api-tests]
env:
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
@ -85,7 +81,6 @@ jobs:
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
DEFAULT_TIMEOUT_OVERRIDE: 120000
steps:
- uses: actions/checkout@v3
@ -95,33 +90,24 @@ jobs:
install-filters: woocommerce
build: false
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Run E2E tests.
- name: Run E2E tests
id: run-e2e-composite-action
timeout-minutes: 60
working-directory: plugins/woocommerce
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: ${{ env.E2E_ARTIFACT }}
env:
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }}
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-report
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-results
BASE_URL: ${{ secrets.SMOKE_TEST_URL }}
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
DEFAULT_TIMEOUT_OVERRIDE: 120000
E2E_MAX_FAILURES: 25
RESET_SITE: true
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js
- name: Generate Playwright E2E Test report.
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive E2E test report
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: ${{ env.E2E_ARTIFACT }}
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore
retention-days: 5
k6-tests:
name: k6 tests on nightly build
@ -130,6 +116,8 @@ jobs:
contents: read
needs: [api-tests]
if: success() || failure()
outputs:
test-result: ${{ steps.run-k6-tests.conclusion }}
steps:
- uses: actions/checkout@v3
@ -139,29 +127,31 @@ jobs:
install-filters: woocommerce
build: false
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Update performance test site with E2E test
working-directory: plugins/woocommerce
id: update-perf-site
continue-on-error: true
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: k6-daily-update-site--run-${{ github.run_number }}
tests: update-woocommerce.spec.js
env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-report
BASE_URL: ${{ secrets.SMOKE_TEST_PERF_URL }}/
ADMIN_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }}
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }}
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }}
UPDATE_WC: nightly
DEFAULT_TIMEOUT_OVERRIDE: 120000
run: |
pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js update-woocommerce.spec.js
continue-on-error: true
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
UPDATE_WC: nightly
- name: Install k6
run: |
curl https://github.com/grafana/k6/releases/download/v0.33.0/k6-v0.33.0-linux-amd64.tar.gz -L | tar xvz --strip-components 1
- name: Run k6 smoke tests
id: run-k6-tests
env:
URL: ${{ secrets.SMOKE_TEST_PERF_URL }}
HOST: ${{ secrets.SMOKE_TEST_PERF_HOST }}
@ -180,7 +170,6 @@ jobs:
contents: read
needs: [api-tests]
env:
USE_WP_ENV: 1
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-report
strategy:
@ -189,17 +178,23 @@ jobs:
include:
- plugin: 'WooCommerce Payments'
repo: 'automattic/woocommerce-payments'
slug: woocommerce-payments
- plugin: 'WooCommerce PayPal Payments'
repo: 'woocommerce/woocommerce-paypal-payments'
slug: woocommerce-paypal-payments
- plugin: 'WooCommerce Shipping & Tax'
repo: 'automattic/woocommerce-services'
slug: woocommerce-services
- plugin: 'WooCommerce Subscriptions'
repo: WC_SUBSCRIPTIONS_REPO
private: true
slug: woocommerce-subscriptions
- plugin: 'Gutenberg'
repo: 'WordPress/gutenberg'
slug: gutenberg
- plugin: 'Gutenberg - Nightly'
repo: 'bph/gutenberg'
slug: gutenberg-nightly
steps:
- uses: actions/checkout@v3
@ -208,43 +203,52 @@ jobs:
with:
build-filters: woocommerce
- name: Launch wp-env e2e environment
working-directory: plugins/woocommerce
run: pnpm env:test --filter=woocommerce
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Setup local test environment
uses: ./.github/actions/tests/setup-local-test-environment
with:
test-type: e2e
- name: Run 'Upload plugin' test
working-directory: plugins/woocommerce
id: run-upload-plugin-test
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
tests: upload-plugin.spec.js
env:
PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }}
PLUGIN_NAME: ${{ matrix.plugin }}
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
run: pnpm test:e2e-pw upload-plugin.spec.js
- name: Run the rest of E2E tests
working-directory: plugins/woocommerce
id: run-e2e-composite-action
timeout-minutes: 60
uses: ./.github/actions/tests/run-e2e-tests
with:
playwright-config: ignore-plugin-tests.playwright.config.js
report-name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
env:
E2E_MAX_FAILURES: 15
run: pnpm test:e2e-pw
- name: Generate E2E Test report.
- name: Create context block and save as JSON file
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
id: create-block-json
uses: actions/github-script@v6
with:
script: |
const script = require( './.github/actions/tests/slack-summary-daily/scripts/create-blocks-plugin-tests.js' )
script( { core } );
env:
UPLOAD_RESULT: ${{ steps.run-upload-plugin-test.outputs.result }}
E2E_RESULT: ${{ steps.run-e2e-composite-action.outputs.result }}
PLUGIN_NAME: ${{ matrix.plugin }}
PLUGIN_SLUG: ${{ matrix.slug }}
- name: Archive E2E test report
- name: Upload JSON file as artifact
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore
retention-days: 5
name: ${{ env.PLUGIN_SLACK_BLOCKS_ARTIFACT }}
path: ${{ steps.create-block-json.outputs.path }}
trunk-results:
name: Publish report on smoke tests on nightly build
@ -306,7 +310,8 @@ jobs:
plugins-results:
name: Publish report on Smoke tests on trunk with plugins
if: |
( success() || failure() ) &&
( success() || failure() ) &&
( needs.test-plugins.result != 'skipped' ) &&
! github.event.pull_request.head.repo.fork
runs-on: ubuntu-20.04
needs: [e2e-tests, test-plugins, k6-tests]
@ -345,3 +350,51 @@ jobs:
-f slug="${{ matrix.slug }}" \
-f s3_root=public \
--repo woocommerce/woocommerce-test-reports
post-slack-summary:
name: Post Slack summary
runs-on: ubuntu-20.04
permissions:
contents: read
if: |
success() || (
failure() && contains( needs.*.result, 'failure' )
)
needs:
- api-tests
- e2e-tests
- k6-tests
- test-plugins
steps:
- uses: actions/checkout@v3
- name: Download Slack blocks from plugin tests
if: needs.test-plugins.result != 'skipped'
id: download-plugin-blocks
uses: actions/download-artifact@v3
with:
name: ${{ env.PLUGIN_SLACK_BLOCKS_ARTIFACT }}
path: /tmp/plugin-blocks
- name: Construct Slack payload
id: construct-slack-payload
uses: actions/github-script@v6
with:
script: |
const script = require('./.github/actions/tests/slack-summary-daily/scripts/construct-slack-payload.js');
await script( { context, core, github } );
env:
API_RESULT: ${{ needs.api-tests.outputs.test-result }}
E2E_RESULT: ${{ needs.e2e-tests.outputs.test-result || needs.e2e-tests.result }}
k6_RESULT: ${{ needs.k6-tests.outputs.test-result || needs.k6-tests.result }}
PLUGINS_BLOCKS_PATH: ${{ steps.download-plugin-blocks.outputs.download-path }}
PLUGIN_TESTS_RESULT: ${{ needs.test-plugins.result }}
- name: Send Slack message
id: send-slack-message
uses: slackapi/slack-github-action@v1.23.0
with:
channel-id: ${{ secrets.DAILY_TEST_SLACK_CHANNEL }}
payload: ${{ steps.construct-slack-payload.outputs.payload }}
env:
SLACK_BOT_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }}

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Decode html characters in SelectTree

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { createElement, useEffect, useState } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { BaseControl, TextControl } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
@ -16,6 +17,7 @@ import { SelectedItems } from '../experimental-select-control/selected-items';
import { ComboBox } from '../experimental-select-control/combo-box';
import { SuffixIcon } from '../experimental-select-control/suffix-icon';
import { SelectTreeMenu } from './select-tree-menu';
import { escapeHTML } from '../utils';
interface SelectTreeProps extends TreeControlProps {
id: string;
@ -185,11 +187,11 @@ export const SelectTree = function SelectTree( {
) : (
<TextControl
{ ...inputProps }
value={ props.createValue || '' }
value={ decodeEntities( props.createValue || '' ) }
onChange={ ( value ) => {
if ( onInputChange ) onInputChange( value );
const item = items.find(
( i ) => i.label === value
( i ) => i.label === escapeHTML( value )
);
if ( props.onSelect && item ) {
props.onSelect( item );

View File

@ -84,7 +84,7 @@ export { DynamicForm } from './dynamic-form';
export { default as TourKit } from './tour-kit';
export * as TourKitTypes from './tour-kit/types';
export { CollapsibleContent } from './collapsible-content';
export { createOrderedChildren, sortFillsByOrder } from './utils';
export { createOrderedChildren, sortFillsByOrder, escapeHTML } from './utils';
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
export { WooProductTabItem as __experimentalWooProductTabItem } from './woo-product-tab-item';

View File

@ -85,3 +85,10 @@ export const sortFillsByOrder: Slot.Props[ 'children' ] = ( fills ) => {
return <Fragment>{ sortedFills }</Fragment>;
};
export const escapeHTML = ( string: string ) => {
return string
.replace( /&/g, '&amp;' )
.replace( />/g, '&gt;' )
.replace( /</g, '&lt;' );
};

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add additional type to user preferences config.

View File

@ -29,10 +29,12 @@ export type UserPreferences = {
variable_product_block_tour_shown?: string;
variations_report_columns?: string;
product_block_variable_options_notice_dismissed?: string;
variable_items_without_price_notice_dismissed?: Record< number, string >;
};
export type WoocommerceMeta = UserPreferences & {
task_list_tracked_started_tasks?: string;
variable_items_without_price_notice_dismissed?: string;
};
export type WCUser<

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add notice to variations when variations do not have prices set.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix error displaying block after removing variation #40255

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add redirection to product edit page if variations are added before it being saved.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Escape special characters when searching for taxonomies

View File

@ -3,6 +3,7 @@
*/
import { useState } from '@wordpress/element';
import { resolveSelect } from '@wordpress/data';
import { escapeHTML } from '@woocommerce/components';
/**
* Internal dependencies
*/
@ -61,7 +62,7 @@ const useTaxonomySearch = (
Taxonomy[]
>( 'taxonomy', taxonomyName, {
per_page: PAGINATION_SIZE,
search,
search: escapeHTML( search ),
} );
if ( options?.fetchParents ) {
taxonomies = await getTaxonomiesMissingParents(

View File

@ -1,16 +1,32 @@
/**
* External dependencies
*/
import { sprintf, __ } from '@wordpress/i18n';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
Product,
ProductVariation,
useUserPreferences,
} from '@woocommerce/data';
import { useBlockProps } from '@wordpress/block-editor';
import { recordEvent } from '@woocommerce/tracks';
import { BlockEditProps } from '@wordpress/blocks';
import { createElement } from '@wordpress/element';
import { createElement, useMemo, useRef } from '@wordpress/element';
import { resolveSelect, useDispatch, useSelect } from '@wordpress/data';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityId, useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { VariationsTable } from '../../components/variations-table';
import { useValidation } from '../../contexts/validation-context';
import { VariationOptionsBlockAttributes } from './types';
import { VariableProductTour } from './variable-product-tour';
import { TRACKS_SOURCE } from '../../constants';
import { handlePrompt } from '../../utils/handle-prompt';
export function Edit( {
context,
@ -19,11 +35,168 @@ export function Edit( {
isInSelectedTab?: boolean;
};
} ) {
const noticeDimissed = useRef( false );
const { invalidateResolution } = useDispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const productId = useEntityId( 'postType', 'product' );
const blockProps = useBlockProps();
const [ productStatus ] = useEntityProp< string >(
'postType',
'product',
'status'
);
const totalCountWithoutPriceRequestParams = useMemo(
() => ( {
product_id: productId,
order: 'asc',
orderby: 'menu_order',
has_price: false,
} ),
[ productId ]
);
const { totalCountWithoutPrice } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
return {
totalCountWithoutPrice:
getProductVariationsTotalCount< number >(
totalCountWithoutPriceRequestParams
),
};
},
[ productId ]
);
const {
updateUserPreferences,
variable_items_without_price_notice_dismissed:
itemsWithoutPriceNoticeDismissed,
} = useUserPreferences();
const { ref: variationTableRef } = useValidation< Product >(
`variations`,
async function regularPriceValidator( defaultValue, newData ) {
/**
* We cause a validation error if there is:
* - more then one variation without a price.
* - the notice hasn't been dismissed.
* - The product hasn't already been published.
* - We are publishing the product.
*/
if (
totalCountWithoutPrice > 0 &&
! noticeDimissed.current &&
productStatus !== 'publish' &&
// New status.
newData?.status === 'publish'
) {
if ( itemsWithoutPriceNoticeDismissed !== 'yes' ) {
updateUserPreferences( {
variable_items_without_price_notice_dismissed: {
...( itemsWithoutPriceNoticeDismissed || {} ),
[ productId ]: 'no',
},
} );
}
return __(
'Set variation prices before adding this product.',
'woocommerce'
);
}
},
[ totalCountWithoutPrice ]
);
function onSetPrices(
handleUpdateAll: ( update: Partial< ProductVariation >[] ) => void
) {
recordEvent( 'product_variations_set_prices_select', {
source: TRACKS_SOURCE,
} );
const productVariationsListPromise = resolveSelect(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
).getProductVariations< Pick< ProductVariation, 'id' >[] >( {
product_id: productId,
has_price: false,
_fields: [ 'id' ],
} );
handlePrompt( {
onOk( value ) {
recordEvent( 'product_variations_set_prices_update', {
source: TRACKS_SOURCE,
} );
productVariationsListPromise.then( ( variations ) => {
handleUpdateAll(
variations.map( ( { id } ) => ( {
id,
regular_price: value,
} ) )
);
} );
},
} );
}
const hasNotDismissedNotice =
! itemsWithoutPriceNoticeDismissed ||
itemsWithoutPriceNoticeDismissed[ productId ] !== 'yes';
const noticeText =
totalCountWithoutPrice > 0 && hasNotDismissedNotice
? sprintf(
/** Translators: Number of variations without price */
__(
'%d variations do not have prices. Variations that do not have prices will not be visible to customers.',
'woocommerce'
),
totalCountWithoutPrice
)
: '';
return (
<div { ...blockProps }>
<VariationsTable />
<VariationsTable
ref={ variationTableRef as React.Ref< HTMLDivElement > }
noticeText={ noticeText }
onNoticeDismiss={ () => {
noticeDimissed.current = true;
updateUserPreferences( {
variable_items_without_price_notice_dismissed: {
...( itemsWithoutPriceNoticeDismissed || {} ),
[ productId ]: 'yes',
},
} );
} }
noticeActions={ [
{
label: __( 'Set prices', 'woocommerce' ),
onClick: onSetPrices,
className: 'is-destructive',
},
] }
onVariationTableChange={ ( type, update ) => {
if (
type === 'delete' ||
( type === 'update' &&
update &&
update.find(
( variation ) =>
variation.regular_price ||
variation.sale_price
) )
) {
invalidateResolution(
'getProductVariationsTotalCount',
[ totalCountWithoutPriceRequestParams ]
);
}
} }
/>
{ context?.isInSelectedTab && <VariableProductTour /> }
</div>
);

View File

@ -403,7 +403,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
}
value={
attribute ===
null
null ||
attribute ===
undefined
? []
: attribute.terms
}
@ -506,7 +508,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
label={ addAccessibleLabel }
disabled={
values.attributes.length === 1 &&
values.attributes[ 0 ] === null
( values.attributes[ 0 ] === null ||
values.attributes[ 0 ] ===
undefined )
}
onClick={ () =>
onAddingAttributes( values )

View File

@ -89,7 +89,9 @@ export const AttributeListItem: React.FC< AttributeListItemProps > = ( {
position="top center"
text={ NOT_VISIBLE_TEXT }
>
<HiddenWithHelpIcon />
<div>
<HiddenWithHelpIcon />
</div>
</Tooltip>
) }
{ typeof onEditClick === 'function' && (

View File

@ -26,7 +26,7 @@ export function usePublish( {
onPublishSuccess?( product: Product ): void;
onPublishError?( error: WPError ): void;
} ): Button.ButtonProps {
const { isValidating, validate } = useValidations();
const { isValidating, validate } = useValidations< Product >();
const [ productId ] = useEntityProp< number >(
'postType',
@ -61,7 +61,9 @@ export function usePublish( {
}
try {
await validate();
await validate( {
status: 'publish',
} );
// The publish button click not only change the status of the product
// but also save all the pending changes. So even if the status is
@ -93,6 +95,12 @@ export function usePublish( {
? 'product_publish_error'
: 'product_create_error',
} as WPError;
if ( ( error as Record< string, string > ).variations ) {
wpError.code = 'variable_product_no_variation_prices';
wpError.message = (
error as Record< string, string >
).variations;
}
}
onPublishError( wpError );
}

View File

@ -56,7 +56,7 @@ export function useSaveDraft( {
[ productId ]
);
const { isValidating, validate } = useValidations();
const { isValidating, validate } = useValidations< Product >();
const ariaDisabled =
disabled ||
@ -76,7 +76,7 @@ export function useSaveDraft( {
}
try {
await validate();
await validate( { status: 'draft' } );
await editEntityRecord( 'postType', 'product', productId, {
status: 'draft',

View File

@ -15,6 +15,21 @@ $table-row-height: calc($grid-unit * 9);
border-bottom: 1px solid $gray-200;
}
&__notice {
border-left: 0px;
margin: 0 0 $gap-large 0;
&.is-error {
background-color: #fcf0f1;
}
.components-notice__actions {
margin-top: $gap-small;
.components-button:first-child {
margin-left: 0px;
}
}
}
&__table {
height: $table-row-height * 5;
overflow: auto;

View File

@ -5,6 +5,7 @@ import { __, sprintf } from '@wordpress/i18n';
import {
Button,
CheckboxControl,
Notice,
Spinner,
Tooltip,
} from '@wordpress/components';
@ -27,6 +28,8 @@ import {
createElement,
useRef,
useMemo,
Fragment,
forwardRef,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import classnames from 'classnames';
@ -53,7 +56,38 @@ import HiddenWithHelpIcon from '../../icons/hidden-with-help-icon';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
export function VariationsTable() {
type VariationsTableProps = {
noticeText?: string;
noticeStatus?: 'error' | 'warning' | 'success' | 'info';
onNoticeDismiss?: () => void;
noticeActions?: {
label: string;
onClick: (
handleUpdateAll: ( update: Partial< ProductVariation >[] ) => void,
handleDeleteAll: ( update: Partial< ProductVariation >[] ) => void
) => void;
className?: string;
variant?: string;
}[];
onVariationTableChange?: (
type: 'update' | 'delete',
updates?: Partial< ProductVariation >[]
) => void;
};
export const VariationsTable = forwardRef<
HTMLDivElement,
VariationsTableProps
>( function Table(
{
noticeText,
noticeActions = [],
noticeStatus = 'error',
onNoticeDismiss = () => {},
onVariationTableChange = () => {},
}: VariationsTableProps,
ref
) {
const [ currentPage, setCurrentPage ] = useState( 1 );
const lastVariations = useRef< ProductVariation[] | null >( null );
const [ perPage, setPerPage ] = useState(
@ -90,22 +124,9 @@ export function VariationsTable() {
} ),
[ productId ]
);
const context = useContext( CurrencyContext );
const { formatAmount } = context;
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
return {
totalCount: getProductVariationsTotalCount< number >(
totalCountRequestParams
),
};
},
[ productId ]
);
const { isLoading, latestVariations, isGeneratingVariations } = useSelect(
( select ) => {
const {
@ -127,6 +148,21 @@ export function VariationsTable() {
[ currentPage, perPage, productId ]
);
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
return {
totalCount: getProductVariationsTotalCount< number >(
totalCountRequestParams
),
};
},
[ productId ]
);
const paginationProps = usePagination( {
totalCount,
defaultPerPage: DEFAULT_VARIATION_PER_PAGE_OPTION,
@ -179,13 +215,17 @@ export function VariationsTable() {
recordEvent( 'product_variations_delete', {
source: TRACKS_SOURCE,
} );
invalidateResolution( 'getProductVariations', [
requestParams,
] );
} )
.finally( () =>
.finally( () => {
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
} ) );
onVariationTableChange( 'delete' );
} );
}
function handleVariationChange(
@ -212,12 +252,13 @@ export function VariationsTable() {
__( 'Failed to save variation.', 'woocommerce' )
);
} )
.finally( () =>
.finally( () => {
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
} ) );
onVariationTableChange( 'update', [ variation ] );
} );
}
function handleUpdateAll( update: Partial< ProductVariation >[] ) {
@ -238,6 +279,7 @@ export function VariationsTable() {
response.update.length
)
);
onVariationTableChange( 'update', update );
} )
.catch( () => {
createErrorNotice(
@ -266,6 +308,7 @@ export function VariationsTable() {
response.delete.length
)
);
onVariationTableChange( 'delete' );
} )
.catch( () => {
createErrorNotice(
@ -275,7 +318,7 @@ export function VariationsTable() {
}
return (
<div className="woocommerce-product-variations">
<div className="woocommerce-product-variations" ref={ ref }>
{ ( isLoading || isGeneratingVariations ) && (
<div className="woocommerce-product-variations__loading">
<Spinner />
@ -286,6 +329,21 @@ export function VariationsTable() {
) }
</div>
) }
{ noticeText && (
<Notice
status={ noticeStatus }
className="woocommerce-product-variations__notice"
onRemove={ onNoticeDismiss }
actions={ noticeActions.map( ( action ) => ( {
...action,
onClick: () => {
action?.onClick( handleUpdateAll, handleDeleteAll );
},
} ) ) }
>
{ noticeText }
</Notice>
) }
<div className="woocommerce-product-variations__header">
<div className="woocommerce-product-variations__selection">
<CheckboxControl
@ -401,18 +459,25 @@ export function VariationsTable() {
}
) }
>
<span
className={ classnames(
'woocommerce-product-variations__status-dot',
getProductStockStatusClass( variation )
) }
>
</span>
{ getProductStockStatus( variation ) }
{ variation.regular_price && (
<>
<span
className={ classnames(
'woocommerce-product-variations__status-dot',
getProductStockStatusClass(
variation
)
) }
>
</span>
{ getProductStockStatus( variation ) }
</>
) }
</div>
<div className="woocommerce-product-variations__actions">
{ variation.status === 'private' && (
{ ( variation.status === 'private' ||
! variation.regular_price ) && (
<Tooltip
// @ts-expect-error className is missing in TS, should remove this when it is included.
className="woocommerce-attribute-list-item__actions-tooltip"
@ -459,4 +524,4 @@ export function VariationsTable() {
) }
</div>
);
}
} );

View File

@ -1,6 +1,9 @@
export type ValidatorResponse = Promise< ValidationError >;
export type Validator< T > = ( initialValue?: T ) => ValidatorResponse;
export type Validator< T > = (
initialValue?: T,
newData?: Partial< T >
) => ValidatorResponse;
export type ValidationContextProps< T > = {
errors: ValidationErrors;
@ -9,7 +12,7 @@ export type ValidationContextProps< T > = {
validator: Validator< T >
): React.Ref< HTMLElement >;
validateField( name: string ): ValidatorResponse;
validateAll(): Promise< ValidationErrors >;
validateAll( newData?: Partial< T > ): Promise< ValidationErrors >;
};
export type ValidationProviderProps< T > = {
@ -19,9 +22,9 @@ export type ValidationProviderProps< T > = {
export type ValidationError = string | undefined;
export type ValidationErrors = Record< string, ValidationError >;
export type ValidatorRegistration = {
export type ValidatorRegistration< T > = {
name: string;
ref: React.Ref< HTMLElement >;
error?: ValidationError;
validate(): ValidatorResponse;
validate( newData?: Partial< T > ): ValidatorResponse;
};

View File

@ -13,17 +13,17 @@ function isInvalid( errors: ValidationErrors ) {
return Object.values( errors ).some( Boolean );
}
export function useValidations() {
export function useValidations< T = unknown >() {
const context = useContext( ValidationContext );
const [ isValidating, setIsValidating ] = useState( false );
return {
isValidating,
async validate() {
async validate( newData?: Partial< T > ) {
setIsValidating( true );
return new Promise< void >( ( resolve, reject ) => {
context
.validateAll()
.validateAll( newData )
.then( ( errors ) => {
if ( isInvalid( errors ) ) {
reject( errors );

View File

@ -38,11 +38,14 @@ export function ValidationProvider< T >( {
};
}
async function validateField( validatorId: string ): ValidatorResponse {
async function validateField(
validatorId: string,
newData?: Partial< T >
): ValidatorResponse {
const validators = validatorsRef.current;
if ( validatorId in validators ) {
const validator = validators[ validatorId ];
const result = validator( initialValue );
const result = validator( initialValue, newData );
return result.then( ( error ) => {
setErrors( ( currentErrors ) => ( {
@ -56,12 +59,17 @@ export function ValidationProvider< T >( {
return Promise.resolve( undefined );
}
async function validateAll(): Promise< ValidationErrors > {
async function validateAll(
newData: Partial< T >
): Promise< ValidationErrors > {
const newErrors: ValidationErrors = {};
const validators = validatorsRef.current;
for ( const validatorId in validators ) {
newErrors[ validatorId ] = await validateField( validatorId );
newErrors[ validatorId ] = await validateField(
validatorId,
newData
);
}
setErrors( newErrors );

View File

@ -33,5 +33,8 @@ export function useConfirmUnsavedProductChanges() {
[ productId ]
);
useConfirmUnsavedChanges( hasEdits || isSaving, preventLeavingProductForm );
useConfirmUnsavedChanges(
hasEdits || isSaving,
preventLeavingProductForm( productId )
);
}

View File

@ -1,11 +1,13 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { resolveSelect, useDispatch } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { useCallback, useState } from '@wordpress/element';
import { getNewPath, getPath, navigateTo } from '@woocommerce/navigation';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
Product,
ProductDefaultAttribute,
} from '@woocommerce/data';
@ -34,6 +36,13 @@ export function useProductVariationsHelper() {
) => {
setIsGenerating( true );
const lastStatus = (
( await resolveSelect( 'core' ).getEditedEntityRecord(
'postType',
'product',
productId
) ) as Product
).status;
const hasVariableAttribute = attributes.some(
( attr ) => attr.variation
);
@ -64,6 +73,13 @@ export function useProductVariationsHelper() {
} )
.finally( () => {
setIsGenerating( false );
if (
lastStatus === 'auto-draft' &&
getPath().endsWith( 'add-product' )
) {
const url = getNewPath( {}, `/product/${ productId }` );
navigateTo( { url } );
}
} );
},
[]

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
export type WPErrorCode =
| 'variable_product_no_variation_prices'
| 'product_invalid_sku'
| 'product_create_error'
| 'product_publish_error'
@ -19,6 +20,8 @@ export type WPError = {
export function getProductErrorMessage( error: WPError ) {
switch ( error.code ) {
case 'variable_product_no_variation_prices':
return error.message;
case 'product_invalid_sku':
return __( 'Invalid or duplicated SKU.', 'woocommerce' );
case 'product_create_error':

View File

@ -6,10 +6,19 @@ import { Location } from 'react-router-dom';
/**
* Allow switching between tabs without prompting for unsaved changes.
*/
export const preventLeavingProductForm = ( toUrl: URL, fromUrl: Location ) => {
const toParams = new URLSearchParams( toUrl.search );
const fromParams = new URLSearchParams( fromUrl.search );
toParams.delete( 'tab' );
fromParams.delete( 'tab' );
return toParams.toString() !== fromParams.toString();
};
export const preventLeavingProductForm =
( productId?: number ) => ( toUrl: URL, fromUrl: Location ) => {
const toParams = new URLSearchParams( toUrl.search );
const fromParams = new URLSearchParams( fromUrl.search );
toParams.delete( 'tab' );
fromParams.delete( 'tab' );
// Prevent dialog from happening if moving from add new to edit page of same product.
if (
productId !== undefined &&
fromParams.get( 'path' ) === '/add-product' &&
toParams.get( 'path' ) === '/product/' + productId
) {
return false;
}
return toParams.toString() !== fromParams.toString();
};

View File

@ -16,7 +16,7 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=general',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
@ -27,7 +27,7 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=general',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
@ -38,7 +38,35 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=shipping',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
it( 'should allow leaving when moving from the add-product to the edit page with same product id', () => {
const toUrl = new URL(
'http://mysite.com/admin.php?page=wc-admin&path=/product/123&tab=general'
);
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/add-product',
} as Location;
const shouldPrevent = preventLeavingProductForm( 123 )(
toUrl,
fromUrl
);
expect( shouldPrevent ).toBe( false );
} );
it( 'should not allow leaving when moving from the add-product to the edit page with different product id', () => {
const toUrl = new URL(
'http://mysite.com/admin.php?page=wc-admin&path=/product/123&tab=general'
);
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/add-product',
} as Location;
const shouldPrevent = preventLeavingProductForm( 333 )(
toUrl,
fromUrl
);
expect( shouldPrevent ).toBe( true );
} );
@ -49,7 +77,7 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=shipping&other_param=b',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
} );

View File

@ -1492,11 +1492,15 @@ export const CoreProfilerController = ( {
hasJetpackSelected: ( context ) => {
return (
context.pluginsSelected.find(
( plugin ) => plugin === 'jetpack'
( plugin ) =>
plugin === 'jetpack' ||
plugin === 'jetpack-boost'
) !== undefined ||
context.pluginsAvailable.find(
( plugin: Extension ) =>
plugin.key === 'jetpack' && plugin.is_activated
( plugin.key === 'jetpack' ||
plugin.key === 'jetpack-boost' ) &&
plugin.is_activated
) !== undefined
);
},

View File

@ -6,7 +6,7 @@
* External dependencies
*/
import { useResizeObserver, pure, useRefEffect } from '@wordpress/compose';
import { useMemo, useContext } from '@wordpress/element';
import { useContext } from '@wordpress/element';
import { Disabled } from '@wordpress/components';
import {
__unstableEditorStyles as EditorStyles,
@ -52,7 +52,6 @@ export type ScaledBlockPreviewProps = {
function ScaledBlockPreview( {
viewportWidth,
containerWidth,
minHeight,
settings,
additionalStyles,
onClickNavigationItem,
@ -70,28 +69,14 @@ function ScaledBlockPreview( {
viewportWidth = containerWidth;
}
// @ts-ignore No types for this exist yet.
const [ contentResizeListener, { height: contentHeight } ] =
useResizeObserver();
// Avoid scrollbars for pattern previews.
const editorStyles = useMemo( () => {
return [
{
css: 'body{height:auto;overflow:hidden;border:none;padding:0;}',
__unstableType: 'presets',
},
...settings.styles,
];
}, [ settings.styles ] );
// Initialize on render instead of module top level, to avoid circular dependency issues.
MemoizedBlockList = MemoizedBlockList || pure( BlockList );
const scale = containerWidth / viewportWidth;
return (
<DisabledProvider value={ true }>
<Iframe
aria-hidden
tabIndex={ -1 }
contentRef={ useRefEffect( ( bodyElement: HTMLBodyElement ) => {
const {
ownerDocument: { documentElement },
@ -225,18 +210,8 @@ function ScaledBlockPreview( {
} );
};
}, [] ) }
aria-hidden
tabIndex={ -1 }
style={ {
width: viewportWidth,
height: contentHeight,
minHeight:
scale !== 0 && scale < 1 && minHeight
? minHeight / scale
: minHeight,
} }
>
<EditorStyles styles={ editorStyles } />
<EditorStyles styles={ settings.styles } />
<style>
{ `
.block-editor-block-list__block::before,
@ -267,7 +242,6 @@ function ScaledBlockPreview( {
${ additionalStyles }
` }
</style>
{ contentResizeListener }
<MemoizedBlockList renderAppender={ false } />
{ /* Only load font families when there are two font families (font-paring selection). Otherwise, it is not needed. */ }
{ externalFontFamilies.length === 2 && (

View File

@ -3,7 +3,6 @@
/**
* External dependencies
*/
import classNames from 'classnames';
// @ts-ignore No types for this exist yet.
import { useEntityRecords } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
@ -46,7 +45,7 @@ export const BlockEditor = ( {} ) => {
: 'topDown';
const previewOpacity = useScrollOpacity(
'.interface-navigable-region.interface-interface-skeleton__content',
'.woocommerce-customize-store__block-editor iframe',
scrollDirection
);
@ -92,59 +91,6 @@ export const BlockEditor = ( {} ) => {
[ history, urlParams, pages ]
);
if ( urlParams.path === '/customize-store/assembler-hub/homepage' ) {
// When assembling the homepage preview, we need to render the blocks in a different way than the rest of the pages.
// Because we want to show a action bar when hovering over a pattern. This is not needed for the rest of the pages and will cause an issue with logo editing.
return (
<div className="woocommerce-customize-store__block-editor">
{ blocks.map( ( block, index ) => {
// Add padding to the top and bottom of the block preview.
let additionalStyles = '';
let hasActionBar = false;
switch ( true ) {
case index === 0:
// header
additionalStyles = `
.editor-styles-wrapper{ padding-top: var(--wp--style--root--padding-top) };'
`;
break;
case index === blocks.length - 1:
// footer
additionalStyles = `
.editor-styles-wrapper{ padding-bottom: var(--wp--style--root--padding-bottom) };
`;
break;
default:
hasActionBar = true;
}
return (
<div
key={ block.clientId }
className={ classNames(
'woocommerce-block-preview-container',
{
'has-action-menu': hasActionBar,
}
) }
>
<BlockPreview
blocks={ block }
settings={ settings }
additionalStyles={ additionalStyles }
onClickNavigationItem={ onClickNavigationItem }
// Use sub registry because we have multiple previews
useSubRegistry={ true }
previewOpacity={ previewOpacity }
/>
</div>
);
} ) }
</div>
);
}
return (
<div className="woocommerce-customize-store__block-editor">
<div className={ 'woocommerce-block-preview-container' }>

View File

@ -24,14 +24,14 @@ export const useEditorScroll = ( {
}
const previewContainer =
document.querySelector< HTMLDivElement >( editorSelector );
document.querySelector< HTMLIFrameElement >( editorSelector );
if ( previewContainer ) {
previewContainer?.scrollTo(
previewContainer.contentWindow?.scrollTo(
0,
scrollDirection === 'bottom'
? previewContainer?.scrollHeight
? previewContainer.contentDocument?.body.scrollHeight || 0
: 0
);
}
}, [ isEditorLoading, scrollDirection ] );
}, [ isEditorLoading, editorSelector, scrollDirection ] );
};

View File

@ -0,0 +1,134 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import { parse } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { usePatterns, Pattern, PatternWithBlocks } from './use-patterns';
// TODO: It might be better to create an API endpoint to get the templates.
const LARGE_BUSINESS_TEMPLATES = {
template1: [
'a8c/cover-image-with-left-aligned-call-to-action',
'woocommerce-blocks/featured-products-5-item-grid',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/featured-category-triple',
'a8c/3-column-testimonials',
'a8c/quotes-2',
'woocommerce-blocks/social-follow-us-in-social-media',
],
template2: [
'woocommerce-blocks/hero-product-split',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/featured-category-triple',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'a8c/three-columns-with-images-and-text',
'woocommerce-blocks/testimonials-3-columns',
'a8c/subscription',
],
template3: [
'a8c/call-to-action-7',
'a8c/3-column-testimonials',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/featured-category-cover-image',
'woocommerce-blocks/featured-products-5-item-grid',
'woocommerce-blocks/featured-products-5-item-grid',
'woocommerce-blocks/social-follow-us-in-social-media',
],
};
const SMALL_MEDIUM_BUSINESS_TEMPLATES = {
template1: [
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/testimonials-single',
'woocommerce-blocks/hero-product-3-split',
'a8c/contact-8',
],
template2: [
'a8c/about-me-4',
'a8c/product-feature-with-buy-button',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'a8c/subscription',
'woocommerce-blocks/testimonials-3-columns',
'a8c/contact-with-map-on-the-left',
],
template3: [
'a8c/heading-and-video',
'a8c/3-column-testimonials',
'woocommerce-blocks/product-hero',
'a8c/quotes-2',
'a8c/product-feature-with-buy-button',
'a8c/simple-two-column-layout',
'woocommerce-blocks/social-follow-us-in-social-media',
],
};
const getTemplatePatterns = (
template: string[],
patternsByName: Record< string, Pattern >
) =>
template
.map( ( patternName: string ) => {
const pattern = patternsByName[ patternName ];
if ( pattern && pattern.content ) {
return {
...pattern,
// @ts-ignore - Passing options is valid, but not in the type.
blocks: parse( pattern.content, {
__unstableSkipMigrationLogs: true,
} ),
};
}
return null;
} )
.filter( ( pattern ) => pattern !== null ) as PatternWithBlocks[];
export const useHomeTemplates = () => {
// TODO: Get businessType from option
const businessType = 'SMB' as string;
const { blockPatterns, isLoading } = usePatterns();
const patternsByName = useMemo( () => {
return blockPatterns.reduce(
( acc: Record< string, Pattern >, pattern: Pattern ) => {
acc[ pattern.name ] = pattern;
return acc;
},
{}
);
}, [ blockPatterns ] );
const homeTemplates = useMemo( () => {
if ( isLoading ) return {};
const recommendedTemplates =
businessType === 'SMB'
? SMALL_MEDIUM_BUSINESS_TEMPLATES
: LARGE_BUSINESS_TEMPLATES;
return Object.entries( recommendedTemplates ).reduce(
(
acc: Record< string, PatternWithBlocks[] >,
[ templateName, template ]
) => {
if ( templateName in recommendedTemplates ) {
acc[ templateName ] = getTemplatePatterns(
template,
patternsByName
);
}
return acc;
},
{}
);
}, [ isLoading, patternsByName ] );
return {
homeTemplates,
isLoading,
};
};

View File

@ -9,7 +9,7 @@ import { store as coreStore } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';
import { BlockInstance, parse } from '@wordpress/blocks';
type Pattern = {
export type Pattern = {
blockTypes: string[];
categories: string[];
content: string;
@ -18,15 +18,17 @@ type Pattern = {
title: string;
};
type PatternWithBlocks = Pattern & {
export type PatternWithBlocks = Pattern & {
blocks: BlockInstance[];
};
export const usePatternsByCategory = ( category: string ) => {
export const usePatterns = () => {
const { blockPatterns, isLoading } = useSelect(
( select ) => ( {
// @ts-ignore - This is valid.
blockPatterns: select( coreStore ).getBlockPatterns(),
blockPatterns: select(
coreStore
// @ts-ignore - This is valid.
).getBlockPatterns() as Pattern[],
isLoading:
// @ts-ignore - This is valid.
! select( coreStore ).hasFinishedResolution(
@ -36,6 +38,15 @@ export const usePatternsByCategory = ( category: string ) => {
[]
);
return {
blockPatterns,
isLoading,
};
};
export const usePatternsByCategory = ( category: string ) => {
const { blockPatterns, isLoading } = usePatterns();
const patternsByCategory: PatternWithBlocks[] = useMemo( () => {
return ( blockPatterns || [] )
.filter( ( pattern: Pattern ) =>

View File

@ -1,7 +1,11 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
// @ts-ignore No types for this exist yet.
import { useIsSiteEditorLoading } from '@wordpress/edit-site/build-module/components/layout/hooks';
type ScrollDirection = 'topDown' | 'bottomUp';
@ -11,35 +15,50 @@ export const useScrollOpacity = (
sensitivity = 0.2
) => {
const [ opacity, setOpacity ] = useState( 0.05 );
const isEditorLoading = useIsSiteEditorLoading();
useEffect( () => {
const targetElement = document.querySelector( selector );
let targetElement: Document | Element | null =
document.querySelector( selector );
const isIFrame = targetElement?.tagName === 'IFRAME';
if ( isIFrame ) {
targetElement = ( targetElement as HTMLIFrameElement )
.contentDocument;
}
const handleScroll = () => {
if ( targetElement ) {
const maxScrollHeight =
targetElement.scrollHeight - targetElement.clientHeight;
const currentScrollPosition = targetElement.scrollTop;
const maxEffectScroll = maxScrollHeight * sensitivity;
let calculatedOpacity;
if ( direction === 'bottomUp' ) {
calculatedOpacity =
1 - currentScrollPosition / maxEffectScroll;
} else {
calculatedOpacity = currentScrollPosition / maxEffectScroll;
}
calculatedOpacity = 0.1 + 0.9 * calculatedOpacity;
// Clamp opacity between 0.1 and 1
calculatedOpacity = Math.max(
0.1,
Math.min( calculatedOpacity, 1 )
);
setOpacity( calculatedOpacity );
if ( ! targetElement ) {
return;
}
const contentElement = isIFrame
? ( targetElement as Document ).documentElement
: ( targetElement as Element );
const maxScrollHeight =
contentElement.scrollHeight - contentElement.clientHeight;
const currentScrollPosition = contentElement.scrollTop;
const maxEffectScroll = maxScrollHeight * sensitivity;
let calculatedOpacity;
if ( direction === 'bottomUp' ) {
calculatedOpacity =
maxScrollHeight / maxEffectScroll -
currentScrollPosition / maxEffectScroll;
} else {
calculatedOpacity = currentScrollPosition / maxEffectScroll;
}
calculatedOpacity = 0.1 + 0.9 * calculatedOpacity;
// Clamp opacity between 0.1 and 1
calculatedOpacity = Math.max(
0.1,
Math.min( calculatedOpacity, 1 )
);
setOpacity( calculatedOpacity );
};
if ( targetElement ) {
@ -51,7 +70,7 @@ export const useScrollOpacity = (
targetElement.removeEventListener( 'scroll', handleScroll );
}
};
}, [ selector, direction, sensitivity ] );
}, [ selector, direction, sensitivity, isEditorLoading ] );
return opacity;
};

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { TourKit, TourKitTypes } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
export * from './use-onboarding-tour';
type OnboardingTourProps = {
@ -83,8 +84,15 @@ export const OnboardingTour = ( {
],
closeHandler: ( _steps, _currentStepIndex, source ) => {
if ( source === 'done-btn' ) {
// Click on "Take a tour" button
recordEvent(
'customize_your_store_assembler_hub_tour_start'
);
setShowWelcomeTour( false );
} else {
recordEvent(
'customize_your_store_assembler_hub_tour_skip'
);
onClose();
}
},
@ -196,7 +204,19 @@ export const OnboardingTour = ( {
},
},
],
closeHandler: onClose,
closeHandler: ( _steps, _currentStepIndex, source ) => {
if ( source === 'done-btn' ) {
recordEvent(
'customize_your_store_assembler_hub_tour_complete'
);
} else {
recordEvent(
'customize_your_store_assembler_hub_tour_close'
);
}
onClose();
},
} }
></TourKit>
);

View File

@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { OnboardingTour } from '../index';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
describe( 'OnboardingTour', () => {
let props: {
onClose: jest.Mock;
setShowWelcomeTour: jest.Mock;
showWelcomeTour: boolean;
};
beforeEach( () => {
props = {
onClose: jest.fn(),
setShowWelcomeTour: jest.fn(),
showWelcomeTour: true,
};
} );
it( 'should render welcome tour', () => {
render( <OnboardingTour { ...props } /> );
expect(
screen.getByText( /Welcome to your AI-generated store!/i )
).toBeInTheDocument();
} );
it( 'should render step 1', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );
expect(
screen.getByText( /View your changes in real time/i )
).toBeInTheDocument();
} );
it( 'should record an event when clicking on "Take a tour" button', () => {
render( <OnboardingTour { ...props } /> );
screen
.getByRole( 'button', {
name: /Take a tour/i,
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_start'
);
} );
it( 'should record an event when clicking on "Skip" button', () => {
render( <OnboardingTour { ...props } /> );
screen
.getByRole( 'button', {
name: /Skip/i,
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_skip'
);
} );
it( 'should record an event when clicking on "Skip" button', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );
screen
.getByRole( 'button', {
name: 'Close Tour',
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_close'
);
} );
it( 'should record an event when complete the tour', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );
screen
.getByRole( 'button', {
name: 'Next',
} )
.click();
screen
.getByRole( 'button', {
name: 'Done',
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_complete'
);
} );
} );

View File

@ -23,7 +23,7 @@ import { SidebarNavigationScreenTypography } from './sidebar-navigation-screen-t
import { SidebarNavigationScreenHeader } from './sidebar-navigation-screen-header';
import { SidebarNavigationScreenHomepage } from './sidebar-navigation-screen-homepage';
import { SidebarNavigationScreenFooter } from './sidebar-navigation-screen-footer';
import { SidebarNavigationScreenPages } from './sidebar-navigation-screen-pages';
// import { SidebarNavigationScreenPages } from './sidebar-navigation-screen-pages';
import { SidebarNavigationScreenLogo } from './sidebar-navigation-screen-logo';
import { SaveHub } from './save-hub';
@ -124,9 +124,10 @@ function SidebarScreens() {
<NavigatorScreen path="/customize-store/assembler-hub/footer">
<SidebarNavigationScreenFooter />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/assembler-hub/pages">
{ /* TODO: Implement pages sidebar in Phrase 2 */ }
{ /* <NavigatorScreen path="/customize-store/assembler-hub/pages">
<SidebarNavigationScreenPages />
</NavigatorScreen>
</NavigatorScreen> */ }
<NavigatorScreen path="/customize-store/assembler-hub/logo">
<SidebarNavigationScreenLogo />
</NavigatorScreen>

View File

@ -22,6 +22,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as noticesStore } from '@wordpress/notices';
// @ts-ignore No types for this exist yet.
import { useEntitiesSavedStatesIsDirty as useIsDirty } from '@wordpress/editor';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -142,6 +143,13 @@ export const SaveHub = () => {
}, [ urlParams.path ] );
const save = async () => {
const source = `${ urlParams.path.replace(
'/customize-store/assembler-hub/',
''
) }`;
recordEvent( 'customize_your_store_assembler_hub_save_click', {
source,
} );
removeNotice( saveNoticeId );
try {
@ -185,6 +193,10 @@ export const SaveHub = () => {
<Button
variant="primary"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_done_click'
);
setIsResolving( true );
sendEvent( 'FINISH_CUSTOMIZATION' );
} }

View File

@ -15,6 +15,7 @@ import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
import { PanelBody } from '@wordpress/components';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -70,14 +71,38 @@ export const SidebarNavigationScreenColorPalette = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'color-palette',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
StyleLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_style_link_click',
{
source: 'color-palette',
}
);
window.open(
`${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -12,6 +12,7 @@ import {
useMemo,
} from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { Spinner } from '@wordpress/components';
// @ts-expect-error Missing type in core-data.
import { __experimentalBlockPatternsList as BlockPatternList } from '@wordpress/block-editor';
@ -34,8 +35,7 @@ const SUPPORTED_FOOTER_PATTERNS = [
export const SidebarNavigationScreenFooter = () => {
useEditorScroll( {
editorSelector:
'.interface-navigable-region.interface-interface-skeleton__content',
editorSelector: '.woocommerce-customize-store__block-editor iframe',
scrollDirection: 'bottom',
} );
@ -86,8 +86,20 @@ export const SidebarNavigationScreenFooter = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'footer',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -12,6 +12,7 @@ import {
useMemo,
} from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { Spinner } from '@wordpress/components';
// @ts-ignore No types for this exist yet.
import { __experimentalBlockPatternsList as BlockPatternList } from '@wordpress/block-editor';
@ -35,8 +36,7 @@ const SUPPORTED_HEADER_PATTERNS = [
export const SidebarNavigationScreenHeader = () => {
useEditorScroll( {
editorSelector:
'.interface-navigable-region.interface-interface-skeleton__content',
editorSelector: '.woocommerce-customize-store__block-editor iframe',
scrollDirection: 'top',
} );
@ -85,8 +85,20 @@ export const SidebarNavigationScreenHeader = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'header',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -1,16 +1,61 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import {
createInterpolateElement,
useCallback,
useMemo,
} from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { Spinner } from '@wordpress/components';
// @ts-expect-error Missing type in core-data.
import { __experimentalBlockPatternsList as BlockPatternList } from '@wordpress/block-editor';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
import { useEditorBlocks } from '../hooks/use-editor-blocks';
import { useHomeTemplates } from '../hooks/use-home-templates';
import { BlockInstance } from '@wordpress/blocks';
export const SidebarNavigationScreenHomepage = () => {
const { isLoading, homeTemplates } = useHomeTemplates();
const [ blocks, , onChange ] = useEditorBlocks();
const onClickPattern = useCallback(
( _pattern, selectedBlocks ) => {
onChange(
[ blocks[ 0 ], ...selectedBlocks, blocks[ blocks.length - 1 ] ],
{ selection: {} }
);
},
[ blocks, onChange ]
);
const homePatterns = useMemo( () => {
return Object.entries( homeTemplates ).map(
( [ templateName, patterns ] ) => {
return {
name: templateName,
blocks: patterns.reduce(
( acc: BlockInstance[], pattern ) => [
...acc,
...pattern.blocks,
],
[]
),
};
}
);
}, [ homeTemplates ] );
return (
<SidebarNavigationScreen
title={ __( 'Change your homepage', 'woocommerce' ) }
@ -22,16 +67,45 @@ export const SidebarNavigationScreenHomepage = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'homepage',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
<div className="woocommerce-customize-store__sidebar-homepage-content">
<div className="edit-site-sidebar-navigation-screen-patterns__group-homepage">
{ isLoading ? (
<span className="components-placeholder__preview">
<Spinner />
</span>
) : (
<BlockPatternList
shownPatterns={ homePatterns }
blockPatterns={ homePatterns }
onClickPattern={ onClickPattern }
label={ 'Hompeage' }
orientation="vertical"
category={ 'homepage' }
isDraggable={ false }
showTitlesAsTooltip={ false }
/>
) }
</div>
</div>
}
/>
);

View File

@ -20,11 +20,11 @@ import {
header,
home,
footer,
pages,
} from '@wordpress/icons';
// @ts-ignore No types for this exist yet.
import SidebarNavigationItem from '@wordpress/edit-site/build-module/components/sidebar-navigation-item';
import { Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -45,8 +45,20 @@ export const SidebarNavigationScreenMain = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'main',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}
@ -64,6 +76,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/logo"
withChevron
icon={ siteLogo }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'logo',
}
);
} }
>
{ __( 'Add your logo', 'woocommerce' ) }
</NavigatorButton>
@ -72,6 +92,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/color-palette"
withChevron
icon={ color }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'color-palette',
}
);
} }
>
{ __( 'Change the color palette', 'woocommerce' ) }
</NavigatorButton>
@ -80,6 +108,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/typography"
withChevron
icon={ typography }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'typography',
}
);
} }
>
{ __( 'Change fonts', 'woocommerce' ) }
</NavigatorButton>
@ -95,6 +131,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/header"
withChevron
icon={ header }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'header',
}
);
} }
>
{ __( 'Change your header', 'woocommerce' ) }
</NavigatorButton>
@ -103,6 +147,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/homepage"
withChevron
icon={ home }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'home',
}
);
} }
>
{ __( 'Change your homepage', 'woocommerce' ) }
</NavigatorButton>
@ -111,17 +163,26 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/footer"
withChevron
icon={ footer }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'footer',
}
);
} }
>
{ __( 'Change your footer', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
{ /* TODO: Turn on this in Phrase 2 */ }
{ /* <NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/assembler-hub/pages"
withChevron
icon={ pages }
>
{ __( 'Add and edit other pages', 'woocommerce' ) }
</NavigatorButton>
</NavigatorButton> */ }
</ItemGroup>
</>
}

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
import { Link } from '@woocommerce/components';
import { createInterpolateElement } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -23,8 +24,20 @@ export const SidebarNavigationScreenPages = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'pages',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
PageLink: (

View File

@ -14,6 +14,7 @@ import { noop } from 'lodash';
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -42,14 +43,38 @@ export const SidebarNavigationScreenTypography = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'typography',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
StyleLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_style_link_click',
{
source: 'typography',
}
);
window.open(
`${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -73,6 +73,7 @@
padding-top: 80px;
padding-bottom: 0;
gap: 0;
width: 348px;
}
.edit-site-sidebar-navigation-screen__title-icon,
@ -243,9 +244,13 @@
.woocommerce-customize-store__sidebar-logo-container {
margin: 12px 0 32px;
width: 324px;
padding: 32px;
cursor: pointer;
width: 324px;
.woocommerce-customize-store_custom-logo {
width: 100%;
}
}
.woocommerce-customize-store__sidebar-logo-content {
@ -411,6 +416,16 @@
}
}
.edit-site-sidebar-navigation-screen__content .block-editor-block-patterns-list {
width: 324px;
}
.woocommerce-customize-store__sidebar-homepage-content {
.block-editor-block-preview__content {
background-color: #fff;
max-height: 280px !important;
}
}
/* Preview Canvas */
.edit-site-layout__canvas {
@ -452,10 +467,18 @@
.woocommerce-customize-store__block-editor,
.edit-site-layout:not(.is-full-canvas) .edit-site-layout__canvas > div .interface-interface-skeleton__content {
border-radius: 20px;
}
.interface-interface-skeleton__content {
@include custom-scrollbars-on-hover(transparent, $gray-600);
.woocommerce-customize-store__block-editor,
.woocommerce-block-preview-container,
.auto-block-preview__container {
height: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
}
}
.edit-site-resizable-frame__inner-content {
@ -526,3 +549,42 @@
margin-left: 12px;
}
}
.woocommerce-customize-store_global-styles-variations_item {
border-radius: 2px;
padding: 2.5px;
.woocommerce-customize-store_global-styles-variations_item-preview {
border: 1px solid #dcdcde;
background: #fff;
}
&:hover,
&.is-active {
box-shadow: 0 0 0 1.5px var(--wp-admin-theme-color), 0 0 0 2.5px #fff;
}
}
.edit-site-sidebar-navigation-screen-patterns__group-homepage {
.woocommerce-collapsible-content:last-child {
border-bottom: none;
}
.woocommerce-collapsible-content {
padding: 16px 0 16px 0;
border-bottom: 1px solid #ededed;
button {
width: 100%;
color: #1e1e1e;
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.15px;
}
svg {
margin-left: auto;
fill: #1e1e1e;
width: 20px;
height: 20px;
}
}
}

View File

@ -9,8 +9,10 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import {
ColorPalette,
designWithAiStateMachineContext,
designWithAiStateMachineEvents,
LookAndToneCompletionResponse,
} from './types';
import { aiWizardClosedBeforeCompletionEvent } from './events';
import {
@ -18,7 +20,6 @@ import {
lookAndFeelCompleteEvent,
toneOfVoiceCompleteEvent,
} from './pages';
import { LookAndToneCompletionResponse } from './services';
const assignBusinessInfoDescription = assign<
designWithAiStateMachineContext,
@ -72,14 +73,28 @@ const assignLookAndTone = assign<
},
} );
const assignDefaultColorPalette = assign<
designWithAiStateMachineContext,
designWithAiStateMachineEvents
>( {
aiSuggestions: ( context, event: unknown ) => {
return {
...context.aiSuggestions,
defaultColorPalette: (
event as {
data: {
response: ColorPalette;
};
}
).data.response,
};
},
} );
const logAIAPIRequestError = () => {
// log AI API request error
// eslint-disable-next-line no-console
console.log( 'API Request error' );
recordEvent(
'customize_your_store_look_and_tone_ai_completion_response_error',
{ error_type: 'http_network_error' }
);
};
const updateQueryStep = (
@ -142,6 +157,7 @@ export const actions = {
assignLookAndFeel,
assignToneOfVoice,
assignLookAndTone,
assignDefaultColorPalette,
logAIAPIRequestError,
updateQueryStep,
recordTracksStepViewed,

View File

@ -19,6 +19,8 @@ import {
} from './pages';
import { customizeStoreStateMachineEvents } from '..';
import './style.scss';
export type events = { type: 'THEME_SUGGESTED' };
export type DesignWithAiComponent =
| typeof BusinessInfoDescription

View File

@ -0,0 +1,142 @@
/**
* Internal dependencies
*/
import { defaultColorPalette } from '.';
describe( 'colorPairing.responseValidation', () => {
it( 'should validate a correct color palette', () => {
const validPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
};
const parsedResult =
defaultColorPalette.responseValidation( validPalette );
expect( parsedResult ).toEqual( validPalette );
} );
it( 'should fail for an incorrect name', () => {
const invalidPalette = {
name: 'Invalid Name',
primary: '#11163d',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Color palette not part of allowed list\\",
\\"path\\": [
\\"name\\"
]
}
]"
` );
} );
it( 'should fail for an invalid primary color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: 'invalidColor',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid primary color\\",
\\"path\\": [
\\"primary\\"
]
}
]"
` );
} );
it( 'should fail for an invalid secondary color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: 'invalidColor',
foreground: '#11163d',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid secondary color\\",
\\"path\\": [
\\"secondary\\"
]
}
]"
` );
} );
it( 'should fail for an invalid foreground color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '11163d',
foreground: '#invalid_color',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid secondary color\\",
\\"path\\": [
\\"secondary\\"
]
},
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid foreground color\\",
\\"path\\": [
\\"foreground\\"
]
}
]"
` );
} );
it( 'should fail for an invalid background color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '#11163d',
foreground: '#11163d',
background: '#fffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid background color\\",
\\"path\\": [
\\"background\\"
]
}
]"
` );
} );
} );

View File

@ -0,0 +1,240 @@
/**
* External dependencies
*/
import { z } from 'zod';
/**
* Internal dependencies
*/
import { ColorPalette } from '../types';
const colorChoices: ColorPalette[] = [
{
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
},
{
name: 'Crimson Tide',
primary: '#A02040',
secondary: '#234B57',
foreground: '#871C37',
background: '#ffffff',
},
{
name: 'Purple Twilight',
primary: '#301834',
secondary: '#6a5eb7',
foreground: '#090909',
background: '#fefbff',
},
{
name: 'Midnight Citrus',
primary: '#1B1736',
secondary: '#7E76A3',
foreground: '#1B1736',
background: '#ffffff',
},
{
name: 'Lemon Myrtle',
primary: '#3E7172',
secondary: '#FC9B00',
foreground: '#325C5D',
background: '#ffffff',
},
{
name: 'Green Thumb',
primary: '#164A41',
secondary: '#4B7B4D',
foreground: '#164A41',
background: '#ffffff',
},
{
name: 'Golden Haze',
primary: '#232224',
secondary: '#EBB54F',
foreground: '#515151',
background: '#ffffff',
},
{
name: 'Golden Indigo',
primary: '#4866C0',
secondary: '#C09F50',
foreground: '#405AA7',
background: '#ffffff',
},
{
name: 'Arctic Dawn',
primary: '#243156',
secondary: '#DE5853',
foreground: '#243156',
background: '#ffffff',
},
{
name: 'Jungle Sunrise',
primary: '#1a4435',
secondary: '#ed774e',
foreground: '#0a271d',
background: '#fefbec',
},
{
name: 'Berry Grove',
primary: '#1F351A',
secondary: '#DE76DE',
foreground: '#1f351a',
background: '#fdfaf1',
},
{
name: 'Fuchsia',
primary: '#b7127f',
secondary: '#18020C',
foreground: '#b7127f',
background: '#f7edf6',
},
{
name: 'Raspberry Chocolate',
primary: '#42332e',
secondary: '#d64d68',
foreground: '#241d1a',
background: '#eeeae6',
},
{
name: 'Canary',
primary: '#0F0F05',
secondary: '#353535',
foreground: '#0F0F05',
background: '#FCFF9B',
},
{
name: 'Gumtree Sunset',
primary: '#476C77',
secondary: '#EFB071',
foreground: '#476C77',
background: '#edf4f4',
},
{
name: 'Ice',
primary: '#12123F',
secondary: '#3473FE',
foreground: '#12123F',
background: '#F1F4FA',
},
{
name: 'Cinder',
primary: '#c14420',
secondary: '#2F2D2D',
foreground: '#863119',
background: '#f1f2f2',
},
{
name: 'Blue Lagoon',
primary: '#004DE5',
secondary: '#0496FF',
foreground: '#0036A3',
background: '#FEFDF8',
},
{
name: 'Sandalwood Oasis',
primary: '#F0EBE3',
secondary: '#DF9785',
foreground: '#ffffff',
background: '#2a2a16',
},
{
name: 'Rustic Rosewood',
primary: '#F4F4F2',
secondary: '#EE797C',
foreground: '#ffffff',
background: '#1A1A1A',
},
{
name: 'Cinnamon Latte',
primary: '#D9CAB3',
secondary: '#BC8034',
foreground: '#FFFFFF',
background: '#3C3F4D',
},
{
name: 'Lilac Nightshade',
primary: '#f5d6ff',
secondary: '#C48DDA',
foreground: '#ffffff',
background: '#000000',
},
{
name: 'Lightning',
primary: '#ebffd2',
secondary: '#fefefe',
foreground: '#ebffd2',
background: '#0e1fb5',
},
{
name: 'Aquamarine Night',
primary: '#deffef',
secondary: '#56fbb9',
foreground: '#ffffff',
background: '#091C48',
},
{
name: 'Charcoal',
primary: '#dbdbdb',
secondary: '#efefef',
foreground: '#dbdbdb',
background: '#1e1e1e',
},
{
name: 'Evergreen Twilight',
primary: '#ffffff',
secondary: '#8EE978',
foreground: '#ffffff',
background: '#181818',
},
{
name: 'Slate',
primary: '#FFFFFF',
secondary: '#FFDF6D',
foreground: '#EFF2F9',
background: '#13161E',
},
];
const allowedNames: string[] = colorChoices.map( ( palette ) => palette.name );
const hexColorRegex = /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/;
export const colorPaletteValidator = z.object( {
name: z.string().refine( ( name ) => allowedNames.includes( name ), {
message: 'Color palette not part of allowed list',
} ),
primary: z
.string()
.regex( hexColorRegex, { message: 'Invalid primary color' } ),
secondary: z
.string()
.regex( hexColorRegex, { message: 'Invalid secondary color' } ),
foreground: z
.string()
.regex( hexColorRegex, { message: 'Invalid foreground color' } ),
background: z
.string()
.regex( hexColorRegex, { message: 'Invalid background color' } ),
} );
export const defaultColorPalette = {
queryId: 'default_color_palette',
// make sure version is updated every time the prompt is changed
version: '2023-09-18',
prompt: ( businessDescription: string, look: string, tone: string ) => {
return `
You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate color scheme.
Respond only with one color scheme and only its JSON.
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
Colors to choose from:
${ JSON.stringify( colorChoices ) }
`;
},
responseValidation: colorPaletteValidator.parse,
};

View File

@ -0,0 +1,2 @@
export * from './colorChoices';
export * from './lookAndTone';

View File

@ -0,0 +1,28 @@
/**
* Internal dependencies
*/
import { LookAndToneCompletionResponse } from '../types';
import { lookAndTone } from '.';
describe( 'parseLookAndToneCompletionResponse', () => {
it( 'should return a valid object when given valid JSON', () => {
const validObj = JSON.parse(
'{"look": "Contemporary", "tone": "Neutral"}'
);
const result = lookAndTone.responseValidation( validObj );
const expected: LookAndToneCompletionResponse = {
look: 'Contemporary',
tone: 'Neutral',
};
expect( result ).toEqual( expected );
} );
it( 'should throw an error and record an event for valid JSON but invalid values', () => {
const invalidValuesObj = {
completion: '{"look": "Invalid", "tone": "Invalid"}',
};
expect( () =>
lookAndTone.responseValidation( invalidValuesObj )
).toThrow( 'Invalid values in Look and Tone completion response' );
} );
} );

View File

@ -0,0 +1,51 @@
/**
* Internal dependencies
*/
import {
Look,
Tone,
VALID_LOOKS,
VALID_TONES,
LookAndToneCompletionResponse,
} from '../types';
export const isLookAndToneCompletionResponse = (
obj: unknown
): obj is LookAndToneCompletionResponse => {
return (
obj !== undefined &&
obj !== null &&
typeof obj === 'object' &&
'look' in obj &&
VALID_LOOKS.includes( obj.look as Look ) &&
'tone' in obj &&
VALID_TONES.includes( obj.tone as Tone )
);
};
export const lookAndTone = {
queryId: 'look_and_tone',
// make sure version is updated every time the prompt is changed
version: '2023-09-18',
prompt: ( businessInfoDescription: string ) => {
return [
'You are a WordPress theme expert.',
'Analyze the following store description and determine the look and tone of the theme.',
`For look, you can choose between ${ VALID_LOOKS.join( ',' ) }.`,
`For tone of the description, you can choose between ${ VALID_TONES.join(
','
) }.`,
'Your response should be in json with look and tone values.',
'\n',
businessInfoDescription,
].join( '\n' );
},
responseValidation: ( response: unknown ) => {
if ( isLookAndToneCompletionResponse( response ) ) {
return response;
}
throw new Error(
'Invalid values in Look and Tone completion response'
);
},
};

View File

@ -4,103 +4,13 @@
import { __experimentalRequestJetpackToken as requestJetpackToken } from '@woocommerce/ai';
import apiFetch from '@wordpress/api-fetch';
import { recordEvent } from '@woocommerce/tracks';
import { Sender } from 'xstate';
import { Sender, assign, createMachine } from 'xstate';
/**
* Internal dependencies
*/
import {
Look,
Tone,
VALID_LOOKS,
VALID_TONES,
designWithAiStateMachineContext,
} from './types';
export interface LookAndToneCompletionResponse {
look: Look;
tone: Tone;
}
interface MaybeLookAndToneCompletionResponse {
completion: string;
}
export const isLookAndToneCompletionResponse = (
obj: unknown
): obj is LookAndToneCompletionResponse => {
return (
obj !== undefined &&
obj !== null &&
typeof obj === 'object' &&
'look' in obj &&
VALID_LOOKS.includes( obj.look as Look ) &&
'tone' in obj &&
VALID_TONES.includes( obj.tone as Tone )
);
};
export const parseLookAndToneCompletionResponse = (
obj: MaybeLookAndToneCompletionResponse
): LookAndToneCompletionResponse => {
try {
const o = JSON.parse( obj.completion );
if ( isLookAndToneCompletionResponse( o ) ) {
return o;
}
} catch {
recordEvent(
'customize_your_store_look_and_tone_ai_completion_response_error',
{ error_type: 'json_parse_error', response: JSON.stringify( obj ) }
);
}
recordEvent(
'customize_your_store_look_and_tone_ai_completion_response_error',
{
error_type: 'valid_json_invalid_values',
response: JSON.stringify( obj ),
}
);
throw new Error( 'Could not parse Look and Tone completion response.' );
};
export const getLookAndTone = async (
context: designWithAiStateMachineContext
) => {
const prompt = [
'You are a WordPress theme expert.',
'Analyze the following store description and determine the look and tone of the theme.',
`For look, you can choose between ${ VALID_LOOKS.join( ',' ) }.`,
`For tone of the description, you can choose between ${ VALID_TONES.join(
','
) }.`,
'Your response should be in json with look and tone values.',
'\n',
context.businessInfoDescription.descriptionText,
];
const { token } = await requestJetpackToken();
const url = new URL(
'https://public-api.wordpress.com/wpcom/v2/text-completion'
);
url.searchParams.append( 'feature', 'woo_cys' );
const data: {
completion: string;
} = await apiFetch( {
url: url.toString(),
method: 'POST',
data: {
token,
prompt: prompt.join( '\n' ),
_fields: 'completion',
},
} );
return parseLookAndToneCompletionResponse( data );
};
import { designWithAiStateMachineContext } from './types';
import { lookAndTone } from './prompts';
const browserPopstateHandler =
() => ( sendBack: Sender< { type: 'EXTERNAL_URL_UPDATE' } > ) => {
@ -113,7 +23,177 @@ const browserPopstateHandler =
};
};
export const getCompletion = async < ValidResponseObject >( {
queryId,
prompt,
version,
responseValidation,
retryCount,
}: {
queryId: string;
prompt: string;
version: string;
responseValidation: ( arg0: string ) => ValidResponseObject;
retryCount: number;
} ) => {
const { token } = await requestJetpackToken();
let data: {
completion: string;
};
let parsedCompletionJson;
try {
const url = new URL(
'https://public-api.wordpress.com/wpcom/v2/text-completion'
);
url.searchParams.append( 'feature', 'woo_cys' );
data = await apiFetch( {
url: url.toString(),
method: 'POST',
data: {
token,
prompt,
_fields: 'completion',
},
} );
} catch ( error ) {
recordEvent( 'customize_your_store_ai_completion_api_error', {
query_id: queryId,
version,
retry_count: retryCount,
error_type: 'api_request_error',
} );
throw error;
}
try {
parsedCompletionJson = JSON.parse( data.completion );
} catch {
recordEvent( 'customize_your_store_ai_completion_response_error', {
query_id: queryId,
version,
retry_count: retryCount,
error_type: 'json_parse_error',
response: data.completion,
} );
throw new Error(
`Error validating Jetpack AI text completions response for ${ queryId }`
);
}
try {
const validatedResponse = responseValidation( parsedCompletionJson );
recordEvent( 'customize_your_store_ai_completion_success', {
query_id: queryId,
version,
retry_count: retryCount,
} );
return validatedResponse;
} catch ( error ) {
recordEvent( 'customize_your_store_ai_completion_response_error', {
query_id: queryId,
version,
retry_count: retryCount,
error_type: 'valid_json_invalid_values',
response: data.completion,
} );
throw error;
}
};
export const getLookAndTone = async (
context: designWithAiStateMachineContext
) => {
return getCompletion( {
...lookAndTone,
prompt: lookAndTone.prompt(
context.businessInfoDescription.descriptionText
),
retryCount: 0,
} );
};
export const queryAiEndpoint = createMachine(
{
id: 'query-ai-endpoint',
predictableActionArguments: true,
initial: 'init',
context: {
// these values are all overwritten by incoming parameters
prompt: '',
queryId: '',
version: '',
responseValidation: () => true,
retryCount: 0,
validatedResponse: {} as unknown,
},
states: {
init: {
always: 'querying',
entry: [ 'setRetryCount' ],
},
querying: {
invoke: {
src: 'getCompletion',
onDone: {
target: 'success',
actions: [ 'handleAiResponse' ],
},
onError: {
target: 'error',
},
},
},
error: {
always: [
{
cond: ( context ) => context.retryCount >= 3,
target: 'failed',
},
{
target: 'querying',
actions: assign( {
retryCount: ( context ) => context.retryCount + 1,
} ),
},
],
},
failed: {
type: 'final',
data: {
result: 'failed',
},
},
success: {
type: 'final',
data: ( context ) => {
return {
result: 'success',
response: context.validatedResponse,
};
},
},
},
},
{
actions: {
handleAiResponse: assign( {
validatedResponse: ( _context, event: unknown ) =>
( event as { data: unknown } ).data,
} ),
setRetryCount: assign( {
retryCount: 0,
} ),
},
services: {
getCompletion,
},
}
);
export const services = {
getLookAndTone,
browserPopstateHandler,
queryAiEndpoint,
};

View File

@ -10,6 +10,7 @@ import { getQuery } from '@woocommerce/navigation';
import {
designWithAiStateMachineContext,
designWithAiStateMachineEvents,
ColorPalette,
} from './types';
import {
BusinessInfoDescription,
@ -19,6 +20,7 @@ import {
} from './pages';
import { actions } from './actions';
import { services } from './services';
import { defaultColorPalette } from './prompts';
export const hasStepInUrl = (
_ctx: unknown,
@ -60,13 +62,15 @@ export const designWithAiStateMachineDefinition = createMachine(
businessInfoDescription: {
descriptionText: '',
},
lookAndFeel: {
choice: '',
},
toneOfVoice: {
choice: '',
},
aiSuggestions: {
defaultColorPalette: {} as ColorPalette,
},
},
initial: 'navigate',
states: {
@ -264,6 +268,30 @@ export const designWithAiStateMachineDefinition = createMachine(
step: 'api-call-loader',
},
],
type: 'parallel',
states: {
chooseColorPairing: {
invoke: {
src: 'queryAiEndpoint',
data: ( context ) => {
return {
...defaultColorPalette,
prompt: defaultColorPalette.prompt(
context.businessInfoDescription
.descriptionText,
context.lookAndFeel.choice,
context.toneOfVoice.choice
),
};
},
onDone: {
actions: [
'assignDefaultColorPalette',
],
},
},
},
},
},
postApiCallLoader: {},
},

View File

@ -0,0 +1,28 @@
.woocommerce-onboarding-loader {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: #fff;
.woocommerce-onboarding-loader-wrapper {
align-items: flex-start;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 520px;
min-height: 400px;
.woocommerce-onboarding-loader-container {
text-align: center;
min-height: 400px;
}
.loader-hearticon {
position: relative;
top: 2px;
left: 2px;
}
}
}

View File

@ -2,63 +2,147 @@
* External dependencies
*/
import { recordEvent } from '@woocommerce/tracks';
import { __experimentalRequestJetpackToken as requestJetpackToken } from '@woocommerce/ai';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import {
parseLookAndToneCompletionResponse,
LookAndToneCompletionResponse,
} from '../services';
import { getCompletion } from '../services';
jest.mock( '@woocommerce/tracks', () => ( {
recordEvent: jest.fn(),
} ) );
describe( 'parseLookAndToneCompletionResponse', () => {
jest.mock( '@woocommerce/ai', () => ( {
__experimentalRequestJetpackToken: jest.fn(),
} ) );
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
describe( 'getCompletion', () => {
beforeEach( () => {
( recordEvent as jest.Mock ).mockClear();
jest.clearAllMocks();
} );
it( 'should return a valid object when given valid JSON', () => {
const validObj = {
completion: '{"look": "Contemporary", "tone": "Neutral"}',
};
const result = parseLookAndToneCompletionResponse( validObj );
const expected: LookAndToneCompletionResponse = {
look: 'Contemporary',
tone: 'Neutral',
};
expect( result ).toEqual( expected );
expect( recordEvent ).not.toHaveBeenCalled();
} );
it( 'should successfully get completion', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockResolvedValue( {
completion: JSON.stringify( { key: 'value' } ),
} );
const responseValidation = jest.fn( ( json ) => json );
it( 'should throw an error and record an event for JSON parse error', () => {
const invalidObj = { completion: 'invalid JSON' };
expect( () =>
parseLookAndToneCompletionResponse( invalidObj )
).toThrow( 'Could not parse Look and Tone completion response.' );
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_look_and_tone_ai_completion_response_error',
const result = await getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation,
retryCount: 0,
version: '1',
} );
expect( result ).toEqual( { key: 'value' } );
expect( responseValidation ).toBeCalledWith( { key: 'value' } );
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_success',
{
error_type: 'json_parse_error',
response: JSON.stringify( invalidObj ),
query_id: 'query1',
retry_count: 0,
version: '1',
}
);
} );
it( 'should throw an error and record an event for valid JSON but invalid values', () => {
const invalidValuesObj = {
completion: '{"look": "Invalid", "tone": "Invalid"}',
};
expect( () =>
parseLookAndToneCompletionResponse( invalidValuesObj )
).toThrow( 'Could not parse Look and Tone completion response.' );
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_look_and_tone_ai_completion_response_error',
it( 'should handle API fetch error', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockRejectedValue(
new Error( 'API error' )
);
await expect(
getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation: () => {},
retryCount: 0,
version: '1',
} )
).rejects.toThrow( 'API error' );
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_api_error',
{
query_id: 'query1',
retry_count: 0,
error_type: 'api_request_error',
version: '1',
}
);
} );
it( 'should handle JSON parse error', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockResolvedValue( {
completion: 'invalid json',
} );
await expect(
getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation: () => {},
retryCount: 0,
version: '1',
} )
).rejects.toThrow(
`Error validating Jetpack AI text completions response for query1`
);
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_response_error',
{
query_id: 'query1',
retry_count: 0,
error_type: 'json_parse_error',
response: 'invalid json',
version: '1',
}
);
} );
it( 'should handle validation error', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockResolvedValue( {
completion: JSON.stringify( { key: 'invalid value' } ),
} );
const responseValidation = jest.fn( () => {
throw new Error( 'Validation error' );
} );
await expect(
getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation,
retryCount: 0,
version: '1',
} )
).rejects.toThrow( 'Validation error' );
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_response_error',
{
query_id: 'query1',
retry_count: 0,
error_type: 'valid_json_invalid_values',
response: JSON.stringify( invalidValuesObj ),
response: JSON.stringify( { key: 'invalid value' } ),
version: '1',
}
);
} );

View File

@ -1,3 +1,12 @@
/**
* External dependencies
*/
import { z } from 'zod';
/**
* Internal dependencies
*/
import { colorPaletteValidator } from './prompts';
export type designWithAiStateMachineContext = {
businessInfoDescription: {
descriptionText: string;
@ -8,6 +17,9 @@ export type designWithAiStateMachineContext = {
toneOfVoice: {
choice: Tone | '';
};
aiSuggestions: {
defaultColorPalette: ColorPalette;
};
// If we require more data from options, previously provided core profiler details,
// we can retrieve them in preBusinessInfoDescription and then assign them here
};
@ -31,3 +43,10 @@ export const VALID_LOOKS = [ 'Contemporary', 'Classic', 'Bold' ] as const;
export const VALID_TONES = [ 'Informal', 'Neutral', 'Formal' ] as const;
export type Look = ( typeof VALID_LOOKS )[ number ];
export type Tone = ( typeof VALID_TONES )[ number ];
export interface LookAndToneCompletionResponse {
look: Look;
tone: Tone;
}
export type ColorPalette = z.infer< typeof colorPaletteValidator >;

View File

@ -5,6 +5,8 @@ import { Sender, createMachine } from 'xstate';
import { useEffect, useMemo, useState } from '@wordpress/element';
import { useMachine, useSelector } from '@xstate/react';
import { getQuery, updateQueryString } from '@woocommerce/navigation';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { dispatch } from '@wordpress/data';
/**
* Internal dependencies
@ -57,6 +59,12 @@ const updateQueryStep = (
}
};
const markTaskComplete = async () => {
return dispatch( OPTIONS_STORE_NAME ).updateOptions( {
woocommerce_admin_customize_store_completed: 'yes',
} );
};
const browserPopstateHandler =
() => ( sendBack: Sender< { type: 'EXTERNAL_URL_UPDATE' } > ) => {
const popstateHandler = () => {
@ -81,6 +89,7 @@ export const customizeStoreStateMachineServices = {
...introServices,
...transitionalServices,
browserPopstateHandler,
markTaskComplete,
};
export const customizeStoreStateMachineDefinition = createMachine( {
id: 'customizeStore',
@ -220,6 +229,14 @@ export const customizeStoreStateMachineDefinition = createMachine( {
},
},
postAssemblerHub: {
invoke: {
src: 'markTaskComplete',
onDone: {
target: 'waitForSitePreview',
},
},
},
waitForSitePreview: {
after: {
// Wait for 5 seconds before redirecting to the transitional page. This is to ensure that the site preview image is refreshed.
5000: {

View File

@ -24,6 +24,10 @@
cursor: default;
}
&__content {
white-space: pre-line;
}
.components-snackbar__content-with-icon {
margin-left: 32px;
}

View File

@ -1,12 +1,14 @@
<svg width="74" height="100" viewBox="0 0 74 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M67.7896 0L62.6619 4.11855L57.5289 0L52.4011 4.11855L47.2682 0L42.1353 4.11855L37.0075 0L31.8746 4.11855L26.7468 0L21.6138 4.11855L16.4809 0L11.348 4.11855L6.21501 0L0.916992 4.25258V95.7474L6.20986 100L11.3428 95.8763L16.4706 100L21.6035 95.8763L26.7313 100L31.8642 95.8763L36.9972 100L42.125 95.8763L47.2579 100L52.3857 95.8763L57.5186 100L62.6515 95.8763L67.7896 100L73.0825 95.7474V4.25258L67.7896 0Z" fill="#E0E0E0"/>
<path d="M49.4449 18.7216H7.10547V22.5103H49.4449V18.7216Z" fill="#757575"/>
<path d="M49.4449 30.2784H7.10547V34.067H49.4449V30.2784Z" fill="#757575"/>
<path d="M49.4449 41.8351H7.10547V45.6237H49.4449V41.8351Z" fill="#757575"/>
<path d="M66.8991 18.7216H56.9102V22.5103H66.8991V18.7216Z" fill="white"/>
<path d="M66.8991 30.2783H56.9102V34.067H66.8991V30.2783Z" fill="white"/>
<path d="M66.8991 41.835H56.9102V45.6237H66.8991V41.835Z" fill="white"/>
<path d="M66.8993 63.9176H50.4043V71.1341H66.8993V63.9176Z" fill="#757575"/>
<path d="M7.13379 55.5258H66.8714" stroke="#271B3D" stroke-width="0.510311" stroke-miterlimit="10"/>
<path d="M51.2154 89.8917C50.6639 89.8917 50.1845 89.7731 49.8082 89.4999C48.0556 88.2473 47.8597 85.768 47.6845 83.5772C47.4886 81.0618 47.2876 79.5205 45.736 79.5205C44.437 79.5205 43.5659 81.7731 42.7205 83.9484C41.705 86.5669 40.6535 89.2731 38.6122 89.2731C36.4473 89.2731 36.1895 87.1752 35.937 85.1494C35.6741 83.0205 35.4266 81.0051 33.2256 81.0051V80.4948C35.8802 80.4948 36.1792 82.9329 36.4473 85.0875C36.7308 87.3762 37.1586 88.4071 38.6071 88.4535C39.8648 88.4896 41.2875 86.2267 42.2411 83.768C43.1896 81.3247 44.2978 78.2886 45.9473 78.2886C48.0504 78.2886 48.2721 80.3762 48.4061 82.8195C48.5401 85.2628 48.5401 87.9741 50.102 89.0927C52.8546 91.0618 62.4011 83.2473 66.2259 79.4174L67.0558 80.3711C66.5609 80.804 56.0093 89.8968 51.2154 89.8968V89.8917Z" fill="#CCCCCC"/>
</svg>
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2483_9556)">
<path d="M15.9776 43.968C11.1189 43.968 5.34584 44.544 2.95838 46.928C2.95838 46.928 2.95838 46.928 2.95439 46.932C2.95439 46.932 2.95439 46.932 2.95039 46.936C0.574906 49.332 -9.13044e-07 55.112 -7.00082e-07 59.984C-4.8712e-07 64.856 0.574907 70.64 2.95039 73.032C2.95039 73.032 2.95039 73.032 2.95439 73.036C2.95439 73.036 2.95438 73.036 2.95838 73.04C5.34584 75.424 11.1189 76 15.9776 76C20.8364 76 26.6094 75.424 28.9969 73.04C28.9969 73.04 28.9969 73.04 29.0009 73.036C29.0049 73.032 29.0009 73.036 29.0049 73.032C31.3804 70.636 31.9553 64.856 31.9553 59.984C31.9553 55.112 31.3804 49.328 29.0049 46.936C29.0049 46.936 29.0049 46.936 29.0009 46.932C28.9969 46.928 29.0009 46.932 28.9969 46.928C26.6094 44.544 20.8364 43.968 15.9776 43.968Z" fill="#757575"/>
<path d="M63.9776 3.968C59.1189 3.968 53.3458 4.544 50.9584 6.928C50.9584 6.928 50.9584 6.928 50.9544 6.932C50.9544 6.932 50.9544 6.932 50.9504 6.936C48.5749 9.332 48 15.112 48 19.984C48 24.856 48.5749 30.64 50.9504 33.032C50.9504 33.032 50.9504 33.032 50.9544 33.036C50.9544 33.036 50.9544 33.036 50.9584 33.04C53.3458 35.424 59.1189 36 63.9776 36C68.8364 36 74.6094 35.424 76.9969 33.04C76.9969 33.04 76.9969 33.04 77.0009 33.036C77.0049 33.032 77.0009 33.036 77.0049 33.032C79.3804 30.636 79.9553 24.856 79.9553 19.984C79.9553 15.112 79.3804 9.328 77.0049 6.936C77.0049 6.936 77.0049 6.936 77.0009 6.932C76.9969 6.928 77.0009 6.932 76.9969 6.92799C74.6094 4.544 68.8364 3.968 63.9776 3.968Z" fill="#757575"/>
<path d="M40 60C40 64.8656 40.7193 70.6467 43.6963 73.0375C43.6963 73.0375 43.6963 73.0375 43.7013 73.0415C43.7013 73.0415 43.7013 73.0415 43.7063 73.0455C46.6983 75.4243 53.9161 76 60 76C66.0839 76 73.3067 75.4243 76.2937 73.0455C76.2937 73.0455 76.2937 73.0455 76.2987 73.0415C76.2987 73.0415 76.2987 73.0415 76.3037 73.0375C79.2807 70.6467 80 64.8656 80 60C80 55.1344 79.2807 49.3533 76.3037 46.9625C76.3037 46.9625 76.3037 46.9625 76.2987 46.9585C76.2937 46.9545 76.2987 46.9585 76.2937 46.9545C73.3017 44.5757 66.0839 44 60 44C53.9161 44 46.6933 44.5757 43.7063 46.9545C43.7063 46.9545 43.7063 46.9545 43.7013 46.9585C43.6963 46.9625 43.7013 46.9585 43.6963 46.9625C40.7193 49.3533 40 55.1344 40 60Z" fill="#E0E0E0"/>
<path d="M-1.39876e-06 20C-9.73403e-07 24.8656 0.719276 30.6467 3.6963 33.0375C3.6963 33.0375 3.6963 33.0375 3.7013 33.0415C3.7013 33.0415 3.7013 33.0415 3.70629 33.0455C6.6983 35.4243 13.9161 36 20 36C26.0839 36 33.3067 35.4243 36.2937 33.0455C36.2937 33.0455 36.2937 33.0455 36.2987 33.0415C36.2987 33.0415 36.2987 33.0415 36.3037 33.0375C39.2807 30.6467 40 24.8656 40 20C40 15.1344 39.2807 9.35332 36.3037 6.96251C36.3037 6.96251 36.3037 6.96251 36.2987 6.95852C36.2937 6.95452 36.2987 6.95852 36.2937 6.95452C33.3017 4.57571 26.0839 4 20 4C13.9161 4 6.6933 4.57571 3.70629 6.95452C3.70629 6.95452 3.70629 6.95452 3.7013 6.95852C3.6963 6.96252 3.70129 6.95852 3.6963 6.96252C0.719274 9.35332 -1.82413e-06 15.1344 -1.39876e-06 20Z" fill="#E0E0E0"/>
<path d="M38.5095 14.0378C40.9729 11.6218 45.2004 11.3695 45.2004 10.4C45.2004 9.43044 40.9729 9.1782 38.5095 6.76221C36.0461 4.34621 35.789 0.199998 34.8004 0.199998C33.8118 0.199998 33.5546 4.34621 31.0913 6.76221C28.6279 9.1782 24.4004 9.43044 24.4004 10.4C24.4004 11.3695 28.6279 11.6218 31.0913 14.0378C33.5546 16.4538 33.8118 20.6 34.8004 20.6C35.789 20.6 36.0461 16.4538 38.5095 14.0378Z" fill="#757575"/>
</g>
<defs>
<clipPath id="clip0_2483_9556">
<rect width="80" height="80" fill="white" transform="translate(0 80) rotate(-90)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -4,11 +4,11 @@
box-sizing: content-box;
margin: auto;
max-width: $content-max-width;
padding: $header-height-mobile $content-spacing-small $content-spacing-small;
padding: 0 $content-spacing-small $content-spacing-small;
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace__content {
padding: $header-height-desktop $content-spacing-large $content-spacing-large;
padding: 0 $content-spacing-large $content-spacing-large;
}
}

View File

@ -112,7 +112,12 @@ export default function Extensions(): JSX.Element {
return <NoResults />;
}
return <ProductListContent products={ products } />;
return (
<>
<CategorySelector />
<ProductListContent products={ products } />
</>
);
}
return (
@ -120,7 +125,6 @@ export default function Extensions(): JSX.Element {
<h2 className="woocommerce-marketplace__product-list-title woocommerce-marketplace__product-list-title--extensions">
{ title }
</h2>
<CategorySelector />
{ content() }
</div>
);

View File

@ -14,7 +14,7 @@ import WooIcon from '../../assets/images/woo-icon.svg';
import { MARKETPLACE_HOST } from '../constants';
const refundPolicyTitle = createInterpolateElement(
__( '30 day <a>money back guarantee</a>', 'woocommerce' ),
__( '30-day <a>money-back guarantee</a>', 'woocommerce' ),
{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href={ MARKETPLACE_HOST + '/refund-policy/' } />,

View File

@ -5,6 +5,7 @@
padding: $grid-unit-80 $grid-unit-40;
display: flex;
flex-direction: column;
align-items: center;
margin-top: $grid-unit-30;
}
@ -13,12 +14,13 @@
}
.woocommerce-marketplace__no-results__icon {
height: 100px;
height: 80px;
}
.woocommerce-marketplace__no-results__description {
text-align: center;
font-size: 13px;
max-width: 52ch;
p {
color: $gutenberg-gray-700;

View File

@ -90,13 +90,15 @@ export default function NoResults(): JSX.Element {
className="woocommerce-marketplace__no-results__icon"
src={ NoResultsIcon }
alt={ __( 'No results.', 'woocommerce' ) }
width="80"
height="80"
/>
<div className="woocommerce-marketplace__no-results__description">
<h3 className="woocommerce-marketplace__no-results__description--bold">
{ sprintf(
// translators: %s: search term
__(
'We didn\'t find any results for "%s"',
"We didn't find any results for “%s”",
'woocommerce'
),
noResultsTerm

View File

@ -22,12 +22,14 @@ export default function ProductListContent( props: {
title: product.title,
icon: product.icon,
vendorName: product.vendorName,
vendorUrl: appendURLParams( product.vendorUrl, [
[ 'utm_source', 'extensionsscreen' ],
[ 'utm_medium', 'product' ],
[ 'utm_campaign', 'wcaddons' ],
[ 'utm_content', 'devpartner' ],
] ),
vendorUrl: product.vendorUrl
? appendURLParams( product.vendorUrl, [
[ 'utm_source', 'extensionsscreen' ],
[ 'utm_medium', 'product' ],
[ 'utm_campaign', 'wcaddons' ],
[ 'utm_content', 'devpartner' ],
] )
: '',
price: product.price,
url: appendURLParams(
product.url,

View File

@ -3,11 +3,31 @@
.woocommerce-admin-page__extensions {
background: #fff;
#wpbody-content {
/* Prevent double-scrollbar issue on WooCommerce > Extension pages */
overflow: hidden;
}
.woocommerce-layout__primary {
margin: 0;
margin: $header-height-mobile 0 0;
@media (min-width: $breakpoint-medium) {
margin-top: $header-height-desktop;
}
}
.woocommerce-layout__main {
padding: 0;
}
/* On marketplace pages, reposition store alerts so they don't collide with other components */
.woocommerce-store-alerts {
margin-left: 16px;
margin-right: 16px;
@media (min-width: $breakpoint-medium) {
margin-left: 32px;
margin-right: 32px;
}
}
}

View File

@ -49,7 +49,7 @@ export const ProductFormActions: React.FC = () => {
const { isDirty, isValidForm, values, resetForm } =
useFormContext< Product >();
useConfirmUnsavedChanges( isDirty, preventLeavingProductForm );
useConfirmUnsavedChanges( isDirty, preventLeavingProductForm() );
useCustomerEffortScoreExitPageTracker(
! values.id ? 'new_product' : 'editing_new_product',

View File

@ -31,7 +31,7 @@ export const ProductVariationFormActions: React.FC = () => {
const { createNotice } = useDispatch( 'core/notices' );
const [ isSaving, setIsSaving ] = useState( false );
useConfirmUnsavedChanges( isDirty, preventLeavingProductForm );
useConfirmUnsavedChanges( isDirty, preventLeavingProductForm() );
const onSave = async () => {
setIsSaving( true );

View File

@ -44,6 +44,7 @@ jest.mock( '@woocommerce/product-editor', () => {
__experimentalUseFeedbackBar: () => ( {
maybeShowFeedbackBar: jest.fn().mockResolvedValue( {} ),
} ),
preventLeavingProductForm: () => () => false,
};
} );
jest.mock( '@woocommerce/navigation', () => ( {

View File

@ -101,7 +101,8 @@
"react-transition-group": "^4.4.2",
"react-visibility-sensor": "^5.1.1",
"redux": "^4.1.2",
"xstate": "4.37.1"
"xstate": "4.37.1",
"zod": "^3.22.2"
},
"devDependencies": {
"@automattic/color-studio": "^2.5.0",

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add component to Customize Your Store task.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add has_price param to the variations REST API query.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add customize store AI wizard call for color palette suggestion

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add tracks to CYS assembler-hub and hide pages sidebar screen

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add new e2e test to cover My Account Addresses section

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Add new E2E test covering shopper product page and make Product-related tests granular (separated test files)

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Add job to post Slack summary of plugin test results in "Smoke test daily" workflow.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Display search results subtitle in HPOS list table view.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Properly convert local time date queries to UTC in the HPOS datastore.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update use of preventLeavingProductForm with new function changes.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Improve documentation for the `is_checkout()` function.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: Tweak the spelling of the marketplace footer

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Redirect to Jetpack connect when jetpack-boost is selected.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Improved visibility/layout of store alerts on WooCommerce > Extensions pages.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Fixed a bug that caused redundant extra scrollbars to appear on WooCommerce > Extensions pages for some browser/OS/configuration combinations.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: More-resilient handling of absent product vendor URLs when browsing WooCommerce > Extensions.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Implement customize your store task completion logic

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Remove core-profiler checks from the tests -- core profiler is enabled by default now.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: Update the design of the extensions search "no results" page.

View File

@ -866,6 +866,17 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
$args['meta_query'] = $this->add_meta_query( $args, wc_get_min_max_price_meta_query( $request ) ); // WPCS: slow query ok.
}
// Price filter.
if ( is_bool( $request['has_price'] ) ) {
$args['meta_query'] = $this->add_meta_query( // WPCS: slow query ok.
$args,
array(
'key' => '_price',
'compare' => $request['has_price'] ? 'EXISTS' : 'NOT EXISTS',
)
);
}
// Filter product based on stock_status.
if ( ! empty( $request['stock_status'] ) ) {
$args['meta_query'] = $this->add_meta_query( // WPCS: slow query ok.
@ -927,6 +938,13 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'validate_callback' => 'rest_validate_request_arg',
);
$params['has_price'] = array(
'description' => __( 'Limit result set to products with or without price.', 'woocommerce' ),
'type' => 'boolean',
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}

View File

@ -102,7 +102,7 @@ if ( ! function_exists( 'is_cart' ) ) {
if ( ! function_exists( 'is_checkout' ) ) {
/**
* Is_checkout - Returns true when viewing the checkout page.
* Is_checkout - Returns true when viewing the checkout page, or when processing AJAX requests for updating or processing the checkout.
*
* @return bool
*/

View File

@ -214,6 +214,7 @@ class Options extends \WC_REST_Data_Controller {
'wcpay_welcome_page_viewed_timestamp',
'wcpay_welcome_page_exit_survey_more_info_needed_timestamp',
'woocommerce_customize_store_onboarding_tour_hidden',
'woocommerce_admin_customize_store_completed',
// WC Test helper options.
'wc-admin-test-helper-rest-api-filters',
'wc_admin_helper_feature_values',

View File

@ -3,6 +3,7 @@
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Jetpack_Gutenberg;
/**
* Customize Your Store Task
@ -15,7 +16,9 @@ class CustomizeStore extends Task {
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_site_editor_scripts' ) );
add_action( 'after_switch_theme', array( $this, 'mark_task_as_complete' ) );
}
/**
@ -182,5 +185,17 @@ class CustomizeStore extends Task {
* @since 8.0.3
*/
do_action( 'enqueue_block_editor_assets' );
// Load Jetpack's block editor assets because they are not enqueued by default.
if ( class_exists( 'Jetpack_Gutenberg' ) ) {
Jetpack_Gutenberg::enqueue_block_editor_assets();
}
}
/**
* Mark task as complete.
*/
public function mark_task_as_complete() {
update_option( 'woocommerce_admin_customize_store_completed', 'yes' );
}
}

Some files were not shown because too many files have changed in this diff Show More