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-20 10:04:07 -07:00
commit 5df15fec41
80 changed files with 1464 additions and 772 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Update copy in the add variation options modal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix blocks product editor variation actions styles

View File

@ -130,20 +130,9 @@ export function Edit() {
'Add variation options',
'woocommerce'
),
newAttributeModalDescription: createInterpolateElement(
__(
'Select from existing <globalAttributeLink>global attributes</globalAttributeLink> or create options for buyers to choose on the product page. You can change the order later.',
'woocommerce'
),
{
globalAttributeLink: (
<Link
href="https://woocommerce.com/document/variable-product/#add-attributes-to-use-for-variations"
type="external"
target="_blank"
/>
),
}
newAttributeModalDescription: __(
'Select from existing attributes or create new ones to add new variations for your product. You can change the order later.',
'woocommerce'
),
attributeRemoveLabel: __(
'Remove variation option',

View File

@ -4,14 +4,9 @@
import classNames from 'classnames';
import type { BlockEditProps } from '@wordpress/blocks';
import { Button } from '@wordpress/components';
import { Link } from '@woocommerce/components';
import { Product, ProductAttribute } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import {
createElement,
useState,
createInterpolateElement,
} from '@wordpress/element';
import { createElement, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
@ -131,20 +126,9 @@ export function Edit( {
{ isNewModalVisible && (
<NewAttributeModal
title={ __( 'Add variation options', 'woocommerce' ) }
description={ createInterpolateElement(
__(
'Select from existing <globalAttributeLink>global attributes</globalAttributeLink> or create options for buyers to choose on the product page. You can change the order later.',
'woocommerce'
),
{
globalAttributeLink: (
<Link
href="https://woocommerce.com/document/variable-product/#add-attributes-to-use-for-variations"
type="external"
target="_blank"
/>
),
}
description={ __(
'Select from existing attributes or create new ones to add new variations for your product. You can change the order later.',
'woocommerce'
) }
createNewAttributesAsGlobal={ true }
notice={ '' }

View File

@ -41,6 +41,7 @@ $table-row-height: calc($grid-unit * 9);
border-color: $gray-600;
}
}
margin-left: $gap-smallest;
}
&__filters {
@ -99,6 +100,7 @@ $table-row-height: calc($grid-unit * 9);
align-items: center;
justify-content: flex-end;
gap: $gap-smaller;
margin-right: $gap-smallest;
&--delete {
&.components-button.components-menu-item__button.is-link {

View File

@ -180,13 +180,12 @@ function ResizableFrame( {
left: 0,
},
visible: {
opacity: 1,
opacity: 0.6,
left: -10,
},
active: {
opacity: 1,
left: -10,
scaleY: 1.3,
},
};
const currentResizeHandleVariant = ( () => {

View File

@ -4,15 +4,19 @@
*/
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
import { useContext } from '@wordpress/element';
import { mergeBaseAndUserConfigs } from '@wordpress/edit-site/build-module/components/global-styles/global-styles-provider';
const {
useGlobalStyle,
useGlobalSetting,
useSettingsForBlockElement,
ColorPanel: StylesColorPanel,
GlobalStylesContext,
} = unlock( blockEditorPrivateApis );
export const ColorPanel = () => {
const { setUserConfig } = useContext( GlobalStylesContext );
const [ style ] = useGlobalStyle( '', undefined, 'user', {
shouldDecodeEncode: false,
} );
@ -22,11 +26,25 @@ export const ColorPanel = () => {
const [ rawSettings ] = useGlobalSetting( '' );
const settings = useSettingsForBlockElement( rawSettings );
const onChange = ( ...props ) => {
setStyle( ...props );
setUserConfig( ( currentConfig ) => ( {
...currentConfig,
settings: mergeBaseAndUserConfigs( currentConfig.settings, {
color: {
palette: {
hasCreatedOwnColors: true,
},
},
} ),
} ) );
};
return (
<StylesColorPanel
inheritedValue={ inheritedStyle }
value={ style }
onChange={ setStyle }
onChange={ onChange }
settings={ settings }
/>
);

View File

@ -28,6 +28,15 @@ export const VariationContainer = ( { variation, children } ) => {
}, [ variation, base ] );
const selectVariation = () => {
// Remove the hasCreatedOwnColors flag if the user is switching to a color palette
if (
variation.settings.color &&
user.settings.color &&
user.settings.color.hasCreatedOwnColors
) {
delete user.settings.color.palette.hasCreatedOwnColors;
}
setUserConfig( () => {
return {
settings: mergeBaseAndUserConfigs(
@ -48,7 +57,6 @@ export const VariationContainer = ( { variation, children } ) => {
selectVariation();
}
};
const isActive = useMemo( () => {
if ( variation.settings.color ) {
return isEqual( variation.settings.color, user.settings.color );

View File

@ -147,8 +147,8 @@ function Sidebar() {
initialPath={ initialPath.current }
>
<SidebarScreens />
<SaveHub />
</NavigatorProvider>
<SaveHub />
</>
);
}

View File

@ -10,6 +10,8 @@ import { useSelect, useDispatch } from '@wordpress/data';
import {
// @ts-ignore No types for this exist yet.
__experimentalHStack as HStack,
// @ts-ignore No types for this exist yet.
__experimentalUseNavigator as useNavigator,
Button,
Spinner,
} from '@wordpress/components';
@ -41,13 +43,13 @@ export const SaveHub = () => {
const urlParams = useQuery();
const { sendEvent } = useContext( CustomizeStoreContext );
const [ isResolving, setIsResolving ] = useState< boolean >( false );
const navigator = useNavigator();
// @ts-ignore No types for this exist yet.
const { __unstableMarkLastChangeAsPersistent } =
useDispatch( blockEditorStore );
const { createSuccessNotice, createErrorNotice, removeNotice } =
useDispatch( noticesStore );
const { createErrorNotice, removeNotice } = useDispatch( noticesStore );
const {
dirtyEntityRecords,
@ -176,10 +178,7 @@ export const SaveHub = () => {
}
}
createSuccessNotice( __( 'Site updated.', 'woocommerce' ), {
type: 'snackbar',
id: saveNoticeId,
} );
navigator.goToParent();
} catch ( error ) {
createErrorNotice(
`${ __( 'Saving failed.', 'woocommerce' ) } ${ error }`

View File

@ -119,10 +119,14 @@
.edit-site-layout__sidebar {
.edit-site-sidebar__content {
display: flex;
flex-direction: column;
.components-navigator-screen {
will-change: auto;
padding: 0 16px;
overflow-x: hidden;
flex: 1;
}
}
@ -431,7 +435,8 @@
.edit-site-layout__canvas {
bottom: 16px;
top: 16px;
padding: 0 16px;
left: 12px; // the default styles for this undersizes the width by 24px so we want to center this
padding: 0 4px 0 16px;
}
.edit-site-resizable-frame__handle {
@ -442,13 +447,13 @@
padding: 0 10px;
align-items: flex-start;
gap: 10px;
background: #d7defb;
background: var(--wp-admin-theme-color-background-25);
left: 5px !important;
border-radius: 4px;
height: 20px;
.components-popover__content {
color: #1d35b4;
color: var(--wp-admin-theme-color-darker-20);
font-size: 0.75rem;
font-style: normal;
font-weight: 500;
@ -549,42 +554,3 @@
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

@ -0,0 +1,26 @@
<svg width="399" height="351" viewBox="0 0 399 351" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M690.229 71.6773C732.419 29.4861 704.871 -66.489 628.68 -142.668C552.49 -218.86 456.531 -246.423 414.327 -204.232C387.767 -177.672 388.854 -129.818 412.279 -78.9995C293.927 -161.631 168.994 -182.204 107.389 -120.612C45.7841 -59.0053 66.3567 65.9162 149 184.286C98.1829 160.846 50.3307 159.773 23.7712 186.333C-18.4189 228.525 9.12891 324.5 85.3197 400.678C161.51 476.871 257.469 504.419 299.659 462.228C326.219 435.668 325.131 387.815 301.707 336.996C420.073 419.628 545.006 440.201 606.597 378.608C668.202 317.002 647.629 192.08 564.986 73.7106C615.803 97.1502 663.655 98.2233 690.215 71.6632L690.229 71.6773Z" fill="#E6DCFF" fill-opacity="0.5"/>
<path d="M376.07 217H319.352V229.555H376.07V217Z" fill="#271B3D"/>
<path d="M376.07 229.555H319.352V250.479H376.07V229.555Z" fill="#BEA0F2"/>
<path d="M367.337 221.185L371.521 225.37" stroke="white" stroke-width="0.71" stroke-miterlimit="10"/>
<path d="M371.521 221.185L367.337 225.37" stroke="white" stroke-width="0.71" stroke-miterlimit="10"/>
<path d="M334.217 324.551H376.066V303.627H334.217V324.551Z" fill="#BEA0F2"/>
<path d="M240.498 324.551H282.346V303.627H240.498V324.551Z" fill="#BEA0F2"/>
<path d="M287.36 324.551H329.208V303.627H287.36V324.551Z" fill="#BEA0F2"/>
<path d="M308.272 299.132H350.12V278.208H308.272V299.132Z" fill="#BEA0F2"/>
<path d="M355.142 299.132H376.066V278.208H355.142V299.132Z" fill="#BEA0F2"/>
<path d="M261.694 293.642C274.881 298.601 282.371 290.256 282.371 290.256L248.478 277.509L281.459 262.557C281.459 262.557 273.437 254.722 260.602 260.544C255.735 262.749 246.829 268.457 238.79 273.868L223.499 268.118C224.173 263.636 221.657 259.129 217.251 257.472C212.187 255.568 206.517 258.137 204.613 263.205C202.708 268.269 205.278 273.939 210.346 275.843C214.849 277.538 219.82 275.689 222.21 271.7C223.821 272.696 227.776 275.12 232.672 278.036C227.985 281.263 224.202 283.941 222.653 285.046C220.009 281.225 214.916 279.71 210.538 281.698C205.609 283.933 203.42 289.758 205.655 294.688C207.889 299.618 213.719 301.806 218.644 299.572C222.934 297.626 225.148 292.96 224.177 288.536L239.054 281.79C247.436 286.657 256.697 291.767 261.698 293.646L261.694 293.642ZM212.074 271.236C209.551 270.286 208.266 267.457 209.216 264.934C210.166 262.406 212.995 261.125 215.518 262.075C218.042 263.025 219.327 265.854 218.377 268.378C217.427 270.901 214.598 272.186 212.074 271.236ZM216.611 295.085C214.154 296.199 211.246 295.106 210.132 292.65C209.019 290.193 210.111 287.285 212.568 286.172C215.024 285.059 217.933 286.151 219.046 288.607C220.159 291.064 219.067 293.972 216.611 295.085Z" fill="#271B3D"/>
<path d="M314.552 265.804H323.88" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M327.869 265.804H337.192" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M341.181 265.804H350.509" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M354.497 265.804H363.821" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M366.817 265.804H376.145" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M301.24 265.804H310.564" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M287.924 265.804H297.248" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M236.378 227.437C236.378 226.429 235.892 225.501 235.072 224.736L228.891 227.462L235.038 230.171C235.875 229.402 236.378 228.457 236.378 227.437Z" fill="#271B3D"/>
<path d="M235.072 224.736C235.888 225.501 236.377 226.429 236.377 227.437C236.377 228.458 235.879 229.398 235.038 230.171L251.702 237.92H257.239V217H251.702L235.072 224.736Z" fill="#BEA0F2"/>
<path d="M251.798 217C250.053 217.949 248.706 218.693 248.706 220.847C248.706 222.288 249.442 223.502 250.748 224.148C249.451 224.804 248.714 226.03 248.706 227.462C248.714 228.89 249.446 230.117 250.748 230.776C249.442 231.423 248.706 232.637 248.706 234.077C248.706 236.232 250.053 236.975 251.798 237.924H307.704L311.328 232.107V222.817L307.704 217H251.798Z" fill="#271B3D"/>
<path d="M307.707 237.924L311.331 232.11V222.818L307.707 217H297.659L294.035 222.818V232.11L297.659 237.924H307.707Z" fill="#BEA0F2"/>
<path d="M302.681 231.245C304.08 231.245 305.213 229.535 305.213 227.425C305.213 225.315 304.08 223.604 302.681 223.604C301.283 223.604 300.15 225.315 300.15 227.425C300.15 229.535 301.283 231.245 302.681 231.245Z" fill="#271B3D"/>
<path d="M128 227.739H225.415" stroke="#271B3D" stroke-miterlimit="10"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -9,9 +9,10 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import {
ColorPalette,
ColorPaletteResponse,
designWithAiStateMachineContext,
designWithAiStateMachineEvents,
FontPairing,
LookAndToneCompletionResponse,
} from './types';
import { aiWizardClosedBeforeCompletionEvent } from './events';
@ -83,7 +84,7 @@ const assignDefaultColorPalette = assign<
defaultColorPalette: (
event as {
data: {
response: ColorPalette;
response: ColorPaletteResponse;
};
}
).data.response,
@ -91,6 +92,24 @@ const assignDefaultColorPalette = assign<
},
} );
const assignFontPairing = assign<
designWithAiStateMachineContext,
designWithAiStateMachineEvents
>( {
aiSuggestions: ( context, event: unknown ) => {
return {
...context.aiSuggestions,
fontPairing: (
event as {
data: {
response: FontPairing;
};
}
).data.response.pair_name,
};
},
} );
const logAIAPIRequestError = () => {
// log AI API request error
// eslint-disable-next-line no-console
@ -158,6 +177,7 @@ export const actions = {
assignToneOfVoice,
assignLookAndTone,
assignDefaultColorPalette,
assignFontPairing,
logAIAPIRequestError,
updateQueryStep,
recordTracksStepViewed,

View File

@ -35,6 +35,7 @@
font-weight: 500;
line-height: 20px; /* 125% */
letter-spacing: -0.24px;
margin-bottom: 8px;
}
p {

View File

@ -33,7 +33,7 @@ export const LookAndFeel = ( {
title: __( 'Contemporary', 'woocommerce' ),
key: 'Contemporary' as const,
subtitle: __(
'Clean lines, neutral colors, sleek and modern look',
'Clean lines, neutral colors, sleek and modern look.',
'woocommerce'
),
},

View File

@ -77,7 +77,10 @@ export const ToneOfVoice = ( {
<div className="woocommerce-cys-design-with-ai-tone-of-voice woocommerce-cys-layout">
<div className="woocommerce-cys-page">
<h1>
{ __( 'How would you like to sound?', 'woocommerce' ) }
{ __(
'Which writing style do you prefer?',
'woocommerce'
) }
</h1>
<div className="choices">
{ choices.map( ( { title, subtitle, key } ) => {

View File

@ -200,11 +200,14 @@ const colorChoices: ColorPalette[] = [
];
const allowedNames: string[] = colorChoices.map( ( palette ) => palette.name );
const hexColorRegex = /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/;
const colorPaletteNameValidator = z
.string()
.refine( ( name ) => allowedNames.includes( name ), {
message: 'Color palette not part of allowed list',
} );
export const colorPaletteValidator = z.object( {
name: z.string().refine( ( name ) => allowedNames.includes( name ), {
message: 'Color palette not part of allowed list',
} ),
name: colorPaletteNameValidator,
primary: z
.string()
.regex( hexColorRegex, { message: 'Invalid primary color' } ),
@ -219,6 +222,11 @@ export const colorPaletteValidator = z.object( {
.regex( hexColorRegex, { message: 'Invalid background color' } ),
} );
export const colorPaletteResponseValidator = z.object( {
default: colorPaletteNameValidator,
bestColors: z.array( colorPaletteNameValidator ).length( 8 ),
} );
export const defaultColorPalette = {
queryId: 'default_color_palette',
@ -226,8 +234,8 @@ export const defaultColorPalette = {
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.
You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate color scheme, along with 8 best alternatives.
Respond in the form: "{ default: "palette name", bestColors: [ "palette name 1", "palette name 2", "palette name 3", "palette name 4", "palette name 5", "palette name 6", "palette name 7", "palette name 8" ] }"
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
@ -236,5 +244,5 @@ export const defaultColorPalette = {
${ JSON.stringify( colorChoices ) }
`;
},
responseValidation: colorPaletteValidator.parse,
responseValidation: colorPaletteResponseValidator.parse,
};

View File

@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { z } from 'zod';
/** This block below was generated by ChatGPT using GPT-4 on 2023-09-18 */
/** Original source: plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/font-pairing-variations/constants.ts */
const fontChoices = [
{
pair_name: 'Bodoni Moda + Overpass',
fonts: {
'Bodoni Moda':
'A modern serif font with high contrast between thick and thin lines, commonly used for headings.',
Overpass:
'A clean, modern sans-serif, originally inspired by Highway Gothic. Good for text and UI elements.',
},
settings:
'Overpass is used for buttons and general typography, while Bodoni Moda is specified for headings and some core blocks like site title and post navigation link.',
},
{
pair_name: 'Commissioner + Crimson Pro',
fonts: {
Commissioner:
'A low-contrast, geometric sans-serif, designed for legibility and readability in long texts.',
'Crimson Pro':
'A serif typeface designed for readability and long-form text.',
},
settings:
'Commissioner dominates elements like buttons, headings, and core blocks, while Crimson Pro is set for general typography.',
},
{
pair_name: 'Libre Baskerville + DM Sans',
fonts: {
'Libre Baskerville':
'A serif typeface with a classic feel, good for long reading and often used for body text in books.',
'DM Sans':
'A clean, geometric sans-serif, often used for UI and short text.',
},
settings:
'Libre Baskerville is used for headings and core blocks, whereas DM Sans is used for buttons and general typography.',
},
{
pair_name: 'Libre Franklin + EB Garamond',
fonts: {
'Libre Franklin':
'A sans-serif that offers readability, suitable for both text and display.',
'EB Garamond':
"A revival of the classical 'Garamond' typefaces, suitable for long-form text.",
},
settings:
'Libre Franklin is predominantly used for elements like buttons, headings, and core blocks. EB Garamond is set for general typography.',
},
{
pair_name: 'Montserrat + Arvo',
fonts: {
Montserrat:
'A geometric sans-serif, popular for its modern clean lines.',
Arvo: 'A slab-serif font with a more traditional feel, suitable for print and screen.',
},
settings:
'Montserrat is used for buttons, headings, and core blocks. Arvo is used for general typography.',
},
{
pair_name: 'Playfair Display + Fira Sans',
fonts: {
'Playfair Display':
'A high-contrast serif designed for headings and offers a modern take on older serif fonts.',
'Fira Sans':
'A sans-serif designed for readability at small sizes, making it suitable for both UI and text.',
},
settings:
'Playfair Display is used in italics for headings and core blocks, while Fira Sans is used for buttons and general typography.',
},
{
pair_name: 'Rubik + Inter',
fonts: {
Rubik: 'A sans-serif with slightly rounded corners, designed for a softer, more modern look.',
Inter: 'A highly legible sans-serif, optimized for UI design.',
},
settings:
'Rubik is applied for headings and core blocks. Inter is used for buttons and general typography.',
},
{
pair_name: 'Space Mono + Roboto',
fonts: {
'Space Mono': 'A monospace typeface with a futuristic vibe.',
Roboto: 'A neo-grotesque sans-serif, known for its flexibility and modern design.',
},
settings:
'Space Mono is used for headings, while Roboto takes care of buttons and general typography.',
},
];
const allowedFontChoices = fontChoices.map( ( config ) => config.pair_name );
export const fontChoiceValidator = z.object( {
pair_name: z
.string()
.refine( ( name ) => allowedFontChoices.includes( name ), {
message: 'Font choice not part of allowed list',
} ),
} );
export const fontPairings = {
queryId: 'font_pairings',
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 font pairing.
Respond only with one font pairing and and in the format: '{"pair_name":"font 1 + font 2"}'.
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
Font pairings to choose from:
${ JSON.stringify( fontChoices ) }
`;
},
responseValidation: fontChoiceValidator.parse,
};

View File

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

View File

@ -1,9 +1,9 @@
/**
* Internal dependencies
*/
import { defaultColorPalette } from '.';
import { colorPaletteValidator, defaultColorPalette } from '..';
describe( 'colorPairing.responseValidation', () => {
describe( 'colorPaletteValidator', () => {
it( 'should validate a correct color palette', () => {
const validPalette = {
name: 'Ancient Bronze',
@ -13,8 +13,7 @@ describe( 'colorPairing.responseValidation', () => {
background: '#ffffff',
};
const parsedResult =
defaultColorPalette.responseValidation( validPalette );
const parsedResult = colorPaletteValidator.parse( validPalette );
expect( parsedResult ).toEqual( validPalette );
} );
@ -26,7 +25,7 @@ describe( 'colorPairing.responseValidation', () => {
foreground: '#11163d',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
@ -48,7 +47,7 @@ describe( 'colorPairing.responseValidation', () => {
foreground: '#11163d',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
@ -71,7 +70,7 @@ describe( 'colorPairing.responseValidation', () => {
foreground: '#11163d',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
@ -94,7 +93,7 @@ describe( 'colorPairing.responseValidation', () => {
foreground: '#invalid_color',
background: '#ffffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
@ -125,7 +124,7 @@ describe( 'colorPairing.responseValidation', () => {
foreground: '#11163d',
background: '#fffff',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
@ -140,3 +139,100 @@ describe( 'colorPairing.responseValidation', () => {
` );
} );
} );
describe( 'colorPaletteResponseValidator', () => {
it( 'should validate a correct color palette response', () => {
const validPalette = {
default: 'Ancient Bronze',
bestColors: Array( 8 ).fill( 'Ancient Bronze' ),
};
const parsedResult =
defaultColorPalette.responseValidation( validPalette );
expect( parsedResult ).toEqual( validPalette );
} );
it( 'should fail if array contains invalid color', () => {
const invalidPalette = {
default: 'Ancient Bronze',
bestColors: Array( 7 )
.fill( 'Ancient Bronze' )
.concat( [ 'Invalid Color' ] ),
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Color palette not part of allowed list\\",
\\"path\\": [
\\"bestColors\\",
7
]
}
]"
` );
} );
it( 'should fail if bestColors property is missing', () => {
const invalidPalette = {
default: 'Ancient Bronze',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"array\\",
\\"received\\": \\"undefined\\",
\\"path\\": [
\\"bestColors\\"
],
\\"message\\": \\"Required\\"
}
]"
` );
} );
it( 'should fail if default property is missing', () => {
const invalidPalette = {
bestColors: Array( 8 ).fill( 'Ancient Bronze' ),
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"string\\",
\\"received\\": \\"undefined\\",
\\"path\\": [
\\"default\\"
],
\\"message\\": \\"Required\\"
}
]"
` );
} );
it( 'should fail if bestColors array is not of length 8', () => {
const invalidPalette = {
default: 'Ancient Bronze',
bestColors: Array( 7 ).fill( 'Ancient Bronze' ),
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"too_small\\",
\\"minimum\\": 8,
\\"type\\": \\"array\\",
\\"inclusive\\": true,
\\"exact\\": true,
\\"message\\": \\"Array must contain exactly 8 element(s)\\",
\\"path\\": [
\\"bestColors\\"
]
}
]"
` );
} );
} );

View File

@ -0,0 +1,47 @@
/**
* Internal dependencies
*/
import { fontChoiceValidator } from '..';
describe( 'fontChoiceValidator', () => {
it( 'should validate when font choice is part of the allowed list', () => {
const validFontChoice = { pair_name: 'Montserrat + Arvo' };
expect( () =>
fontChoiceValidator.parse( validFontChoice )
).not.toThrow();
} );
it( 'should not validate when font choice is not part of the allowed list', () => {
const invalidFontChoice = { pair_name: 'Comic Sans' };
expect( () => fontChoiceValidator.parse( invalidFontChoice ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Font choice not part of allowed list\\",
\\"path\\": [
\\"pair_name\\"
]
}
]"
` );
} );
it( 'should not validate when pair_name is not a string', () => {
const invalidType = { pair_name: 123 };
expect( () => fontChoiceValidator.parse( invalidType ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"string\\",
\\"received\\": \\"number\\",
\\"path\\": [
\\"pair_name\\"
],
\\"message\\": \\"Expected string, received number\\"
}
]"
` );
} );
} );

View File

@ -1,8 +1,8 @@
/**
* Internal dependencies
*/
import { LookAndToneCompletionResponse } from '../types';
import { lookAndTone } from '.';
import { LookAndToneCompletionResponse } from '../../types';
import { lookAndTone } from '..';
describe( 'parseLookAndToneCompletionResponse', () => {
it( 'should return a valid object when given valid JSON', () => {

View File

@ -10,7 +10,8 @@ import { getQuery } from '@woocommerce/navigation';
import {
designWithAiStateMachineContext,
designWithAiStateMachineEvents,
ColorPalette,
FontPairing,
ColorPaletteResponse,
} from './types';
import {
BusinessInfoDescription,
@ -20,7 +21,7 @@ import {
} from './pages';
import { actions } from './actions';
import { services } from './services';
import { defaultColorPalette } from './prompts';
import { defaultColorPalette, fontPairings } from './prompts';
export const hasStepInUrl = (
_ctx: unknown,
@ -69,7 +70,8 @@ export const designWithAiStateMachineDefinition = createMachine(
choice: '',
},
aiSuggestions: {
defaultColorPalette: {} as ColorPalette,
defaultColorPalette: {} as ColorPaletteResponse,
fontPairing: '' as FontPairing[ 'pair_name' ],
},
},
initial: 'navigate',
@ -291,6 +293,25 @@ export const designWithAiStateMachineDefinition = createMachine(
},
},
},
chooseFontPairing: {
invoke: {
src: 'queryAiEndpoint',
data: ( context ) => {
return {
...fontPairings,
prompt: fontPairings.prompt(
context.businessInfoDescription
.descriptionText,
context.lookAndFeel.choice,
context.toneOfVoice.choice
),
};
},
onDone: {
actions: [ 'assignFontPairing' ],
},
},
},
},
},
postApiCallLoader: {},

View File

@ -5,7 +5,11 @@ import { z } from 'zod';
/**
* Internal dependencies
*/
import { colorPaletteValidator } from './prompts';
import {
colorPaletteValidator,
colorPaletteResponseValidator,
fontChoiceValidator,
} from './prompts';
export type designWithAiStateMachineContext = {
businessInfoDescription: {
@ -18,7 +22,8 @@ export type designWithAiStateMachineContext = {
choice: Tone | '';
};
aiSuggestions: {
defaultColorPalette: ColorPalette;
defaultColorPalette: ColorPaletteResponse;
fontPairing: FontPairing[ 'pair_name' ];
};
// If we require more data from options, previously provided core profiler details,
// we can retrieve them in preBusinessInfoDescription and then assign them here
@ -50,3 +55,8 @@ export interface LookAndToneCompletionResponse {
}
export type ColorPalette = z.infer< typeof colorPaletteValidator >;
export type ColorPaletteResponse = z.infer<
typeof colorPaletteResponseValidator
>;
export type FontPairing = z.infer< typeof fontChoiceValidator >;

View File

@ -1,8 +1,17 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { chevronLeft } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { CustomizeStoreComponent } from '../types';
import './intro.scss';
export type events =
| { type: 'DESIGN_WITH_AI' }
| { type: 'CLICKED_ON_BREADCRUMB' }
@ -15,33 +24,106 @@ export * as services from './services';
export const Intro: CustomizeStoreComponent = ( { sendEvent, context } ) => {
const {
intro: { themeCards, activeTheme },
intro: { themeCards },
} = context;
return (
<>
<h1>Intro</h1>
<div>Active theme: { activeTheme }</div>
{ themeCards?.map( ( themeCard ) => (
<button
key={ themeCard.name }
onClick={ () =>
sendEvent( {
type: 'SELECTED_NEW_THEME',
payload: { theme: themeCard.name },
} )
}
>
{ themeCard.name }
</button>
) ) }
<button onClick={ () => sendEvent( { type: 'DESIGN_WITH_AI' } ) }>
Design with AI
</button>
<button
onClick={ () => sendEvent( { type: 'SELECTED_ACTIVE_THEME' } ) }
>
Assembler Hub
</button>
<div className="woocommerce-customize-store-header">
<h1>{ 'Site title' }</h1>
</div>
<div className="woocommerce-customize-store-container">
<div className="woocommerce-customize-store-sidebar">
<div className="woocommerce-customize-store-sidebar__title">
<button
onClick={ () => {
sendEvent( 'CLICKED_ON_BREADCRUMB' );
} }
>
{ chevronLeft }
</button>
{ __( 'Customize your store', 'woocommerce' ) }
</div>
<p>
{ __(
'Create a store that reflects your brand and business. Select one of our professionally designed themes to customize, or create your own using AI.',
'woocommerce'
) }
</p>
</div>
<div className="woocommerce-customize-store-main">
<div className="woocommerce-customize-store-banner">
<div
className={ `woocommerce-customize-store-banner-content` }
>
<h1>
{ __(
'Use the power of AI to design your store',
'woocommerce'
) }
</h1>
<p>
{ __(
'Design the look of your store, create pages, and generate copy using our built-in AI tools.',
'woocommerce'
) }
</p>
<button
onClick={ () =>
sendEvent( { type: 'DESIGN_WITH_AI' } )
}
>
{ __( 'Design with AI', 'woocommerce' ) }
</button>
</div>
</div>
<p>
{ __(
'Or select a professionally designed theme to customize and make your own.',
'woocommerce'
) }
</p>
<div className="woocommerce-customize-store-theme-cards">
{ themeCards?.map( ( themeCard ) => (
<div className="theme-card" key={ themeCard.slug }>
<div>
<img
src={ themeCard.image }
alt={ themeCard.description }
/>
</div>
<h2 className="theme-card__title">
{ themeCard.name }
</h2>
</div>
) ) }
</div>
<div className="woocommerce-customize-store-browse-themes">
<button
onClick={ () =>
sendEvent( {
type: 'SELECTED_BROWSE_ALL_THEMES',
} )
}
>
{ __( 'Browse all themes', 'woocommerce' ) }
</button>
</div>
<button
onClick={ () =>
sendEvent( { type: 'SELECTED_ACTIVE_THEME' } )
}
>
Assembler Hub
</button>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,155 @@
.woocommerce-profile-wizard__container {
display: flex;
flex-direction: column;
button {
cursor: pointer;
}
}
.woocommerce-customize-store-header {
min-height: 64px;
padding: 1rem;
width: 100%;
h1 {
font-size: 0.8125rem;
font-weight: 500;
margin: 0;
padding: 0;
line-height: 1.5rem;
}
}
.woocommerce-customize-store-container {
display: flex;
flex-direction: row;
}
.woocommerce-customize-store-sidebar {
flex: 0 0 380px;
padding: 1rem;
.woocommerce-customize-store-sidebar__title {
color: #1e1e1e;
font-size: 1rem;
font-weight: 600;
margin: 0;
padding: 0;
line-height: 2.5;
}
button {
background-color: transparent;
border: none;
line-height: 1;
padding-right: 0;
vertical-align: middle;
}
svg {
color: inherit;
height: 24px;
width: 24px;
margin: 0.25rem;
}
p {
padding: 0 1rem;
color: #757575;
max-width: 20rem;
}
}
.woocommerce-customize-store-main {
margin-right: 2.5rem;
p {
color: #2f2f2f;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
}
}
.woocommerce-customize-store-banner {
background: var(--woo-purple-woo-purple-0, #f2edff);
background-image: url(../assets/images/banner-design-with-ai.svg);
background-repeat: no-repeat;
background-position: bottom right;
background-size: contain;
border-radius: 4px;
display: flex;
margin: 1.25rem 0 3.375rem;
min-height: 343px;
padding: 70px 0;
width: 820px;
.woocommerce-customize-store-banner-content {
width: 345px;
margin-left: 50px;
h1 {
font-size: 1.5rem;
line-height: 1.33;
}
p {
margin: 1rem 0 2rem 0;
}
button {
background-color: #3858e9;
border: none;
border-radius: 2px;
color: #fff;
display: inline-block;
line-height: 1.25rem;
padding: 10px 15px;
&:hover {
background-color: #2234e0;
}
}
}
}
.woocommerce-customize-store-theme-cards {
display: flex;
flex-wrap: wrap;
gap: 2rem;
max-width: 820px;
.theme-card {
flex-basis: 45%;
img {
border-radius: 4px;
border: 1px solid #e9e9e9;
width: 394px;
}
.theme-card__title {
font-size: 1rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
}
}
}
.woocommerce-customize-store-browse-themes {
text-align: center;
button {
background-color: #fff;
border: 1px solid #3858e9;
border-radius: 2px;
color: #3858e9;
display: inline-block;
font-size: 0.8125rem;
margin: 3.75rem 0;
padding: 0.5rem 0.75rem;
}
}

View File

@ -3,12 +3,55 @@
export const fetchThemeCards = async () => {
return [
{
slug: 'twentytwentyone',
name: 'Twenty Twenty One',
description: 'The default theme for WordPress.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/pub/twentytwentyone/screenshot.png',
styleVariations: [],
},
{
slug: 'twentytwenty',
name: 'Twenty Twenty',
description: 'The previous default theme for WordPress.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/pub/twentytwenty/screenshot.png',
styleVariations: [],
},
{
slug: 'tsubaki',
name: 'Tsubaki',
description:
'Tsubaki puts the spotlight on your products and your customers. This theme leverages WooCommerce to provide you with intuitive product navigation and the patterns you need to master digital merchandising.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/premium/tsubaki/screenshot.png',
styleVariations: [],
},
{
slug: 'winkel',
name: 'Winkel',
description:
'Winkel is a minimal, product-focused theme featuring Payments block. Its clean, cool look combined with a simple layout makes it perfect for showcasing fashion items clothes, shoes, and accessories.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/pub/winkel/screenshot.png',
styleVariations: [
{
title: 'Default',
primary: '#ffffff',
secondary: '#676767',
},
{
title: 'Charcoal',
primary: '#1f2527',
secondary: '#9fd3e8',
},
{
title: 'Rainforest',
primary: '#eef4f7',
secondary: '#35845d',
},
{
title: 'Ruby Wine',
primary: '#ffffff',
secondary: '#c8133e',
},
],
},
];
};

View File

@ -1,6 +1,9 @@
export type ThemeCard = {
// placeholder props, possibly take reference from https://github.com/Automattic/wp-calypso/blob/1f1b79210c49ef0d051f8966e24122229a334e29/packages/design-picker/src/components/theme-card/index.tsx#L32
slug: string;
name: string;
description: string;
image: string;
isActive: boolean;
styleVariations: string[];
};

View File

@ -20,6 +20,10 @@
body.woocommerce-customize-store.js.is-fullscreen-mode {
margin-top: 0 !important;
height: 100%;
& > div.tour-kit.woocommerce-tour-kit > div > div.tour-kit-spotlight.is-visible {
outline: 99999px solid rgba(0, 0, 0, 0.15);
}
}
.woocommerce-cys-layout {
@ -78,7 +82,7 @@ body.woocommerce-customize-store.js.is-fullscreen-mode {
padding: 12px;
width: 404px;
border-radius: 2px;
background: var(--gutenberg-transparent-blueberry, rgba(56, 88, 233, 0.04));
background: var(--wp-admin-theme-color-background-04, rgba(168, 168, 170, 0.301));
color: var(--gutenberg-gray-800, #2f2f2f);
p {

View File

@ -19,6 +19,7 @@ import { EmbeddedBodyLayout } from './embedded-body-layout';
import { WcAdminPaymentsGatewaysBannerSlot } from './payments/payments-settings-banner-slotfill';
import { WcAdminConflictErrorSlot } from './settings/conflict-error-slotfill.js';
import './xstate.js';
import { deriveWpAdminBackgroundColours } from './utils/derive-wp-admin-background-colours';
// Modify webpack pubilcPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
@ -48,6 +49,8 @@ const embeddedRoot = document.getElementById( 'woocommerce-embedded-root' );
const settingsGroup = 'wc_admin';
const hydrateUser = getAdminSetting( 'currentUserData' );
deriveWpAdminBackgroundColours();
if ( appRoot ) {
let HydratedPageLayout = withSettingsHydration(
settingsGroup,

View File

@ -1,431 +1,36 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { Button, Card, CardBody } from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { filter } from 'lodash';
import { withDispatch, withSelect } from '@wordpress/data';
import { Stepper, TextControl, ImageUpload } from '@woocommerce/components';
import {
OPTIONS_STORE_NAME,
ONBOARDING_STORE_NAME,
WC_ADMIN_NAMESPACE,
} from '@woocommerce/data';
import { queueRecordEvent, recordEvent } from '@woocommerce/tracks';
import React from 'react';
import { WooOnboardingTaskListItem } from '@woocommerce/onboarding';
import { registerPlugin } from '@wordpress/plugins';
import { WooOnboardingTask } from '@woocommerce/onboarding';
import { getAdminLink } from '@woocommerce/settings';
/**
* Internal dependencies
*/
class Appearance extends Component {
constructor( props ) {
super( props );
const { hasHomepage, hasProducts, supportCustomLogo } =
props.task.additionalData;
this.stepVisibility = {
homepage: ! hasHomepage,
import: ! hasProducts,
logo: supportCustomLogo,
};
this.state = {
isDirty: false,
isPending: false,
logo: null,
stepIndex: 0,
isUpdatingLogo: false,
isUpdatingNotice: false,
storeNoticeText: props.demoStoreNotice || '',
};
this.completeStep = this.completeStep.bind( this );
this.createHomepage = this.createHomepage.bind( this );
this.importProducts = this.importProducts.bind( this );
this.updateLogo = this.updateLogo.bind( this );
this.updateNotice = this.updateNotice.bind( this );
}
componentDidMount() {
const { themeMods } = this.props.task.additionalData;
if ( themeMods && themeMods.custom_logo ) {
/* eslint-disable react/no-did-mount-set-state */
this.setState( { logo: { id: themeMods.custom_logo } } );
/* eslint-enable react/no-did-mount-set-state */
}
}
componentDidUpdate( prevProps ) {
const { isPending, logo } = this.state;
const { demoStoreNotice } = this.props;
if ( logo && ! logo.url && ! isPending ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( { isPending: true } );
wp.media
.attachment( logo.id )
.fetch()
.then( () => {
const logoUrl = wp.media.attachment( logo.id ).get( 'url' );
this.setState( {
isPending: false,
logo: { id: logo.id, url: logoUrl },
} );
} );
/* eslint-enable react/no-did-update-set-state */
}
if (
demoStoreNotice &&
prevProps.demoStoreNotice !== demoStoreNotice
) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
storeNoticeText: demoStoreNotice,
} );
/* eslint-enable react/no-did-update-set-state */
}
}
async completeStep() {
const { stepIndex } = this.state;
const { actionTask, onComplete } = this.props;
const nextStep = this.getSteps()[ stepIndex + 1 ];
if ( nextStep ) {
this.setState( { stepIndex: stepIndex + 1 } );
} else {
this.setState( { isPending: true } );
await actionTask( 'appearance' );
onComplete();
}
}
importProducts() {
const { createNotice } = this.props;
this.setState( { isPending: true } );
recordEvent( 'tasklist_appearance_import_demo', {} );
apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/import_sample_products`,
method: 'POST',
} )
.then( ( result ) => {
if ( result.failed && result.failed.length ) {
createNotice(
'error',
__(
'There was an error importing some of the sample products',
'woocommerce'
)
);
} else {
createNotice(
'success',
__(
'All sample products have been imported',
'woocommerce'
)
);
}
this.setState( { isPending: false } );
this.completeStep();
} )
.catch( ( { message } ) => {
createNotice(
'error',
message ||
__(
'There was an error importing the sample products',
'woocommerce'
),
{ __unstableHTML: true }
);
this.setState( { isPending: false } );
} );
}
createHomepage() {
const { createNotice } = this.props;
this.setState( { isPending: true } );
recordEvent( 'tasklist_appearance_create_homepage', {
create_homepage: true,
} );
apiFetch( {
path: '/wc-admin/onboarding/tasks/create_homepage',
method: 'POST',
} )
.then( ( response ) => {
createNotice( response.status, response.message, {
actions: response.edit_post_link
? [
{
label: __( 'Customize', 'woocommerce' ),
onClick: () => {
queueRecordEvent(
'tasklist_appearance_customize_homepage',
{}
);
window.location = `${ response.edit_post_link }&wc_onboarding_active_task=appearance`;
},
},
]
: null,
} );
this.setState( { isPending: false } );
this.completeStep();
} )
.catch( ( error ) => {
createNotice( 'error', error.message );
this.setState( { isPending: false } );
} );
}
async updateLogo() {
const { createNotice, task, updateOptions } = this.props;
const { stylesheet, themeMods } = task.additionalData;
const { logo } = this.state;
const updatedThemeMods = {
...themeMods,
custom_logo: logo ? logo.id : null,
};
recordEvent( 'tasklist_appearance_upload_logo' );
this.setState( { isUpdatingLogo: true } );
const update = await updateOptions( {
[ `theme_mods_${ stylesheet }` ]: updatedThemeMods,
} );
if ( update.success ) {
this.setState( { isUpdatingLogo: false } );
createNotice(
'success',
__( 'Store logo updated successfully', 'woocommerce' )
);
this.completeStep();
} else {
createNotice( 'error', update.message );
}
}
async updateNotice() {
const { createNotice, updateOptions } = this.props;
const { storeNoticeText } = this.state;
recordEvent( 'tasklist_appearance_set_store_notice', {
added_text: Boolean( storeNoticeText.length ),
} );
this.setState( { isUpdatingNotice: true } );
const update = await updateOptions( {
woocommerce_demo_store: storeNoticeText.length ? 'yes' : 'no',
woocommerce_demo_store_notice: storeNoticeText,
} );
if ( update.success ) {
this.setState( { isUpdatingNotice: false } );
createNotice(
'success',
__(
"🎨 Your store is looking great! Don't forget to continue personalizing it",
'woocommerce'
)
);
this.completeStep();
} else {
createNotice( 'error', update.message );
}
}
getSteps() {
const { isDirty, isPending, logo, storeNoticeText, isUpdatingLogo } =
this.state;
const steps = [
{
key: 'import',
label: __( 'Import sample products', 'woocommerce' ),
description: __(
'Well add some products that will make it easier to see what your store looks like',
'woocommerce'
),
content: (
<Fragment>
<Button
onClick={ this.importProducts }
isBusy={ isPending }
isPrimary
>
{ __( 'Import products', 'woocommerce' ) }
</Button>
<Button onClick={ () => this.completeStep() }>
{ __( 'Skip', 'woocommerce' ) }
</Button>
</Fragment>
),
visible: this.stepVisibility.import,
},
{
key: 'homepage',
label: __( 'Create a custom homepage', 'woocommerce' ),
description: __(
'Create a new homepage and customize it to suit your needs',
'woocommerce'
),
content: (
<Fragment>
<Button
isPrimary
isBusy={ isPending }
onClick={ this.createHomepage }
>
{ __( 'Create homepage', 'woocommerce' ) }
</Button>
<Button
isTertiary
onClick={ () => {
recordEvent(
'tasklist_appearance_create_homepage',
{ create_homepage: false }
);
this.completeStep();
} }
>
{ __( 'Skip', 'woocommerce' ) }
</Button>
</Fragment>
),
visible: this.stepVisibility.homepage,
},
{
key: 'logo',
label: __( 'Upload a logo', 'woocommerce' ),
description: __(
'Ensure your store is on-brand by adding your logo',
'woocommerce'
),
content: isPending ? null : (
<Fragment>
<ImageUpload
image={ logo }
onChange={ ( image ) =>
this.setState( { isDirty: true, logo: image } )
}
/>
<Button
disabled={ ! logo && ! isDirty }
onClick={ this.updateLogo }
isBusy={ isUpdatingLogo }
isPrimary
>
{ __( 'Continue', 'woocommerce' ) }
</Button>
<Button
isTertiary
onClick={ () => this.completeStep() }
>
{ __( 'Skip', 'woocommerce' ) }
</Button>
</Fragment>
),
visible: this.stepVisibility.logo,
},
{
key: 'notice',
label: __( 'Set a store notice', 'woocommerce' ),
description: __(
'Optionally display a prominent notice across all pages of your store',
'woocommerce'
),
content: (
<Fragment>
<TextControl
label={ __( 'Store notice text', 'woocommerce' ) }
placeholder={ __(
'Store notice text',
'woocommerce'
) }
value={ storeNoticeText }
onChange={ ( value ) =>
this.setState( { storeNoticeText: value } )
}
/>
<Button onClick={ this.updateNotice } isPrimary>
{ __( 'Complete task', 'woocommerce' ) }
</Button>
</Fragment>
),
visible: true,
},
];
return filter( steps, ( step ) => step.visible );
}
render() {
const { isPending, stepIndex, isUpdatingLogo, isUpdatingNotice } =
this.state;
const currentStep = this.getSteps()[ stepIndex ].key;
return (
<div className="woocommerce-task-appearance">
<Card className="woocommerce-task-card">
<CardBody>
<Stepper
isPending={
isUpdatingNotice || isUpdatingLogo || isPending
}
isVertical
currentStep={ currentStep }
steps={ this.getSteps() }
/>
</CardBody>
</Card>
</div>
const useAppearanceClick = () => {
const onClick = () => {
window.location = getAdminLink(
'theme-install.php?browse=block-themes'
);
}
}
};
const AppearanceWrapper = compose(
withSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
return { onClick };
};
return {
demoStoreNotice: getOption( 'woocommerce_demo_store_notice' ),
};
} ),
withDispatch( ( dispatch ) => {
const { createNotice } = dispatch( 'core/notices' );
const { updateOptions } = dispatch( OPTIONS_STORE_NAME );
const { actionTask } = dispatch( ONBOARDING_STORE_NAME );
return {
actionTask,
createNotice,
updateOptions,
};
} )
)( Appearance );
const AppearanceFill = () => {
const { onClick } = useAppearanceClick();
return (
<WooOnboardingTaskListItem id="appearance">
{ ( { defaultTaskItem: DefaultTaskItem } ) => (
<DefaultTaskItem
// Override task click so it doesn't navigate to a task component.
onClick={ onClick }
/>
) }
</WooOnboardingTaskListItem>
);
};
registerPlugin( 'wc-admin-onboarding-task-appearance', {
scope: 'woocommerce-tasks',
render: () => (
<WooOnboardingTask id="appearance">
{ ( { onComplete, task } ) => (
<AppearanceWrapper onComplete={ onComplete } task={ task } />
) }
</WooOnboardingTask>
),
render: () => <AppearanceFill />,
} );

View File

@ -0,0 +1,48 @@
/**
* Helper function that lightens a colour by blending it
* with some percentage of white
*/
function blendWithWhite( hex: string, alpha: number ) {
let r = parseInt( hex.slice( 1, 3 ), 16 ),
g = parseInt( hex.slice( 3, 5 ), 16 ),
b = parseInt( hex.slice( 5, 7 ), 16 );
// Blend with white
r = Math.floor( ( 1 - alpha ) * 255 + alpha * r );
g = Math.floor( ( 1 - alpha ) * 255 + alpha * g );
b = Math.floor( ( 1 - alpha ) * 255 + alpha * b );
// Convert to hex
const newHex =
'#' +
r.toString( 16 ).padStart( 2, '0' ) +
g.toString( 16 ).padStart( 2, '0' ) +
b.toString( 16 ).padStart( 2, '0' );
return newHex;
}
/**
* wp-admin theme colour only include the main colour,
* but in some applications we want to derive a complementary
* background colour that's some percentage lighter than the
* wp-admin theme colour. This is not doable in CSS as it involves
* breaking down the hex colour code and then running calculations on it.
* As of writing, CSS calc can only operate on individual numbers
*/
export const deriveWpAdminBackgroundColours = () => {
const rootStyles = window.getComputedStyle( document.body );
const wpAdminThemeColor = rootStyles
.getPropertyValue( '--wp-admin-theme-color' )
.trim();
document.documentElement.style.setProperty(
'--wp-admin-theme-color-background-04',
blendWithWhite( wpAdminThemeColor, 0.04 )
);
document.documentElement.style.setProperty(
'--wp-admin-theme-color-background-25',
blendWithWhite( wpAdminThemeColor, 0.25 )
);
};

View File

@ -1,40 +1,38 @@
{
"phpVersion": "7.4",
"plugins": [
"."
],
"config": {
"JETPACK_AUTOLOAD_DEV": true,
"WP_DEBUG_LOG": true,
"WP_DEBUG_DISPLAY": true,
"ALTERNATE_WP_CRON": true
},
"mappings": {
"wp-cli.yml": "./tests/wp-cli.yml",
"wp-content/plugins/filter-setter.php": "./tests/e2e-pw/bin/filter-setter.php",
"wp-content/plugins/enable-experimental-features.php": "./tests/e2e-pw/bin/enable-experimental-features.php"
},
"lifecycleScripts": {
"afterStart": "./tests/e2e-pw/bin/test-env-setup.sh",
"afterClean": "./tests/e2e-pw/bin/test-env-setup.sh"
},
"env": {
"development": {},
"tests": {
"port": 8086,
"plugins": [
".",
"https://downloads.wordpress.org/plugin/akismet.zip",
"https://github.com/WP-API/Basic-Auth/archive/master.zip",
"https://downloads.wordpress.org/plugin/wp-mail-logging.zip"
],
"themes": [
"https://downloads.wordpress.org/theme/twentynineteen.zip"
],
"config": {
"WP_TESTS_DOMAIN": "localhost",
"ALTERNATE_WP_CRON": false
}
}
}
"phpVersion": "7.4",
"plugins": [ "." ],
"config": {
"JETPACK_AUTOLOAD_DEV": true,
"WP_DEBUG_LOG": true,
"WP_DEBUG_DISPLAY": true,
"ALTERNATE_WP_CRON": true
},
"mappings": {
"wp-cli.yml": "./tests/wp-cli.yml",
"wp-content/plugins/filter-setter.php": "./tests/e2e-pw/bin/filter-setter.php",
"wp-content/plugins/test-helper-apis.php": "./tests/e2e-pw/bin/test-helper-apis.php"
},
"lifecycleScripts": {
"afterStart": "./tests/e2e-pw/bin/test-env-setup.sh",
"afterClean": "./tests/e2e-pw/bin/test-env-setup.sh"
},
"env": {
"development": {},
"tests": {
"port": 8086,
"plugins": [
".",
"https://downloads.wordpress.org/plugin/akismet.zip",
"https://github.com/WP-API/Basic-Auth/archive/master.zip",
"https://downloads.wordpress.org/plugin/wp-mail-logging.zip"
],
"themes": [
"https://downloads.wordpress.org/theme/twentynineteen.zip"
],
"config": {
"WP_TESTS_DOMAIN": "localhost",
"ALTERNATE_WP_CRON": false
}
}
}
}

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update intro screen for the new Customize Your Store task

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add customize store AI wizard call for best colour palette suggestions.

View File

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

View File

@ -0,0 +1,4 @@
Significance: major
Type: enhancement
Enable HPOS by default for new installs.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Adds regression test for https://github.com/woocommerce/woocommerce/pull/40221.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Add some basic E2E tests for Assembler Hub

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: This change only tweaks a few comments.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Addressed visual tweaks for CYS in response to feedback from 12th Sept

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Address missing order type handling in HPOS compatibility mode sync.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix changes in order custom fields made from admin not being applied when using the order Update button with HPOS active.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix CYS UI issues

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Replace Personalize Your Store task with Choose Your Theme

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Bump Woo Blocks 11.1.1

View File

@ -23,7 +23,7 @@
"maxmind-db/reader": "^1.11",
"pelago/emogrifier": "^6.0",
"woocommerce/action-scheduler": "3.6.3",
"woocommerce/woocommerce-blocks": "11.1.0"
"woocommerce/woocommerce-blocks": "11.1.1"
},
"require-dev": {
"automattic/jetpack-changelogger": "^3.3.0",

View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cc5c4172d1078ecf041bdf3240a8c4e1",
"content-hash": "e5800331e1c25cd08ad877b665da724f",
"packages": [
{
"name": "automattic/jetpack-a8c-mc-stats",
@ -1004,16 +1004,16 @@
},
{
"name": "woocommerce/woocommerce-blocks",
"version": "11.1.0",
"version": "11.1.1",
"source": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce-blocks.git",
"reference": "9a9749f72c16bbb9cd75e23061f6b19dec01c262"
"reference": "81bff865d7fdd7de40a4da8fc49539a124ac1863"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/woocommerce/woocommerce-blocks/zipball/9a9749f72c16bbb9cd75e23061f6b19dec01c262",
"reference": "9a9749f72c16bbb9cd75e23061f6b19dec01c262",
"url": "https://api.github.com/repos/woocommerce/woocommerce-blocks/zipball/81bff865d7fdd7de40a4da8fc49539a124ac1863",
"reference": "81bff865d7fdd7de40a4da8fc49539a124ac1863",
"shasum": ""
},
"require": {
@ -1062,9 +1062,9 @@
],
"support": {
"issues": "https://github.com/woocommerce/woocommerce-blocks/issues",
"source": "https://github.com/woocommerce/woocommerce-blocks/tree/v11.1.0"
"source": "https://github.com/woocommerce/woocommerce-blocks/tree/v11.1.1"
},
"time": "2023-09-12T13:56:36+00:00"
"time": "2023-09-20T10:14:34+00:00"
}
],
"packages-dev": [

View File

@ -1291,7 +1291,7 @@ class WC_Checkout {
* Get a posted address field after sanitization and validation.
*
* @param string $key Field key.
* @param string $type Type of address. Available options: 'billing' or 'shipping'.
* @param string $type Type of address; 'billing' or 'shipping'.
* @return string
*/
public function get_posted_address_data( $key, $type = 'billing' ) {

View File

@ -454,18 +454,27 @@ class WC_Customer extends WC_Legacy_Customer {
*
* @since 3.0.0
* @param string $prop Name of prop to get.
* @param string $address billing or shipping.
* @param string $context What the value is for. Valid values are 'view' and 'edit'. What the value is for. Valid values are view and edit.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
* @return mixed
*/
protected function get_address_prop( $prop, $address = 'billing', $context = 'view' ) {
protected function get_address_prop( $prop, $address_type = 'billing', $context = 'view' ) {
$value = null;
if ( array_key_exists( $prop, $this->data[ $address ] ) ) {
$value = isset( $this->changes[ $address ][ $prop ] ) ? $this->changes[ $address ][ $prop ] : $this->data[ $address ][ $prop ];
if ( array_key_exists( $prop, $this->data[ $address_type ] ) ) {
$value = isset( $this->changes[ $address_type ][ $prop ] ) ? $this->changes[ $address_type ][ $prop ] : $this->data[ $address_type ][ $prop ];
if ( 'view' === $context ) {
$value = apply_filters( $this->get_hook_prefix() . $address . '_' . $prop, $value, $this );
/**
* Filter: 'woocommerce_customer_get_[billing|shipping]_[prop]'
*
* Allow developers to change the returned value for any customer address property.
*
* @since 3.6.0
* @param string $value The address property value.
* @param WC_Customer $customer The customer object being read.
*/
$value = apply_filters( $this->get_hook_prefix() . $address_type . '_' . $prop, $value, $this );
}
}
return $value;
@ -920,18 +929,18 @@ class WC_Customer extends WC_Legacy_Customer {
* Sets a prop for a setter method.
*
* @since 3.0.0
* @param string $prop Name of prop to set.
* @param string $address Name of address to set. billing or shipping.
* @param mixed $value Value of the prop.
* @param string $prop Name of prop to set.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param mixed $value Value of the prop.
*/
protected function set_address_prop( $prop, $address, $value ) {
if ( array_key_exists( $prop, $this->data[ $address ] ) ) {
protected function set_address_prop( $prop, $address_type, $value ) {
if ( array_key_exists( $prop, $this->data[ $address_type ] ) ) {
if ( true === $this->object_read ) {
if ( $value !== $this->data[ $address ][ $prop ] || ( isset( $this->changes[ $address ] ) && array_key_exists( $prop, $this->changes[ $address ] ) ) ) {
$this->changes[ $address ][ $prop ] = $value;
if ( $value !== $this->data[ $address_type ][ $prop ] || ( isset( $this->changes[ $address_type ] ) && array_key_exists( $prop, $this->changes[ $address_type ] ) ) ) {
$this->changes[ $address_type ][ $prop ] = $value;
}
} else {
$this->data[ $address ][ $prop ] = $value;
$this->data[ $address_type ][ $prop ] = $value;
}
}
}

View File

@ -103,13 +103,13 @@ class WC_Form_Handler {
return;
}
$load_address = isset( $wp->query_vars['edit-address'] ) ? wc_edit_address_i18n( sanitize_title( $wp->query_vars['edit-address'] ), true ) : 'billing';
$address_type = isset( $wp->query_vars['edit-address'] ) ? wc_edit_address_i18n( sanitize_title( $wp->query_vars['edit-address'] ), true ) : 'billing';
if ( ! isset( $_POST[ $load_address . '_country' ] ) ) {
if ( ! isset( $_POST[ $address_type . '_country' ] ) ) {
return;
}
$address = WC()->countries->get_address_fields( wc_clean( wp_unslash( $_POST[ $load_address . '_country' ] ) ), $load_address . '_' );
$address = WC()->countries->get_address_fields( wc_clean( wp_unslash( $_POST[ $address_type . '_country' ] ) ), $address_type . '_' );
foreach ( $address as $key => $field ) {
if ( ! isset( $field['type'] ) ) {
@ -138,7 +138,7 @@ class WC_Form_Handler {
foreach ( $field['validate'] as $rule ) {
switch ( $rule ) {
case 'postcode':
$country = wc_clean( wp_unslash( $_POST[ $load_address . '_country' ] ) );
$country = wc_clean( wp_unslash( $_POST[ $address_type . '_country' ] ) );
$value = wc_format_postcode( $value, $country );
if ( '' !== $value && ! WC_Validation::is_postcode( $value, $country ) ) {
@ -191,12 +191,13 @@ class WC_Form_Handler {
*
* Allow developers to add custom validation logic and throw an error to prevent save.
*
* @since 3.6.0
* @param int $user_id User ID being saved.
* @param string $load_address Type of address e.g. billing or shipping.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param array $address The address fields.
* @param WC_Customer $customer The customer object being saved. @since 3.6.0
* @param WC_Customer $customer The customer object being saved.
*/
do_action( 'woocommerce_after_save_address_validation', $user_id, $load_address, $address, $customer );
do_action( 'woocommerce_after_save_address_validation', $user_id, $address_type, $address, $customer );
if ( 0 < wc_notice_count( 'error' ) ) {
return;
@ -206,7 +207,16 @@ class WC_Form_Handler {
wc_add_notice( __( 'Address changed successfully.', 'woocommerce' ) );
do_action( 'woocommerce_customer_save_address', $user_id, $load_address );
/**
* Hook: woocommerce_customer_save_address.
*
* Fires after a customer address has been saved.
*
* @since 3.6.0
* @param int $user_id User ID being saved.
* @param string $address_type Type of address; 'billing' or 'shipping'.
*/
do_action( 'woocommerce_customer_save_address', $user_id, $address_type );
wp_safe_redirect( wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) ) );
exit;

View File

@ -18,6 +18,7 @@ use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchro
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\WCCom\ConnectionHelper as WCConnectionHelper;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\OrderUtil;
defined( 'ABSPATH' ) || exit;
@ -256,6 +257,7 @@ class WC_Install {
public static function init() {
add_action( 'init', array( __CLASS__, 'check_version' ), 5 );
add_action( 'init', array( __CLASS__, 'manual_database_update' ), 20 );
add_action( 'woocommerce_newly_installed', array( __CLASS__, 'maybe_enable_hpos' ), 20 );
add_action( 'admin_init', array( __CLASS__, 'wc_admin_db_update_notice' ) );
add_action( 'admin_init', array( __CLASS__, 'add_admin_note_after_page_created' ) );
add_action( 'woocommerce_run_update_callback', array( __CLASS__, 'run_update_callback' ) );
@ -878,6 +880,51 @@ class WC_Install {
}
}
/**
* Enable HPOS by default for new shops.
*
* @since 8.2.0
*/
public static function maybe_enable_hpos() {
if ( self::should_enable_hpos_for_new_shop() ) {
$feature_controller = wc_get_container()->get( FeaturesController::class );
$feature_controller->change_feature_enable( 'custom_order_tables', true );
}
}
/**
* Checks whether HPOS should be enabled for new shops.
*
* @return bool
*/
private static function should_enable_hpos_for_new_shop() {
if ( ! did_action( 'woocommerce_init' ) && ! doing_action( 'woocommerce_init' ) ) {
return false;
}
$feature_controller = wc_get_container()->get( FeaturesController::class );
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
return true;
}
if ( ! empty( wc_get_orders( array( 'limit' => 1 ) ) ) ) {
return false;
}
$plugin_compat_info = $feature_controller->get_compatible_plugins_for_feature( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, true );
if ( ! empty( $plugin_compat_info['incompatible'] ) || ! empty( $plugin_compat_info['uncertain'] ) ) {
return false;
}
/**
* Filter to enable HPOS by default for new shops.
*
* @since 8.2.0
*/
return apply_filters( 'woocommerce_enable_hpos_by_default_for_new_shops', true );
}
/**
* Delete obsolete notes.
*/
@ -1167,7 +1214,9 @@ class WC_Install {
$feature_controller = wc_get_container()->get( FeaturesController::class );
$hpos_enabled =
$feature_controller->feature_is_enabled( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION ) || $feature_controller->feature_is_enabled( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
$feature_controller->feature_is_enabled( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION ) || $feature_controller->feature_is_enabled( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) ||
self::should_enable_hpos_for_new_shop()
;
$hpos_table_schema = $hpos_enabled ? wc_get_container()->get( OrdersTableDataStore::class )->get_database_schema() : '';
$tables = "

View File

@ -557,18 +557,27 @@ class WC_Order extends WC_Abstract_Order {
*
* @since 3.0.0
* @param string $prop Name of prop to get.
* @param string $address billing or shipping.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param string $context What the value is for. Valid values are view and edit.
* @return mixed
*/
protected function get_address_prop( $prop, $address = 'billing', $context = 'view' ) {
protected function get_address_prop( $prop, $address_type = 'billing', $context = 'view' ) {
$value = null;
if ( array_key_exists( $prop, $this->data[ $address ] ) ) {
$value = isset( $this->changes[ $address ][ $prop ] ) ? $this->changes[ $address ][ $prop ] : $this->data[ $address ][ $prop ];
if ( array_key_exists( $prop, $this->data[ $address_type ] ) ) {
$value = isset( $this->changes[ $address_type ][ $prop ] ) ? $this->changes[ $address_type ][ $prop ] : $this->data[ $address_type ][ $prop ];
if ( 'view' === $context ) {
$value = apply_filters( $this->get_hook_prefix() . $address . '_' . $prop, $value, $this );
/**
* Filter: 'woocommerce_order_get_[billing|shipping]_[prop]'
*
* Allow developers to change the returned value for any order address property.
*
* @since 3.6.0
* @param string $value The address property value.
* @param WC_Order $order The order object being read.
*/
$value = apply_filters( $this->get_hook_prefix() . $address_type . '_' . $prop, $value, $this );
}
}
return $value;
@ -896,11 +905,20 @@ class WC_Order extends WC_Abstract_Order {
* Note: Merges raw data with get_prop data so changes are returned too.
*
* @since 2.4.0
* @param string $type Billing or shipping. Anything else besides 'billing' will return shipping address.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @return array The stored address after filter.
*/
public function get_address( $type = 'billing' ) {
return apply_filters( 'woocommerce_get_order_address', array_merge( $this->data[ $type ], $this->get_prop( $type, 'view' ) ), $type, $this );
public function get_address( $address_type = 'billing' ) {
/**
* Filter: 'woocommerce_get_order_address'
*
* Allow developers to change the returned value for an order's billing or shipping address.
*
* @since 2.4.0
* @param array $address_data The raw address data merged with the data from get_prop.
* @param string $address_type Type of address; 'billing' or 'shipping'.
*/
return apply_filters( 'woocommerce_get_order_address', array_merge( $this->data[ $address_type ], $this->get_prop( $address_type, 'view' ) ), $address_type, $this );
}
/**
@ -1067,17 +1085,17 @@ class WC_Order extends WC_Abstract_Order {
*
* @since 3.0.0
* @param string $prop Name of prop to set.
* @param string $address Name of address to set. billing or shipping.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param mixed $value Value of the prop.
*/
protected function set_address_prop( $prop, $address, $value ) {
if ( array_key_exists( $prop, $this->data[ $address ] ) ) {
protected function set_address_prop( $prop, $address_type, $value ) {
if ( array_key_exists( $prop, $this->data[ $address_type ] ) ) {
if ( true === $this->object_read ) {
if ( $value !== $this->data[ $address ][ $prop ] || ( isset( $this->changes[ $address ] ) && array_key_exists( $prop, $this->changes[ $address ] ) ) ) {
$this->changes[ $address ][ $prop ] = $value;
if ( $value !== $this->data[ $address_type ][ $prop ] || ( isset( $this->changes[ $address_type ] ) && array_key_exists( $prop, $this->changes[ $address_type ] ) ) ) {
$this->changes[ $address_type ][ $prop ] = $value;
}
} else {
$this->data[ $address ][ $prop ] = $value;
$this->data[ $address_type ][ $prop ] = $value;
}
}
}

View File

@ -329,7 +329,7 @@ abstract class WC_Abstract_Legacy_Order extends WC_Data {
/**
* Set the customer address.
* @param array $address Address data.
* @param string $type billing or shipping.
* @param string $type Type of address; 'billing' or 'shipping'.
*/
public function set_address( $address, $type = 'billing' ) {
foreach ( $address as $key => $value ) {

View File

@ -761,7 +761,7 @@ class WC_API_Orders extends WC_API_Resource {
*
* @param WC_Order $order
* @param array $posted
* @param string $type
* @param string $type Type of address; 'billing' or 'shipping'.
*/
protected function update_address( $order, $posted, $type = 'billing' ) {
foreach ( $posted as $key => $value ) {

View File

@ -805,7 +805,7 @@ class WC_API_Orders extends WC_API_Resource {
*
* @param WC_Order $order
* @param array $posted
* @param string $type
* @param string $type Type of address; 'billing' or 'shipping'.
*/
protected function update_address( $order, $posted, $type = 'billing' ) {
foreach ( $posted as $key => $value ) {

View File

@ -593,9 +593,9 @@ class WC_REST_Orders_V1_Controller extends WC_REST_Posts_Controller {
/**
* Update address.
*
* @param WC_Order $order
* @param array $posted
* @param string $type
* @param WC_Order $order Order object.
* @param array $posted Request data.
* @param string $type Type of address; 'billing' or 'shipping'.
*/
protected function update_address( $order, $posted, $type = 'billing' ) {
foreach ( $posted as $key => $value ) {

View File

@ -804,7 +804,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
*
* @param WC_Order $order Order data.
* @param array $posted Posted data.
* @param string $type Address type.
* @param string $type Type of address; 'billing' or 'shipping'.
*/
protected function update_address( $order, $posted, $type = 'billing' ) {
foreach ( $posted as $key => $value ) {

View File

@ -165,7 +165,7 @@ class WC_Shortcode_My_Account {
/**
* Edit address page.
*
* @param string $load_address Type of address to load.
* @param string $load_address Type of address; 'billing' or 'shipping'.
*/
public static function edit_address( $load_address = 'billing' ) {
$current_user = wp_get_current_user();

View File

@ -309,11 +309,9 @@ function wc_get_account_orders_actions( $order ) {
* Get account formatted address.
*
* @since 3.2.0
* @param string $address_type Address type.
* Accepts: 'billing' or 'shipping'.
* Default to 'billing'.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param int $customer_id Customer ID.
* Default to 0.
* Defaults to 0.
* @return string
*/
function wc_get_account_formatted_address( $address_type = 'billing', $customer_id = 0 ) {

View File

@ -3295,7 +3295,7 @@ if ( ! function_exists( 'woocommerce_account_edit_address' ) ) {
/**
* My Account > Edit address template.
*
* @param string $type Address type.
* @param string $type Type of address; 'billing' or 'shipping'.
*/
function woocommerce_account_edit_address( $type ) {
$type = wc_edit_address_i18n( sanitize_title( $type ), true );

View File

@ -112,12 +112,12 @@ class TaskLists {
'CustomizeStore',
'StoreDetails',
'Products',
'Appearance',
'WooCommercePayments',
'Payments',
'Tax',
'Shipping',
'Marketing',
'Appearance',
);
if ( Features::is_enabled( 'core-profiler' ) ) {

View File

@ -14,14 +14,12 @@ use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
class Appearance extends Task {
/**
* Constructor
*
* @param TaskList $task_list Parent task list.
* Constructor.
*/
public function __construct( $task_list ) {
parent::__construct( $task_list );
add_action( 'admin_enqueue_scripts', array( $this, 'add_media_scripts' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_return_notice_script' ) );
public function __construct() {
if ( ! $this->is_complete() ) {
add_action( 'load-theme-install.php', array( $this, 'mark_actioned' ) );
}
}
/**
@ -39,13 +37,7 @@ class Appearance extends Task {
* @return string
*/
public function get_title() {
if ( $this->get_parent_option( 'use_completed_title' ) === true ) {
if ( $this->is_complete() ) {
return __( 'You personalized your store', 'woocommerce' );
}
return __( 'Personalize your store', 'woocommerce' );
}
return __( 'Personalize my store', 'woocommerce' );
return __( 'Choose your theme', 'woocommerce' );
}
/**
@ -55,7 +47,7 @@ class Appearance extends Task {
*/
public function get_content() {
return __(
'Add your logo, create a homepage, and start designing your store.',
"Choose a theme that best fits your brand's look and feel, then make it your own. Change the colors, add your logo, and create pages.",
'woocommerce'
);
}
@ -70,68 +62,11 @@ class Appearance extends Task {
}
/**
* Addtional data.
* Action label.
*
* @return array
* @return string
*/
public function get_additional_data() {
return array(
'has_homepage' => self::has_homepage(),
'has_products' => Products::has_products(),
'stylesheet' => get_option( 'stylesheet' ),
'theme_mods' => get_theme_mods(),
'support_custom_logo' => false !== get_theme_support( 'custom-logo' ),
);
}
/**
* Add media scripts for image uploader.
*/
public function add_media_scripts() {
if ( ! PageController::is_admin_page() || ! $this->can_view() ) {
return;
}
wp_enqueue_media();
}
/**
* Adds a return to task list notice when completing the task.
*
* @param string $hook Page hook.
*/
public function possibly_add_return_notice_script( $hook ) {
global $post;
if ( $hook !== 'post.php' || $post->post_type !== 'page' ) {
return;
}
if ( $this->is_complete() || ! $this->is_active() ) {
return;
}
WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-homepage-notice', true );
}
/**
* Check if the site has a homepage set up.
*/
public static function has_homepage() {
if ( get_option( 'classic-editor-replace' ) === 'classic' ) {
return true;
}
$homepage_id = get_option( 'woocommerce_onboarding_homepage_post_id', false );
if ( ! $homepage_id ) {
return false;
}
$post = get_post( $homepage_id );
$completed = $post && $post->post_status === 'publish';
return $completed;
public function get_action_label() {
return __( 'Choose theme', 'woocommerce' );
}
}

View File

@ -16,7 +16,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
*/
class PostToOrderAddressTableMigrator extends MetaToCustomTableMigrator {
/**
* Type of addresses being migrated, could be billing|shipping.
* Type of addresses being migrated; 'billing' or 'shipping'.
*
* @var $type
*/
@ -25,7 +25,7 @@ class PostToOrderAddressTableMigrator extends MetaToCustomTableMigrator {
/**
* PostToOrderAddressTableMigrator constructor.
*
* @param string $type Type of addresses being migrated, could be billing|shipping.
* @param string $type Type of address being migrated; 'billing' or 'shipping'.
*/
public function __construct( $type ) {
$this->type = $type;

View File

@ -208,7 +208,6 @@ class Edit {
* @return void
*/
public function handle_order_update() {
global $theorder;
if ( ! isset( $this->order ) ) {
return;
}
@ -233,6 +232,8 @@ class Edit {
*/
do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );
$this->custom_meta_box->handle_metadata_changes($this->order);
// Order updated message.
$this->message = 1;

View File

@ -242,7 +242,7 @@ class CustomMetaBox {
* @return void
*/
private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
$count = 0;
$count = 0;
if ( is_protected_meta( $meta_key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
@ -409,4 +409,52 @@ class CustomMetaBox {
}
wp_die( 0 );
}
/**
* Handle the possible changes in order metadata coming from an order edit page in admin
* (labeled "custom fields" in the UI).
*
* This method expects the $_POST array to contain a 'meta' key that is an associative
* array of [meta item id => [ 'key' => meta item name, 'value' => meta item value ];
* and also to contain (possibly empty) 'metakeyinput' and 'metavalue' keys.
*
* @param WC_Order $order The order to handle.
*/
public function handle_metadata_changes( $order ) {
$has_meta_changes = false;
$order_meta = $order->get_meta_data();
$order_meta =
array_combine(
array_map( fn( $meta ) => $meta->id, $order_meta ),
$order_meta
);
// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
foreach ( ( $_POST['meta'] ?? array() ) as $request_meta_id => $request_meta_data ) {
$request_meta_id = wp_unslash( $request_meta_id );
$request_meta_key = wp_unslash( $request_meta_data['key'] );
$request_meta_value = wp_unslash( $request_meta_data['value'] );
if ( array_key_exists( $request_meta_id, $order_meta ) &&
( $order_meta[ $request_meta_id ]->key !== $request_meta_key || $order_meta[ $request_meta_id ]->value !== $request_meta_value ) ) {
$order->update_meta_data( $request_meta_key, $request_meta_value, $request_meta_id );
$has_meta_changes = true;
}
}
$request_new_key = wp_unslash( $_POST['metakeyinput'] ?? '' );
$request_new_value = wp_unslash( $_POST['metavalue'] ?? '' );
if ( '' !== $request_new_key ) {
$order->add_meta_data( $request_new_key, $request_new_value );
$has_meta_changes = true;
}
// phpcs:enable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
if ( $has_meta_changes ) {
$order->save();
}
}
}

View File

@ -251,12 +251,15 @@ class DataSynchronizer implements BatchProcessorInterface {
}
if ( $this->custom_orders_table_is_authoritative() ) {
$missing_orders_count_sql = "
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
";
AND orders.type IN ($order_post_type_placeholder)",
$order_post_types
);
$operator = '>';
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
@ -374,13 +377,16 @@ ORDER BY posts.ID ASC",
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
break;
case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
$sql = "
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
ORDER BY posts.id ASC
";
AND orders.type IN ($order_post_type_placeholders)
ORDER BY posts.id ASC",
$order_post_types
);
break;
case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';

View File

@ -537,7 +537,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
/**
* Helper function to get alias for address table, this is used in select query.
*
* @param string $type Address type.
* @param string $type Type of address; 'billing' or 'shipping'.
*
* @return string Alias.
*/
@ -1665,7 +1665,7 @@ FROM $order_meta_table
/**
* Helper method to generate join and select query for address table.
*
* @param string $address_type Type of address. Typically will be `billing` or `shipping`.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param string $order_table_alias Alias of order table to use.
* @param string $address_table_alias Alias for address table to use.
*

View File

@ -1,13 +0,0 @@
<?php
/**
* Plugin Name: Enable Experimental Features
* Description: Utility designed for E2E testing purposes. It activates experimental features in WooCommerce.
* @package Automattic\WooCommerce\E2EPlaywright
*/
function enable_experimental_features( $features ) {
$features['product-variation-management'] = true;
return $features;
}
add_filter( 'woocommerce_admin_get_feature_config', 'enable_experimental_features' );

View File

@ -12,8 +12,8 @@ wp-env run tests-cli wp rewrite structure '/%postname%/' --hard
echo -e 'Activate Filter Setter utility plugin \n'
wp-env run tests-cli wp plugin activate filter-setter
echo -e 'Activate Enable Experimental Features utility plugin \n'
wp-env run tests-cli wp plugin activate enable-experimental-features
echo -e 'Activate Test Helper APIs utility plugin \n'
wp-env run tests-cli wp plugin activate test-helper-apis
echo -e 'Add Customer user \n'
wp-env run tests-cli wp user create customer customer@woocommercecoree2etestsuite.com \

View File

@ -0,0 +1,106 @@
<?php
/**
* Plugin Name: Test Helper APIs
* Description: Utility REST API designed for E2E testing purposes. Allows turning features on or off, and setting option values
*/
function register_helper_api() {
register_rest_route(
'e2e-feature-flags',
'/update',
array(
'methods' => 'POST',
'callback' => 'update_feature_flags',
'permission_callback' => 'is_allowed',
)
);
register_rest_route(
'e2e-feature-flags',
'/reset',
array(
'methods' => 'GET',
'callback' => 'reset_feature_flags',
'permission_callback' => 'is_allowed',
)
);
register_rest_route(
'e2e-options',
'/update',
array(
'methods' => 'POST',
'callback' => 'api_update_option',
'permission_callback' => 'is_allowed',
)
);
}
add_action( 'rest_api_init', 'register_helper_api' );
/**
* Update feature flags
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
function update_feature_flags( WP_REST_Request $request ) {
$features = get_option( 'e2e_feature_flags', array() );
$new_features = json_decode( $request->get_body(), true );
if ( is_array( $new_features ) ) {
$features = array_merge( $features, $new_features );
update_option( 'e2e_feature_flags', $features );
return new WP_REST_Response( 'Feature flags updated', 200 );
}
return new WP_REST_Response( 'Invalid request body', 400 );
}
/**
* Reset feature flags
* @return WP_REST_Response
*/
function reset_feature_flags() {
delete_option( 'e2e_feature_flags' );
return new WP_REST_Response( 'Feature flags reset', 200 );
}
/**
* Enable experimental features
* @param array $features Array of features.
* @return array
*/
function enable_experimental_features( $features ) {
$stored_features = get_option( 'e2e_feature_flags', array() );
// We always enable this for tests at the moment.
$features['product-variation-management'] = true;
return array_merge( $features, $stored_features );
}
add_filter( 'woocommerce_admin_get_feature_config', 'enable_experimental_features' );
/**
* Update a WordPress option.
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
function api_update_option( WP_REST_Request $request ) {
$option_name = sanitize_text_field( $request['option_name'] );
$option_value = sanitize_text_field( $request['option_value'] );
if ( update_option( $option_name, $option_value ) ) {
return new WP_REST_Response( 'Option updated', 200 );
}
return new WP_REST_Response( 'Invalid request body', 400 );
}
/**
* Check if user is admin
* @return bool
*/
function is_allowed() {
return current_user_can( 'manage_options' );
}

View File

@ -1,6 +1,5 @@
const { test, expect } = require( '@playwright/test' );
const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default;
const { features } = require( '../../utils' );
test.describe( 'Payment setup task', () => {
test.use( { storageState: process.env.ADMINSTATE } );

View File

@ -0,0 +1,65 @@
const { test, expect, request } = require( '@playwright/test' );
const { features } = require( '../../utils' );
const { activateTheme } = require( '../../utils/themes' );
const { setOption } = require( '../../utils/options' );
const ASSEMBLER_HUB_URL =
'/wp-admin/admin.php?page=wc-admin&path=%2Fcustomize-store%2Fassembler-hub';
test.describe( 'Store owner can view Assembler Hub for store customization', () => {
test.use( { storageState: process.env.ADMINSTATE } );
test.beforeAll( async ( { baseURL } ) => {
// In some environments the tour blocks clicking other elements.
await setOption(
request,
baseURL,
'woocommerce_customize_store_onboarding_tour_hidden',
'yes'
);
await features.setFeatureFlag(
request,
baseURL,
'customize-store',
true
);
// Need a block enabled theme to test
await activateTheme( 'twentytwentythree' );
} );
test.afterAll( async ( { baseURL } ) => {
await features.resetFeatureFlags( request, baseURL );
// Reset theme back to twentynineteen
await activateTheme( 'twentynineteen' );
// Reset tour to visible.
await setOption(
request,
baseURL,
'woocommerce_customize_store_onboarding_tour_hidden',
'no'
);
} );
test( 'Can view the Assembler Hub page', async ( { page } ) => {
await page.goto( ASSEMBLER_HUB_URL );
const locator = page.locator( 'h1:visible' );
await expect( locator ).toHaveText( "Let's get creative" );
} );
test( 'Visiting change header should show a list of block patterns to choose from', async ( {
page,
} ) => {
await page.goto( ASSEMBLER_HUB_URL );
await page.click( 'text=Change your header' );
const locator = page.locator(
'.block-editor-block-patterns-list__list-item'
);
await expect( locator ).toHaveCount( 4 );
} );
} );

View File

@ -86,6 +86,75 @@ test.describe( 'Edit order', () => {
'2018-12-14'
);
} );
test( 'can load billing details', async ( { page, baseURL } ) => {
let customerId = 0;
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
await api
.post( 'customers', {
email: 'archie123@email.addr',
first_name: 'Archie',
last_name: 'Greenback',
username: 'big.archie',
billing: {
first_name: 'Archibald',
last_name: 'Greenback',
company: 'Automattic',
country: 'US',
address_1: 'address1',
address_2: 'address2',
city: 'San Francisco',
state: 'CA',
postcode: '94107',
phone: '123456789',
email: 'archie123@email.addr',
}
} )
.then( ( response ) => {
customerId = response.data.id;
} );
// Open our test order and select the customer we just created.
await page.goto( `wp-admin/post.php?post=${orderId}&action=edit` );
// Simulate the ajax `woocommerce_get_customer_details` call normally done inside meta-boxes-order.js.
const response = await page.evaluate(
async ( customerId ) => {
const simulateCustomerDetailsCall = new Promise( ( resolve ) => {
jQuery.ajax( {
url: woocommerce_admin_meta_boxes.ajax_url,
data: {
user_id : customerId,
action : 'woocommerce_get_customer_details',
security: woocommerce_admin_meta_boxes.get_customer_details_nonce
},
type: 'POST',
success: function( response ) {
resolve( response );
}
} );
} );
return await simulateCustomerDetailsCall;
},
customerId
);
// Response should contain billing address info, but should not contain user meta data.
expect( 'billing' in response ).toBeTruthy();
expect( response.billing.first_name ).toContain( 'Archibald' );
expect( response.meta_data ).toBeUndefined();
// Clean-up.
await api.delete( `customers/${ customerId }`, { force: true } );
} );
} );
test.describe( 'Edit order > Downloadable product permissions', () => {

View File

@ -1,14 +1,41 @@
function is_enabled( feature ) {
const phase = process.env.WC_ADMIN_PHASE;
let config = 'development.json';
if ( ![ 'core','developer' ].includes( phase ) ) {
config = 'core.json';
}
const { encodeCredentials } = require( './plugin-utils' );
const features = require( `../../../client/admin/config/${config}` ).features;
return features[ feature ] && features[ feature ] === true;
}
const setFeatureFlag = async ( request, baseURL, flagName, enable ) => {
const apiContext = await request.newContext( {
baseURL,
extraHTTPHeaders: {
Authorization: `Basic ${ encodeCredentials(
'admin',
'password'
) }`,
cookie: '',
},
} );
await apiContext.post( '/wp-json/e2e-feature-flags/update', {
failOnStatusCode: true,
data: { [ flagName ]: enable },
} );
};
const resetFeatureFlags = async ( request, baseURL ) => {
const apiContext = await request.newContext( {
baseURL,
extraHTTPHeaders: {
Authorization: `Basic ${ encodeCredentials(
'admin',
'password'
) }`,
cookie: '',
},
} );
await apiContext.get( '/wp-json/e2e-feature-flags/reset', {
failOnStatusCode: true,
} );
};
module.exports = {
is_enabled
}
setFeatureFlag,
resetFeatureFlags,
};

View File

@ -0,0 +1,24 @@
import { encodeCredentials } from './plugin-utils';
export const setOption = async (
request,
baseURL,
optionName,
optionValue
) => {
const apiContext = await request.newContext( {
baseURL,
extraHTTPHeaders: {
Authorization: `Basic ${ encodeCredentials(
'admin',
'password'
) }`,
cookie: '',
},
} );
await apiContext.post( '/wp-json/e2e-options/update', {
failOnStatusCode: true,
data: { option_name: optionName, option_value: optionValue },
} );
};

View File

@ -12,7 +12,7 @@ const execAsync = promisify( require( 'child_process' ).exec );
* @param {string} password
* @returns Base64-encoded string
*/
const encodeCredentials = ( username, password ) => {
export const encodeCredentials = ( username, password ) => {
return Buffer.from( `${ username }:${ password }` ).toString( 'base64' );
};

View File

@ -0,0 +1,16 @@
const { exec } = require( 'node:child_process' );
export const activateTheme = ( themeName ) => {
return new Promise( ( resolve, reject ) => {
const command = `wp-env run tests-cli wp theme activate ${ themeName }`;
exec( command, ( error, stdout, stderr ) => {
if ( error ) {
console.error( `Error executing command: ${ error }` );
return reject( error );
}
resolve( stdout );
} );
} );
};

View File

@ -30,5 +30,5 @@ wp plugin activate filter-setter
# initialize pretty permalinks
wp rewrite structure /%postname%/
# Activate our Enable Experimental Features utility.
wp plugin activate enable-experimental-features
# Activate our helper APIs plugin.
wp plugin activate test-helper-apis

View File

@ -226,6 +226,10 @@ class WC_Unit_Tests_Bootstrap {
define( 'WC_REMOVE_ALL_DATA', true );
include $this->plugin_dir . '/uninstall.php';
if ( ! getenv( 'HPOS' ) ) {
add_filter( 'woocommerce_enable_hpos_by_default_for_new_shops', '__return_false' );
}
WC_Install::install();
// Reload capabilities after install, see https://core.trac.wordpress.org/ticket/28374.