Merge branch 'trunk' into update/contribution-guide-2023-03-03

This commit is contained in:
Barry Hughes 2023-04-06 12:15:00 -07:00 committed by GitHub
commit 7cde8d500e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1302 additions and 575 deletions

View File

@ -39,8 +39,8 @@ If you have questions about the process to contribute code or want to discuss de
- Ensure that your code supports the minimum supported versions of PHP and WordPress; this is shown at the top of the `readme.txt` file.
- Push the changes to your fork and submit a pull request on the trunk branch of the WooCommerce repository.
- Make sure to write good and detailed commit messages (see [this post](https://chris.beams.io/posts/git-commit/) for more on this) and follow all the applicable sections of the pull request template.
- Please add a changelog entry by following the steps detailed in the [development guide](https://github.com/woocommerce/woocommerce/blob/trunk/DEVELOPMENT.md), but do not modify `changelog.txt` directly.
- Please do not update any `.pot` files. These will be updated by the WooCommerce team.
- Please create a change file for your changes by running `pnpm --filter=<project> changelog add`. For example, a change file for the WooCommerce Core project would be added by running `pnpm --filter=woocommerce changelog add`.
- Please avoid modifying the changelog directly or updating the .pot files. These will be updated by the WooCommerce team.
If you are contributing code to our (Javascript-driven) Gutenberg blocks, please note that they are developed in their [own repository](https://github.com/woocommerce/woocommerce-gutenberg-products-block) and have their [own issue tracker](https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues).

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Add unit tests

View File

@ -0,0 +1,107 @@
import { render } from '@testing-library/react';
import React, { createElement } from '@wordpress/element';
import { SelectTree } from '../select-tree';
import { Item } from '../../experimental-tree-control';
const mockItems: Item[] = [
{
label: 'Item 1',
value: 'item-1',
},
{
label: 'Item 2',
value: 'item-2',
parent: 'item-1',
},
{
label: 'Item 3',
value: 'item-3',
},
];
const DEFAULT_PROPS = {
id: 'select-tree',
items: mockItems,
label: 'Select Tree',
placeholder: 'Type here',
};
describe( 'SelectTree', () => {
beforeEach( () => {
jest.clearAllMocks();
} );
it( 'should show the popover only when focused', () => {
const { queryByPlaceholderText, queryByText } = render(
<SelectTree { ...DEFAULT_PROPS } />
);
expect( queryByText( 'Item 1' ) ).not.toBeInTheDocument();
queryByPlaceholderText( 'Type here' )?.focus();
expect( queryByText( 'Item 1' ) ).toBeInTheDocument();
} );
it( 'should show create button when callback is true ', () => {
const { queryByText, queryByPlaceholderText } = render(
<SelectTree
{ ...DEFAULT_PROPS }
shouldShowCreateButton={ () => true }
/>
);
queryByPlaceholderText( 'Type here' )?.focus();
expect( queryByText( 'Create new' ) ).toBeInTheDocument();
} );
it( 'should not show create button when callback is false or no callback', () => {
const { queryByText, queryByPlaceholderText } = render(
<SelectTree { ...DEFAULT_PROPS } />
);
queryByPlaceholderText( 'Type here' )?.focus();
expect( queryByText( 'Create new' ) ).not.toBeInTheDocument();
} );
it( 'should show a root item when focused and child when expand button is clicked', () => {
const { queryByText, queryByLabelText, queryByPlaceholderText } =
render( <SelectTree { ...DEFAULT_PROPS } /> );
queryByPlaceholderText( 'Type here' )?.focus();
expect( queryByText( 'Item 1' ) ).toBeInTheDocument();
expect( queryByText( 'Item 2' ) ).not.toBeInTheDocument();
queryByLabelText( 'Expand' )?.click();
expect( queryByText( 'Item 2' ) ).toBeInTheDocument();
} );
it( 'should show selected items', () => {
const { queryAllByRole, queryByPlaceholderText } = render(
<SelectTree { ...DEFAULT_PROPS } selected={ [ mockItems[ 0 ] ] } />
);
queryByPlaceholderText( 'Type here' )?.focus();
expect( queryAllByRole( 'treeitem' )[ 0 ] ).toHaveAttribute(
'aria-selected',
'true'
);
} );
it( 'should show Create "<createValue>" button', () => {
const { queryByPlaceholderText, queryByText } = render(
<SelectTree
{ ...DEFAULT_PROPS }
createValue="new item"
shouldShowCreateButton={ () => true }
/>
);
queryByPlaceholderText( 'Type here' )?.focus();
expect( queryByText( 'Create "new item"' ) ).toBeInTheDocument();
} );
it( 'should call onCreateNew when Create "<createValue>" button is clicked', () => {
const mockFn = jest.fn();
const { queryByPlaceholderText, queryByText } = render(
<SelectTree
{ ...DEFAULT_PROPS }
createValue="new item"
shouldShowCreateButton={ () => true }
onCreateNew={ mockFn }
/>
);
queryByPlaceholderText( 'Type here' )?.focus();
queryByText( 'Create "new item"' )?.click();
expect( mockFn ).toBeCalledTimes( 1 );
} );
} );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add action buttons to the editor header

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product radio block and tax class to product blocks editor

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Prevent click event when the element is aria-disabled"

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Use SelectTree component in product editor's category field

View File

@ -8,7 +8,8 @@
"textdomain": "default",
"attributes": {
"toggleText": {
"type": "string"
"type": "string",
"__experimentalRole": "content"
},
"initialCollapsed": {
"type": "boolean"

View File

@ -9,6 +9,11 @@ import { ProductCategory } from '@woocommerce/data';
import { __experimentalSelectControlMenuItemProps as MenuItemProps } from '@woocommerce/components';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { ProductCategoryNode } from './use-category-search';
export type CategoryTreeItem = {
data: ProductCategory;
children: CategoryTreeItem[];
@ -19,13 +24,10 @@ export type CategoryTreeItem = {
type CategoryFieldItemProps = {
item: CategoryTreeItem;
selectedIds: number[];
items: Pick< ProductCategory, 'id' | 'name' >[];
items: ProductCategoryNode[];
highlightedIndex: number;
openParent?: () => void;
} & Pick<
MenuItemProps< Pick< ProductCategory, 'id' | 'name' > >,
'getItemProps'
>;
} & Pick< MenuItemProps< ProductCategoryNode >, 'getItemProps' >;
export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
item,

View File

@ -3,11 +3,8 @@
*/
import { useMemo, useState, createElement, Fragment } from '@wordpress/element';
import {
selectControlStateChangeTypes,
Spinner,
__experimentalSelectControl as SelectControl,
__experimentalSelectControlMenuSlot as MenuSlot,
__experimentalSelectControlMenu as Menu,
TreeItemType,
__experimentalSelectTreeControl as SelectTree,
} from '@woocommerce/components';
import { ProductCategory } from '@woocommerce/data';
import { debounce } from 'lodash';
@ -15,16 +12,15 @@ import { debounce } from 'lodash';
/**
* Internal dependencies
*/
import { CategoryFieldItem, CategoryTreeItem } from './category-field-item';
import { useCategorySearch } from './use-category-search';
import { CategoryTreeItem } from './category-field-item';
import { useCategorySearch, ProductCategoryNode } from './use-category-search';
import { CreateCategoryModal } from './create-category-modal';
import { CategoryFieldAddNewItem } from './category-field-add-new-item';
type CategoryFieldProps = {
label: string;
placeholder: string;
value?: Pick< ProductCategory, 'id' | 'name' >[];
onChange: ( value: Pick< ProductCategory, 'id' | 'name' >[] ) => void;
value?: ProductCategoryNode[];
onChange: ( value: ProductCategoryNode[] ) => void;
};
/**
@ -32,11 +28,11 @@ type CategoryFieldProps = {
* if not included already.
*/
function getSelectedWithParents(
selected: Pick< ProductCategory, 'id' | 'name' >[] = [],
selected: ProductCategoryNode[] = [],
item: ProductCategory,
treeKeyValues: Record< number, CategoryTreeItem >
): Pick< ProductCategory, 'id' | 'name' >[] {
selected.push( { id: item.id, name: item.name } );
): ProductCategoryNode[] {
selected.push( { id: item.id, name: item.name, parent: item.parent } );
const parentId =
item.parent !== undefined
@ -59,6 +55,33 @@ function getSelectedWithParents(
return selected;
}
function mapFromCategoryType(
categories: ProductCategoryNode[]
): TreeItemType[] {
return categories.map( ( val ) =>
val.parent
? {
value: String( val.id ),
label: val.name,
parent: String( val.parent ),
}
: {
value: String( val.id ),
label: val.name,
}
);
}
function mapToCategoryType(
categories: TreeItemType[]
): ProductCategoryNode[] {
return categories.map( ( cat ) => ( {
id: +cat.value,
name: cat.label,
parent: cat.parent ? +cat.parent : 0,
} ) );
}
export const CategoryField: React.FC< CategoryFieldProps > = ( {
label,
placeholder,
@ -70,7 +93,7 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
categoriesSelectList,
categoryTreeKeyValues,
searchCategories,
getFilteredItems,
getFilteredItemsForSelectTree,
} = useCategorySearch();
const [ showCreateNewModal, setShowCreateNewModal ] = useState( false );
const [ searchValue, setSearchValue ] = useState( '' );
@ -85,167 +108,62 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
[ onInputChange ]
);
const onSelect = ( itemId: number, selected: boolean ) => {
if ( itemId === -99 ) {
setShowCreateNewModal( true );
return;
}
if ( selected ) {
const item = categoryTreeKeyValues[ itemId ].data;
if ( item ) {
onChange(
getSelectedWithParents(
[ ...value ],
item,
categoryTreeKeyValues
)
);
}
} else {
onChange( value.filter( ( i ) => i.id !== itemId ) );
}
};
const categoryFieldGetFilteredItems = (
allItems: Pick< ProductCategory, 'id' | 'name' >[],
inputValue: string,
selectedItems: Pick< ProductCategory, 'id' | 'name' >[]
) => {
const filteredItems = getFilteredItems(
allItems,
inputValue,
selectedItems
);
if (
inputValue.length > 0 &&
! isSearching &&
! filteredItems.find(
( item ) => item.name.toLowerCase() === inputValue.toLowerCase()
)
) {
return [
...filteredItems,
{
id: -99,
name: inputValue,
},
];
}
return filteredItems;
};
const selectedIds = value.map( ( item ) => item.id );
return (
<>
<SelectControl< Pick< ProductCategory, 'id' | 'name' > >
className="woocommerce-category-field-dropdown components-base-control"
<SelectTree
id="category-field"
multiple
items={ categoriesSelectList }
shouldNotRecursivelySelect
createValue={ searchValue }
label={ label }
selected={ value }
getItemLabel={ ( item ) => item?.name || '' }
getItemValue={ ( item ) => item?.id || '' }
onSelect={ ( item ) => {
if ( item ) {
onSelect( item.id, ! selectedIds.includes( item.id ) );
}
} }
onRemove={ ( item ) => item && onSelect( item.id, false ) }
isLoading={ isSearching }
onInputChange={ searchDelayed }
getFilteredItems={ categoryFieldGetFilteredItems }
placeholder={ value.length === 0 ? placeholder : '' }
stateReducer={ ( state, actionAndChanges ) => {
const { changes, type } = actionAndChanges;
switch ( type ) {
case selectControlStateChangeTypes.ControlledPropUpdatedSelectedItem:
return {
...changes,
inputValue: state.inputValue,
};
case selectControlStateChangeTypes.ItemClick:
if (
changes.selectedItem &&
changes.selectedItem.id === -99
) {
return changes;
}
return {
...changes,
isOpen: true,
inputValue: state.inputValue,
highlightedIndex: state.highlightedIndex,
};
default:
return changes;
onCreateNew={ () => {
setShowCreateNewModal( true );
} }
shouldShowCreateButton={ ( typedValue ) =>
! typedValue ||
categoriesSelectList.findIndex(
( item ) => item.name === typedValue
) === -1
}
items={ getFilteredItemsForSelectTree(
mapFromCategoryType( categoriesSelectList ),
searchValue,
mapFromCategoryType( value )
) }
selected={ mapFromCategoryType( value ) }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
const newItems: ProductCategoryNode[] =
mapToCategoryType(
selectedItems.filter(
( { value: selectedItemValue } ) =>
! value.some(
( item ) =>
item.id === +selectedItemValue
)
)
);
onChange( [ ...value, ...newItems ] );
}
} }
__experimentalOpenMenuOnFocus
>
{ ( {
items,
isOpen,
getMenuProps,
getItemProps,
highlightedIndex,
} ) => {
const rootItems =
items.length > 0
? items.filter(
( item ) =>
categoryTreeKeyValues[ item.id ]
?.parentID === 0 || item.id === -99
)
: [];
return (
<Menu
isOpen={ isOpen }
getMenuProps={ getMenuProps }
className="woocommerce-category-field-dropdown__menu"
>
<>
{ isSearching ? (
<li className="woocommerce-category-field-dropdown__item">
<div className="woocommerce-category-field-dropdown__item-content">
<Spinner />
</div>
</li>
) : (
rootItems.map( ( item ) => {
return item.id === -99 ? (
<CategoryFieldAddNewItem
key={ `${ item.id }` }
item={ item }
highlightedIndex={
highlightedIndex
}
items={ items }
getItemProps={ getItemProps }
/>
) : (
<CategoryFieldItem
key={ `${ item.id }` }
item={
categoryTreeKeyValues[
item.id
]
}
highlightedIndex={
highlightedIndex
}
selectedIds={ selectedIds }
items={ items }
getItemProps={ getItemProps }
/>
);
} )
) }
</>
</Menu>
);
onRemove={ ( removedItems ) => {
const newValues = Array.isArray( removedItems )
? value.filter(
( item ) =>
! removedItems.some(
( { value: removedValue } ) =>
item.id === +removedValue
)
)
: value.filter(
( item ) => item.id !== +removedItems.value
);
onChange( newValues );
} }
</SelectControl>
<MenuSlot />
></SelectTree>
{ showCreateNewModal && (
<CreateCategoryModal
initialCategoryName={ searchValue }

View File

@ -23,7 +23,7 @@ import {
/**
* Internal dependencies
*/
import { useCategorySearch } from './use-category-search';
import { ProductCategoryNode, useCategorySearch } from './use-category-search';
import { CategoryFieldItem } from './category-field-item';
type CreateCategoryModalProps = {
@ -51,10 +51,8 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
const [ categoryName, setCategoryName ] = useState(
initialCategoryName || ''
);
const [ categoryParent, setCategoryParent ] = useState< Pick<
ProductCategory,
'id' | 'name'
> | null >( null );
const [ categoryParent, setCategoryParent ] =
useState< ProductCategoryNode | null >( null );
const onSave = async () => {
recordEvent( 'product_category_add', {
@ -94,7 +92,7 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
value={ categoryName }
onChange={ setCategoryName }
/>
<SelectControl< Pick< ProductCategory, 'id' | 'name' > >
<SelectControl< ProductCategoryNode >
items={ categoriesSelectList }
label={ createInterpolateElement(
__( 'Parent category <optional/>', 'woocommerce' ),

View File

@ -3,13 +3,14 @@
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { Product, ProductCategory } from '@woocommerce/data';
import { Product } from '@woocommerce/data';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { CategoryField } from './category-field';
import { ProductCategoryNode } from './use-category-search';
export const DetailsCategoriesField = () => {
const { getInputProps } = useFormContext< Product >();
@ -18,9 +19,7 @@ export const DetailsCategoriesField = () => {
<CategoryField
label={ __( 'Categories', 'woocommerce' ) }
placeholder={ __( 'Search or create category…', 'woocommerce' ) }
{ ...getInputProps< Pick< ProductCategory, 'id' | 'name' >[] >(
'categories'
) }
{ ...getInputProps< ProductCategoryNode[] >( 'categories' ) }
/>
);
};

View File

@ -1,94 +1,17 @@
/**
* External dependencies
*/
import { ReactElement, Component } from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render } from '@testing-library/react';
import { Form, FormContextType } from '@woocommerce/components';
import { Product, ProductCategory } from '@woocommerce/data';
import { Product } from '@woocommerce/data';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { CategoryField } from '../category-field';
import {
getCategoriesTreeWithMissingParents,
useCategorySearch,
} from '../use-category-search';
import { ProductCategoryNode } from '../use-category-search';
const mockCategoryList = [
{ id: 1, name: 'Clothing', parent: 0 },
{ id: 2, name: 'Hoodies', parent: 1 },
{ id: 3, name: 'Rain gear', parent: 0 },
] as ProductCategory[];
jest.mock( '@woocommerce/components', () => {
const originalModule = jest.requireActual( '@woocommerce/components' );
type ChildrenProps = {
items: ProductCategory[];
isOpen: boolean;
highlightedIndex: number;
getMenuProps: () => Record< string, string >;
getItemProps: () => Record< string, string >;
selectItem: ( item: ProductCategory ) => void;
setInputValue: ( value: string ) => void;
};
type SelectControlProps = {
children: ( {}: ChildrenProps ) => ReactElement | Component;
items: ProductCategory[];
label: string;
initialSelectedItems?: ProductCategory[];
itemToString?: ( item: ProductCategory | null ) => string;
getFilteredItems?: (
allItems: ProductCategory[],
inputValue: string,
selectedItems: ProductCategory[]
) => ProductCategory[];
multiple?: boolean;
onInputChange?: ( value: string | undefined ) => void;
onRemove?: ( item: ProductCategory ) => void;
onSelect?: ( selected: ProductCategory ) => void;
placeholder?: string;
selected: ProductCategory[];
};
return {
...originalModule,
__experimentalSelectControlMenu: ( {
children,
}: {
children: JSX.Element;
} ) => children,
__experimentalSelectControl: ( {
children,
items,
selected,
}: SelectControlProps ) => {
return (
<div>
[select-control]
<div className="selected">
{ selected.map( ( item ) => (
<div key={ item.id }>{ item.name }</div>
) ) }
</div>
<div className="children">
{ children( {
items,
isOpen: true,
getMenuProps: () => ( {} ),
selectItem: () => {},
highlightedIndex: -1,
setInputValue: () => {},
getItemProps: () => ( {} ),
} ) }
</div>
</div>
);
},
};
} );
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
jest.mock( '../use-category-search', () => {
@ -98,7 +21,7 @@ jest.mock( '../use-category-search', () => {
originalModule.getCategoriesTreeWithMissingParents,
useCategorySearch: jest.fn().mockReturnValue( {
searchCategories: jest.fn(),
getFilteredItems: jest.fn(),
getFilteredItemsForSelectTree: jest.fn().mockReturnValue( [] ),
isSearching: false,
categoriesSelectList: [],
categoryTreeKeyValues: {},
@ -112,24 +35,25 @@ describe( 'CategoryField', () => {
} );
it( 'should render a dropdown select control', () => {
const { queryByText } = render(
const { queryByText, queryByPlaceholderText } = render(
<Form initialValues={ { categories: [] } }>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
{ ...getInputProps< ProductCategoryNode[] >(
'categories'
) }
/>
) }
</Form>
);
expect( queryByText( '[select-control]' ) ).toBeInTheDocument();
queryByPlaceholderText( 'Search or create category…' )?.focus();
expect( queryByText( 'Create new' ) ).toBeInTheDocument();
} );
it( 'should pass in the selected categories as select control items', () => {
const { queryByText } = render(
const { queryAllByText, queryByPlaceholderText } = render(
<Form
initialValues={ {
categories: [
@ -142,189 +66,15 @@ describe( 'CategoryField', () => {
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
{ ...getInputProps< ProductCategoryNode[] >(
'categories'
) }
/>
) }
</Form>
);
expect( queryByText( 'Test' ) ).toBeInTheDocument();
expect( queryByText( 'Clothing' ) ).toBeInTheDocument();
} );
describe( 'search values', () => {
beforeEach( async () => {
const items = await getCategoriesTreeWithMissingParents(
mockCategoryList,
''
);
( useCategorySearch as jest.Mock ).mockReturnValue( {
searchCategories: jest.fn(),
getFilteredItems: jest.fn(),
isSearching: false,
categoriesSelectList: items[ 0 ],
categoryTreeKeyValues: items[ 2 ],
} );
} );
it( 'should display only the parent categories passed in to the categoriesSelectList', () => {
const { queryByText } = render(
<Form
initialValues={ {
categories: [],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
/>
) }
</Form>
);
expect(
queryByText( mockCategoryList[ 0 ].name )
).toBeInTheDocument();
const childParent = queryByText(
mockCategoryList[ 1 ].name
)?.parentElement?.closest(
'.woocommerce-category-field-dropdown__item-children'
);
expect( childParent ).toBeInTheDocument();
expect( childParent?.className ).not.toMatch(
'woocommerce-category-field-dropdown__item-open'
);
expect(
queryByText( mockCategoryList[ 2 ].name )
).toBeInTheDocument();
} );
it( 'should show selected categories as selected', () => {
const { getByLabelText } = render(
<Form
initialValues={ {
categories: [ mockCategoryList[ 2 ] ],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
/>
) }
</Form>
);
const rainGearCheckbox = getByLabelText(
mockCategoryList[ 2 ].name
);
expect( rainGearCheckbox ).toBeChecked();
const clothingCheckbox = getByLabelText(
mockCategoryList[ 0 ].name
);
expect( clothingCheckbox ).not.toBeChecked();
} );
it( 'should show selected categories as selected', () => {
const { getByLabelText } = render(
<Form
initialValues={ {
categories: [ mockCategoryList[ 2 ] ],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
/>
) }
</Form>
);
const rainGearCheckbox = getByLabelText(
mockCategoryList[ 2 ].name
);
expect( rainGearCheckbox ).toBeChecked();
const clothingCheckbox = getByLabelText(
mockCategoryList[ 0 ].name
);
expect( clothingCheckbox ).not.toBeChecked();
} );
it( 'should include a toggle icon for parents that contain children', () => {
const { getByLabelText } = render(
<Form
initialValues={ {
categories: [ mockCategoryList[ 2 ] ],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
/>
) }
</Form>
);
const rainGearCheckboxParent = getByLabelText(
mockCategoryList[ 0 ].name
).parentElement?.closest(
'.woocommerce-category-field-dropdown__item-content'
);
expect(
rainGearCheckboxParent?.querySelector( 'svg' )
).toBeInTheDocument();
} );
it( 'should allow user to toggle the parents using the svg button', () => {
const { getByLabelText, queryByText } = render(
<Form
initialValues={ {
categories: [ mockCategoryList[ 2 ] ],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps<
Pick< ProductCategory, 'id' | 'name' >[]
>( 'categories' ) }
/>
) }
</Form>
);
const rainGearCheckboxParent = getByLabelText(
mockCategoryList[ 0 ].name
).parentElement?.closest(
'.woocommerce-category-field-dropdown__item-content'
);
const toggle = rainGearCheckboxParent?.querySelector( 'svg' );
if ( toggle ) {
fireEvent.click( toggle );
}
const childParent = queryByText(
mockCategoryList[ 1 ].name
)?.parentElement?.closest(
'.woocommerce-category-field-dropdown__item-children'
);
expect( childParent ).toBeInTheDocument();
expect( childParent?.className ).toMatch(
'woocommerce-category-field-dropdown__item-open'
);
} );
queryByPlaceholderText( 'Search or create category…' )?.focus();
expect( queryAllByText( 'Test' ) ).toHaveLength( 2 );
expect( queryAllByText( 'Clothing' ) ).toHaveLength( 2 );
} );
} );

View File

@ -9,6 +9,7 @@ import {
ProductCategory,
} from '@woocommerce/data';
import { escapeRegExp } from 'lodash';
import { TreeItemType } from '@woocommerce/components';
/**
* Internal dependencies
@ -33,6 +34,11 @@ function openParents(
}
}
export type ProductCategoryNode = Pick<
ProductCategory,
'id' | 'name' | 'parent'
>;
/**
* Sort function for category tree items, sorts by popularity and then alphabetically.
*/
@ -256,9 +262,9 @@ export const useCategorySearch = () => {
*/
const getFilteredItems = useCallback(
(
allItems: Pick< ProductCategory, 'id' | 'name' >[],
allItems: ProductCategoryNode[],
inputValue: string,
selectedItems: Pick< ProductCategory, 'id' | 'name' >[]
selectedItems: ProductCategoryNode[]
) => {
const searchRegex = new RegExp( escapeRegExp( inputValue ), 'i' );
return allItems.filter(
@ -272,9 +278,33 @@ export const useCategorySearch = () => {
[ categoriesAndNewItem ]
);
/**
* this is the same as getFilteredItems but for the SelectTree component, where item id is a string.
* After all the occurrences of getFilteredItems are migrated to use SelectTree,
* this can become the standard version
*/
const getFilteredItemsForSelectTree = useCallback(
(
allItems: TreeItemType[],
inputValue: string,
selectedItems: TreeItemType[]
) => {
const searchRegex = new RegExp( escapeRegExp( inputValue ), 'i' );
return allItems.filter(
( item ) =>
selectedItems.indexOf( item ) < 0 &&
( searchRegex.test( item.label ) ||
( categoryTreeKeyValues[ +item.value ] &&
categoryTreeKeyValues[ +item.value ].isOpen ) )
);
},
[ categoriesAndNewItem ]
);
return {
searchCategories,
getFilteredItems,
getFilteredItemsForSelectTree,
categoriesSelectList: categoriesAndNewItem[ 0 ],
categories: categoriesAndNewItem[ 1 ],
isSearching,

View File

@ -48,12 +48,7 @@ export function Editor( { product, settings }: EditorProps ) {
<FullscreenMode isActive={ false } />
<SlotFillProvider>
<InterfaceSkeleton
header={
<Header
productId={ product.id }
productName={ product.name }
/>
}
header={ <Header productName={ product.name } /> }
content={
<>
<BlockEditor

View File

@ -14,6 +14,7 @@ import {
*/
import { init as initImages } from '../images';
import { init as initName } from '../details-name-block';
import { init as initRadio } from '../radio';
import { init as initSummary } from '../details-summary-block';
import { init as initSection } from '../section';
import { init as initTab } from '../tab';
@ -31,6 +32,7 @@ export const initBlocks = () => {
initImages();
initName();
initRadio();
initSummary();
initSection();
initTab();

View File

@ -1,74 +1,31 @@
/**
* External dependencies
*/
import { Product } from '@woocommerce/data';
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { navigateTo, getNewPath } from '@woocommerce/navigation';
import { WooHeaderItem } from '@woocommerce/admin-layout';
/**
* Internal dependencies
*/
import { AUTO_DRAFT_NAME, getHeaderTitle } from '../../utils';
/**
* Internal dependencies
*/
import { getHeaderTitle } from '../../utils';
import { MoreMenu } from './more-menu';
import { PreviewButton } from './preview-button';
import { SaveDraftButton } from './save-draft-button';
import { PublishButton } from './publish-button';
export type HeaderProps = {
productId: number;
productName: string;
};
export function Header( { productId, productName }: HeaderProps ) {
const { isProductLocked, isSaving, editedProductName } = useSelect(
( select ) => {
const { isSavingEntityRecord, getEditedEntityRecord } =
select( 'core' );
const { isPostSavingLocked } = select( 'core/editor' );
const product: Product = getEditedEntityRecord(
'postType',
'product',
productId
);
return {
isProductLocked: isPostSavingLocked(),
isSaving: isSavingEntityRecord(
'postType',
'product',
productId
),
editedProductName: product?.name,
};
},
[ productId ]
export function Header( { productName }: HeaderProps ) {
const [ editedProductName ] = useEntityProp< string >(
'postType',
'product',
'name'
);
const isDisabled = isProductLocked || isSaving;
const isCreating = productName === AUTO_DRAFT_NAME;
const { saveEditedEntityRecord } = useDispatch( 'core' );
function handleSave() {
saveEditedEntityRecord< Product >(
'postType',
'product',
productId
).then( ( response ) => {
if ( isCreating ) {
navigateTo( {
url: getNewPath( {}, `/product/${ response.id }` ),
} );
}
} );
}
return (
<div
className="woocommerce-product-header"
@ -81,16 +38,12 @@ export function Header( { productId, productName }: HeaderProps ) {
</h1>
<div className="woocommerce-product-header__actions">
<Button
onClick={ handleSave }
variant="primary"
isBusy={ isSaving }
disabled={ isDisabled }
>
{ isCreating
? __( 'Add', 'woocommerce' )
: __( 'Save', 'woocommerce' ) }
</Button>
<SaveDraftButton />
<PreviewButton />
<PublishButton />
<WooHeaderItem.Slot name="product" />
<MoreMenu />
</div>

View File

@ -0,0 +1 @@
export * from './use-preview';

View File

@ -0,0 +1,146 @@
/**
* External dependencies
*/
import { Product, ProductStatus } from '@woocommerce/data';
import { Button } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { MouseEvent } from 'react';
export function usePreview( {
disabled,
onClick,
onSaveSuccess,
onSaveError,
...props
}: Omit< Button.AnchorProps, 'aria-disabled' | 'variant' | 'href' > & {
onSaveSuccess?( product: Product ): void;
onSaveError?( error: Error ): void;
} ): Button.AnchorProps {
const anchorRef = useRef< HTMLAnchorElement >();
const [ productId ] = useEntityProp< number >(
'postType',
'product',
'id'
);
const [ productStatus ] = useEntityProp< ProductStatus | 'auto-draft' >(
'postType',
'product',
'status'
);
const [ permalink ] = useEntityProp< string >(
'postType',
'product',
'permalink'
);
const { hasEdits, isDisabled } = useSelect(
( select ) => {
const { hasEditsForEntityRecord, isSavingEntityRecord } =
select( 'core' );
const { isPostSavingLocked } = select( 'core/editor' );
const isSavingLocked = isPostSavingLocked();
const isSaving = isSavingEntityRecord< boolean >(
'postType',
'product',
productId
);
return {
isDisabled: isSavingLocked || isSaving,
hasEdits: hasEditsForEntityRecord< boolean >(
'postType',
'product',
productId
),
};
},
[ productId ]
);
const ariaDisabled = disabled || isDisabled;
const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' );
let previewLink: URL | undefined;
if ( typeof permalink === 'string' ) {
previewLink = new URL( permalink );
previewLink.searchParams.append( 'preview', 'true' );
}
/**
* Overrides the default anchor behaviour when the product has unsaved changes.
* Before redirecting to the preview page all changes are saved and then the
* redirection is performed.
*
* @param event
*/
async function handleClick( event: MouseEvent< HTMLAnchorElement > ) {
if ( ariaDisabled ) {
return event.preventDefault();
}
if ( onClick ) {
onClick( event );
}
// Prevent an infinite recursion call due to the
// `anchorRef.current?.click()` call.
if ( ! hasEdits ) {
return;
}
// Prevent the default anchor behaviour.
event.preventDefault();
try {
// If the product status is `auto-draft` it's not possible to
// reach the preview page, so the status is changed to `draft`
// before redirecting.
if ( productStatus === 'auto-draft' ) {
await editEntityRecord( 'postType', 'product', productId, {
status: 'draft',
} );
}
// Persist the product changes before redirecting
const publishedProduct = await saveEditedEntityRecord< Product >(
'postType',
'product',
productId
);
// Redirect using the default anchor behaviour. This way, the usage
// of `window.open` is avoided which comes with some edge cases.
anchorRef.current?.click();
if ( onSaveSuccess ) {
onSaveSuccess( publishedProduct );
}
} catch ( error ) {
if ( onSaveError ) {
onSaveError( error as Error );
}
}
}
return {
'aria-label': __( 'Preview in new tab', 'woocommerce' ),
children: __( 'Preview', 'woocommerce' ),
target: '_blank',
...props,
ref( element: HTMLAnchorElement ) {
if ( typeof props.ref === 'function' ) props.ref( element );
anchorRef.current = element;
},
'aria-disabled': ariaDisabled,
// Note that the href is always passed for a11y support. So
// the final rendered element is always an anchor.
href: previewLink?.toString(),
variant: 'tertiary',
onClick: handleClick,
};
}

View File

@ -0,0 +1 @@
export * from './use-publish';

View File

@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { Product, ProductStatus } from '@woocommerce/data';
import { Button } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { MouseEvent } from 'react';
export function usePublish( {
disabled,
onClick,
onPublishSuccess,
onPublishError,
...props
}: Omit< Button.ButtonProps, 'aria-disabled' | 'variant' | 'children' > & {
onPublishSuccess?( product: Product ): void;
onPublishError?( error: Error ): void;
} ): Button.ButtonProps {
const [ productId ] = useEntityProp< number >(
'postType',
'product',
'id'
);
const [ productStatus ] = useEntityProp< ProductStatus | 'auto-draft' >(
'postType',
'product',
'status'
);
const { hasEdits, isDisabled, isBusy } = useSelect(
( select ) => {
const { hasEditsForEntityRecord, isSavingEntityRecord } =
select( 'core' );
const { isPostSavingLocked } = select( 'core/editor' );
const isSavingLocked = isPostSavingLocked();
const isSaving = isSavingEntityRecord< boolean >(
'postType',
'product',
productId
);
return {
isDisabled: isSavingLocked || isSaving,
isBusy: isSaving,
hasEdits: hasEditsForEntityRecord< boolean >(
'postType',
'product',
productId
),
};
},
[ productId ]
);
const isCreating = productStatus === 'auto-draft';
const ariaDisabled =
disabled || isDisabled || ( productStatus === 'publish' && ! hasEdits );
const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' );
async function handleClick( event: MouseEvent< HTMLButtonElement > ) {
if ( ariaDisabled ) {
return event.preventDefault();
}
if ( onClick ) {
onClick( event );
}
try {
// 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
// publish it's possible to save the product too.
if ( productStatus !== 'publish' ) {
await editEntityRecord( 'postType', 'product', productId, {
status: 'publish',
} );
}
const publishedProduct = await saveEditedEntityRecord< Product >(
'postType',
'product',
productId
);
if ( onPublishSuccess ) {
onPublishSuccess( publishedProduct );
}
} catch ( error ) {
if ( onPublishError ) {
onPublishError( error as Error );
}
}
}
return {
children: isCreating
? __( 'Add', 'woocommerce' )
: __( 'Save', 'woocommerce' ),
...props,
'aria-disabled': ariaDisabled,
isBusy,
variant: 'primary',
onClick: handleClick,
};
}

View File

@ -0,0 +1 @@
export * from './use-save-draft';

View File

@ -0,0 +1,113 @@
/**
* External dependencies
*/
import { Product, ProductStatus } from '@woocommerce/data';
import { Button, Icon } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { check } from '@wordpress/icons';
import { createElement, Fragment } from '@wordpress/element';
import { MouseEvent, ReactNode } from 'react';
export function useSaveDraft( {
disabled,
onClick,
onSaveSuccess,
onSaveError,
...props
}: Omit< Button.ButtonProps, 'aria-disabled' | 'variant' | 'children' > & {
onSaveSuccess?( product: Product ): void;
onSaveError?( error: Error ): void;
} ): Button.ButtonProps {
const [ productId ] = useEntityProp< number >(
'postType',
'product',
'id'
);
const [ productStatus ] = useEntityProp< ProductStatus | 'auto-draft' >(
'postType',
'product',
'status'
);
const { hasEdits, isDisabled } = useSelect(
( select ) => {
const { hasEditsForEntityRecord, isSavingEntityRecord } =
select( 'core' );
const { isPostSavingLocked } = select( 'core/editor' );
const isSavingLocked = isPostSavingLocked();
const isSaving = isSavingEntityRecord< boolean >(
'postType',
'product',
productId
);
return {
isDisabled: isSavingLocked || isSaving,
hasEdits: hasEditsForEntityRecord< boolean >(
'postType',
'product',
productId
),
};
},
[ productId ]
);
const ariaDisabled =
disabled || isDisabled || ( productStatus !== 'publish' && ! hasEdits );
const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' );
async function handleClick( event: MouseEvent< HTMLButtonElement > ) {
if ( ariaDisabled ) {
return event.preventDefault();
}
if ( onClick ) {
onClick( event );
}
try {
await editEntityRecord( 'postType', 'product', productId, {
status: 'draft',
} );
const publishedProduct = await saveEditedEntityRecord< Product >(
'postType',
'product',
productId
);
if ( onSaveSuccess ) {
onSaveSuccess( publishedProduct );
}
} catch ( error ) {
if ( onSaveError ) {
onSaveError( error as Error );
}
}
}
let children: ReactNode;
if ( productStatus === 'publish' ) {
children = __( 'Switch to draft', 'woocommerce' );
} else if ( hasEdits ) {
children = __( 'Save draft', 'woocommerce' );
} else {
children = (
<>
<Icon icon={ check } />
{ __( 'Saved', 'woocommerce' ) }
</>
);
}
return {
children,
...props,
'aria-disabled': ariaDisabled,
variant: 'tertiary',
onClick: handleClick,
};
}

View File

@ -0,0 +1 @@
export * from './preview-button';

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { Product, ProductStatus } from '@woocommerce/data';
import { getNewPath, navigateTo } from '@woocommerce/navigation';
import { Button } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { usePreview } from '../hooks/use-preview';
export function PreviewButton( {
...props
}: Omit<
Button.AnchorProps,
'aria-disabled' | 'variant' | 'href' | 'children'
> ) {
const [ productStatus ] = useEntityProp< ProductStatus | 'auto-draft' >(
'postType',
'product',
'status'
);
const { createErrorNotice } = useDispatch( 'core/notices' );
const previewButtonProps = usePreview( {
...props,
onSaveSuccess( savedProduct: Product ) {
if ( productStatus === 'auto-draft' ) {
const url = getNewPath( {}, `/product/${ savedProduct.id }` );
navigateTo( { url } );
}
},
onSaveError() {
createErrorNotice(
__( 'Failed to preview product.', 'woocommerce' )
);
},
} );
return <Button { ...previewButtonProps } />;
}

View File

@ -0,0 +1 @@
export * from './publish-button';

View File

@ -0,0 +1,72 @@
/**
* External dependencies
*/
import { Product, ProductStatus } from '@woocommerce/data';
import { getNewPath, navigateTo } from '@woocommerce/navigation';
import { Button } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { MouseEvent } from 'react';
/**
* Internal dependencies
*/
import { usePublish } from '../hooks/use-publish';
export function PublishButton(
props: Omit< Button.ButtonProps, 'aria-disabled' | 'variant' | 'children' >
) {
const [ productStatus ] = useEntityProp< ProductStatus | 'auto-draft' >(
'postType',
'product',
'status'
);
const isCreating = productStatus === 'auto-draft';
const { createSuccessNotice, createErrorNotice } =
useDispatch( 'core/notices' );
const publishButtonProps = usePublish( {
...props,
onPublishSuccess( savedProduct: Product ) {
const noticeContent = isCreating
? __( 'Product successfully created.', 'woocommerce' )
: __( 'Product published.', 'woocommerce' );
const noticeOptions = {
icon: '🎉',
actions: [
{
label: __( 'View in store', 'woocommerce' ),
// Leave the url to support a11y.
url: savedProduct.permalink,
onClick( event: MouseEvent< HTMLAnchorElement > ) {
event.preventDefault();
// Notice actions do not support target anchor prop,
// so this forces the page to be opened in a new tab.
window.open( savedProduct.permalink, '_blank' );
},
},
],
};
createSuccessNotice( noticeContent, noticeOptions );
if ( productStatus === 'auto-draft' ) {
const url = getNewPath( {}, `/product/${ savedProduct.id }` );
navigateTo( { url } );
}
},
onPublishError() {
const noticeContent = isCreating
? __( 'Failed to create product.', 'woocommerce' )
: __( 'Failed to publish product.', 'woocommerce' );
createErrorNotice( noticeContent );
},
} );
return <Button { ...publishButtonProps } />;
}

View File

@ -0,0 +1 @@
export * from './save-draft-button';

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { Product, ProductStatus } from '@woocommerce/data';
import { getNewPath, navigateTo } from '@woocommerce/navigation';
import { Button } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { useSaveDraft } from '../hooks/use-save-draft';
export function SaveDraftButton(
props: Omit< Button.ButtonProps, 'aria-disabled' | 'variant' | 'children' >
) {
const [ productStatus ] = useEntityProp< ProductStatus | 'auto-draft' >(
'postType',
'product',
'status'
);
const { createSuccessNotice, createErrorNotice } =
useDispatch( 'core/notices' );
const saveDraftButtonProps = useSaveDraft( {
...props,
onSaveSuccess( savedProduct: Product ) {
createSuccessNotice(
__( 'Product saved as draft.', 'woocommerce' )
);
if ( productStatus === 'auto-draft' ) {
const url = getNewPath( {}, `/product/${ savedProduct.id }` );
navigateTo( { url } );
}
},
onSaveError() {
createErrorNotice(
__( 'Failed to update product.', 'woocommerce' )
);
},
} );
return <Button { ...saveDraftButtonProps } />;
}

View File

@ -0,0 +1,39 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-radio",
"title": "Product radio control",
"category": "woocommerce",
"description": "The product radio.",
"keywords": [ "products", "radio", "input" ],
"textdomain": "default",
"attributes": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"property": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "object"
},
"default": [],
"__experimentalRole": "content"
}
},
"supports": {
"align": false,
"html": false,
"multiple": true,
"reusable": false,
"inserter": false,
"lock": false,
"__experimentalToolbar": false
},
"editorStyle": "file:./editor.css"
}

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { createElement, Fragment } from '@wordpress/element';
import type { BlockAttributes } from '@wordpress/blocks';
import { RadioControl } from '@wordpress/components';
import { useBlockProps } from '@wordpress/block-editor';
import { useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { sanitizeHTML } from '../../utils/sanitize-html';
export function Edit( { attributes }: { attributes: BlockAttributes } ) {
const blockProps = useBlockProps();
const { description, options, property, title } = attributes;
const [ value, setValue ] = useEntityProp< string >(
'postType',
'product',
property
);
return (
<div { ...blockProps }>
<RadioControl
label={
<>
<span className="wp-block-woocommerce-product-radio__title">
{ title }
</span>
<span
className="wp-block-woocommerce-product-radio__description"
dangerouslySetInnerHTML={ sanitizeHTML(
description
) }
/>
</>
}
selected={ value }
options={ options }
onChange={ ( selected ) => setValue( selected || '' ) }
/>
</div>
);
}

View File

@ -0,0 +1,27 @@
.wp-block-woocommerce-product-radio {
.components-base-control__label {
text-transform: none;
font-weight: 400;
> span {
display: block;
line-height: 1.5;
}
margin-bottom: $gap-large;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #1e1e1e;
margin-bottom: $gap-smaller;
}
&__description {
font-size: 13px;
color: #1e1e1e;
}
.components-base-control__field > .components-v-stack {
gap: $gap;
}
}

View File

@ -0,0 +1,17 @@
/**
* Internal dependencies
*/
import initBlock from '../../utils/init-block';
import metadata from './block.json';
import { Edit } from './edit';
const { name } = metadata;
export { metadata, name };
export const settings = {
example: {},
edit: Edit,
};
export const init = () => initBlock( { name, metadata, settings } );

View File

@ -6,6 +6,7 @@
@import 'components/header/style.scss';
@import 'components/images/editor.scss';
@import 'components/block-editor/style.scss';
@import 'components/radio/editor.scss';
@import 'components/section/editor.scss';
@import 'components/tab/editor.scss';
@import 'components/tabs/style.scss';

View File

@ -167,7 +167,7 @@ export const getPages = () => {
} );
}
if ( window.wcAdminFeatures[ 'block-editor-feature-enabled' ] ) {
if ( window.wcAdminFeatures[ 'product-block-editor' ] ) {
pages.push( {
container: ProductPage,
path: '/add-product',

View File

@ -61,7 +61,7 @@
body.woocommerce-page {
.components-button.is-primary {
&:not(:disabled) {
&:not(:disabled):not([aria-disabled='true']):hover {
color: $studio-white;
}
}

View File

@ -9,7 +9,7 @@ declare global {
wcAdminFeatures: {
'activity-panels': boolean;
analytics: boolean;
'block-editor-feature-enabled': boolean;
'product-block-editor': boolean;
coupons: boolean;
'customer-effort-score-tracks': boolean;
homescreen: boolean;

View File

@ -504,7 +504,14 @@ export const initProductScreenTracks = () => {
document
.querySelector( '.save_attributes' )
?.addEventListener( 'click', () => {
?.addEventListener( 'click', ( event ) => {
if (
event.target instanceof Element &&
event.target.classList.contains( 'disabled' )
) {
// skip in case the button is disabled
return;
}
const newAttributesCount = document.querySelectorAll(
'.woocommerce_attribute'
).length;

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Change variations dropdown menu visibility

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix global button aria-disabled style

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tax class to product editor template

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding charge sales tax field to product block editor template.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Default to sorting orders by date (desc) when HPOS is active.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
update select all to checkbox in menu editor

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix table alias issue in order field queries.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Add sort order to migration script for consistency.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Register product editor blocks server-side

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Show tooltip in Save attributes button instead of using title attribute

View File

@ -2,7 +2,7 @@
"features": {
"activity-panels": true,
"analytics": true,
"block-editor-feature-enabled": false,
"product-block-editor": false,
"coupons": true,
"customer-effort-score-tracks": true,
"import-products-task": true,

View File

@ -2,7 +2,7 @@
"features": {
"activity-panels": true,
"analytics": true,
"block-editor-feature-enabled": true,
"product-block-editor": true,
"coupons": true,
"customer-effort-score-tracks": true,
"import-products-task": true,

View File

@ -1018,8 +1018,13 @@ $default-line-height: 18px;
max-width: 100%;
}
.variation_actions {
max-width: 131px;
}
.toolbar-top {
.button {
.button,
.select {
margin: 1px;
}
}

View File

@ -56,7 +56,11 @@ jQuery( function ( $ ) {
);
},
create_variations: function () {
create_variations: function ( event ) {
if ( $( this ).hasClass( 'disabled' ) ) {
event.preventDefault();
return;
}
var new_attribute_data = $(
'.woocommerce_variation_new_attribute_data'
);
@ -655,8 +659,8 @@ jQuery( function ( $ ) {
} );
$( '.wc-metaboxes-wrapper' ).on(
'click',
'a.do_variation_action',
'change',
'#field_to_edit',
this.do_variation_action
);
@ -1195,7 +1199,7 @@ jQuery( function ( $ ) {
* Actions
*/
do_variation_action: function () {
var do_variation_action = $( 'select.variation_actions' ).val(),
var do_variation_action = $( this ).val(),
data = {},
changes = 0,
value;
@ -1344,11 +1348,9 @@ jQuery( function ( $ ) {
if ( parseInt( wrapper.attr( 'data-total' ) ) > 0 ) {
$( '.add-variation-container' ).addClass( 'hidden' );
$( '#field_to_edit' ).removeClass( 'hidden' );
$( 'a.do_variation_action' ).removeClass( 'hidden' );
} else {
$( '.add-variation-container' ).removeClass( 'hidden' );
$( '#field_to_edit' ).addClass( 'hidden' );
$( 'a.do_variation_action' ).addClass( 'hidden' );
}
},
@ -1451,7 +1453,7 @@ jQuery( function ( $ ) {
if ( page_nav.is( ':hidden' ) ) {
$( 'option, optgroup', '.variation_actions' ).show();
$( '.variation_actions' ).val( 'delete_all' );
$( '.variation_actions' ).val( 'bulk_actions' );
$( '#variable_product_options' ).find( '.toolbar' ).show();
page_nav.show();
$( '.pagination-links', page_nav ).hide();
@ -1498,13 +1500,13 @@ jQuery( function ( $ ) {
toolbar.not( '.toolbar-top, .toolbar-buttons' ).hide();
page_nav.hide();
$( 'option, optgroup', variation_action ).hide();
$( '.variation_actions' ).val( 'delete_all' );
$( '.variation_actions' ).val( 'bulk_actions' );
$( 'option[data-global="true"]', variation_action ).show();
} else {
toolbar.show();
page_nav.show();
$( 'option, optgroup', variation_action ).show();
$( '.variation_actions' ).val( 'delete_all' );
$( '.variation_actions' ).val( 'bulk_actions' );
// Show/hide links
if ( 1 === total_pages ) {

View File

@ -750,7 +750,11 @@ jQuery( function ( $ ) {
);
// Save attributes and update variations.
$( '.save_attributes' ).on( 'click', function () {
$( '.save_attributes' ).on( 'click', function ( event ) {
if ( $( this ).hasClass( 'disabled' ) ) {
event.preventDefault();
return;
}
$( '.product_attributes' ).block( {
message: null,
overlayCSS: {

View File

@ -50,17 +50,14 @@ jQuery( function ( $ ) {
attributes_and_variations_data
)
) {
if ( ! $save_button.is( ':disabled' ) ) {
$save_button.attr( 'disabled', 'disabled' );
$save_button.attr(
'title',
woocommerce_admin_meta_boxes.i18n_save_attribute_variation_tip
);
if ( ! $save_button.hasClass( 'disabled' ) ) {
$save_button.addClass( 'disabled' );
$save_button.attr( 'aria-disabled', true );
}
return;
} else {
$save_button.removeClass( 'disabled' );
$save_button.removeAttr( 'aria-disabled' );
}
$save_button.removeAttr( 'disabled' );
$save_button.removeAttr( 'title' );
};
// Run tipTip
@ -79,6 +76,30 @@ jQuery( function ( $ ) {
runTipTip();
$( '.save_attributes' ).tipTip( {
content: function () {
return $( '.save_attributes' ).hasClass( 'disabled' )
? woocommerce_admin_meta_boxes.i18n_save_attribute_variation_tip
: '';
},
fadeIn: 50,
fadeOut: 50,
delay: 200,
keepAlive: true,
} );
$( '.create-variations' ).tipTip( {
content: function () {
return $( '.create-variations' ).hasClass( 'disabled' )
? woocommerce_admin_meta_boxes.i18n_save_attribute_variation_tip
: '';
},
fadeIn: 50,
fadeOut: 50,
delay: 200,
keepAlive: true,
} );
$( '.wc-metaboxes-wrapper' ).on( 'click', '.wc-metabox > h3', function () {
var metabox = $( this ).parent( '.wc-metabox' );

View File

@ -4,7 +4,10 @@
* www.drewwilson.com
* code.drewwilson.com/entry/tiptip-jquery-plugin
*
* Version 1.3 - Updated: Mar. 23, 2010
* Version 1.3.1 - Updated: Mar. 30, 2023
*
* This is a custom version of TipTip. This file has been locally modified for specific requirements.
* Since the original version is no longer maintained, the changes were not submitted back to the original author.
*
* This Plug-In will create a custom tooltip to replace the default
* browser tooltip. It is extremely lightweight and very smart in
@ -31,7 +34,7 @@
fadeIn: 200,
fadeOut: 200,
attribute: "title",
content: false, // HTML or String to fill TipTIp with
content: false, // HTML or String or callback to fill TipTIp with
enter: function(){},
exit: function(){}
};
@ -98,8 +101,12 @@
}
function active_tiptip(){
var content = typeof opts.content === 'function' ? opts.content() : org_title;
if (!content) {
return;
}
opts.enter.call(this);
tiptip_content.html(org_title);
tiptip_content.html(content);
tiptip_holder.hide().css("margin","0");
tiptip_holder.removeAttr('class');
tiptip_arrow.removeAttr("style");

View File

@ -218,7 +218,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
if ( OrderUtil::orders_cache_usage_is_enabled() ) {
$order_cache = wc_get_container()->get( OrderCache::class );
$order_cache->update_if_cached( $this );
$order_cache->remove( $this->get_id() );
}
/**

View File

@ -225,7 +225,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
'gateway_toggle' => wp_create_nonce( 'woocommerce-toggle-payment-gateway-enabled' ),
),
'urls' => array(
'add_product' => Features::is_enabled( 'new-product-management-experience' ) || Features::is_enabled( 'block-editor-feature-enabled' ) ? esc_url_raw( admin_url( 'admin.php?page=wc-admin&path=/add-product' ) ) : null,
'add_product' => Features::is_enabled( 'new-product-management-experience' ) || Features::is_enabled( 'product-block-editor' ) ? esc_url_raw( admin_url( 'admin.php?page=wc-admin&path=/add-product' ) ) : null,
'import_products' => current_user_can( 'import' ) ? esc_url_raw( admin_url( 'edit.php?post_type=product&page=product_importer' ) ) : null,
'export_products' => current_user_can( 'export' ) ? esc_url_raw( admin_url( 'edit.php?post_type=product&page=product_exporter' ) ) : null,
),

View File

@ -375,9 +375,12 @@ class WC_Admin_Menus {
?>
</ul>
</div>
<p class="button-controls">
<p class="button-controls" data-items-type="posttype-woocommerce-endpoints">
<span class="list-controls">
<a href="<?php echo esc_url( admin_url( 'nav-menus.php?page-tab=all&selectall=1#posttype-woocommerce-endpoints' ) ); ?>" class="select-all"><?php esc_html_e( 'Select all', 'woocommerce' ); ?></a>
<label>
<input type="checkbox" class="select-all" />
<?php esc_html_e( 'Select all', 'woocommerce' ); ?>
</label>
</span>
<span class="add-to-menu">
<button type="submit" class="button-secondary submit-add-to-menu right" value="<?php esc_attr_e( 'Add to menu', 'woocommerce' ); ?>" name="add-post-type-menu-item" id="submit-posttype-woocommerce-endpoints"><?php esc_html_e( 'Add to menu', 'woocommerce' ); ?></button>
@ -424,7 +427,7 @@ class WC_Admin_Menus {
* Maybe add new management product experience.
*/
public function maybe_add_new_product_management_experience() {
if ( Features::is_enabled( 'new-product-management-experience' ) || Features::is_enabled( 'block-editor-feature-enabled' ) ) {
if ( Features::is_enabled( 'new-product-management-experience' ) || Features::is_enabled( 'product-block-editor' ) ) {
global $submenu;
if ( isset( $submenu['edit.php?post_type=product'][10] ) ) {
// Disable phpcs since we need to override submenu classes.

View File

@ -108,7 +108,7 @@ $icon_url = WC_ADMIN_IMAGES_FOLDER_URL . '/icons/global-a
<span class="expand-close">
<a href="#" class="expand_all"><?php esc_html_e( 'Expand', 'woocommerce' ); ?></a> / <a href="#" class="close_all"><?php esc_html_e( 'Close', 'woocommerce' ); ?></a>
</span>
<button type="button" class="button save_attributes button-primary" disabled="disabled" title="<?php echo esc_html_e( 'Make sure you enter the name and values for each attribute.', 'woocommerce' ); ?>"><?php esc_html_e( 'Save attributes', 'woocommerce' ); ?></button>
<button type="button" aria-disabled="true" class="button save_attributes button-primary disabled"><?php esc_html_e( 'Save attributes', 'woocommerce' ); ?></button>
</div>
<?php do_action( 'woocommerce_product_options_attributes' ); ?>
</div>

View File

@ -53,7 +53,7 @@ $arrow_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variati
require __DIR__ . '/html-product-attribute-inner.php';
?>
<div class="toolbar">
<button type="button" class="button button-primary create-variations" disabled="disabled" title="<?php echo esc_html_e( 'Make sure you enter the name and values for each attribute.', 'woocommerce' ); ?>"><?php esc_html_e( 'Create variations', 'woocommerce' ); ?></button>
<button type="button" aria-disabled="true" class="button button-primary create-variations disabled"><?php esc_html_e( 'Create variations', 'woocommerce' ); ?></button>
</div>
</div>
</div>
@ -97,7 +97,8 @@ $arrow_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variati
<div class="toolbar toolbar-top">
<button type="button" class="button generate_variations"><?php esc_html_e( 'Generate variations', 'woocommerce' ); ?></button>
<button type="button" class="button add_variation_manually"><?php esc_html_e( 'Add manually', 'woocommerce' ); ?></button>
<select id="field_to_edit" class="variation_actions hidden">
<select id="field_to_edit" class="select variation_actions hidden">
<option value="bulk_actions" disabled>Bulk actions</option>
<option value="delete_all"><?php esc_html_e( 'Delete all variations', 'woocommerce' ); ?></option>
<optgroup label="<?php esc_attr_e( 'Status', 'woocommerce' ); ?>">
<option value="toggle_enabled"><?php esc_html_e( 'Toggle &quot;Enabled&quot;', 'woocommerce' ); ?></option>
@ -135,7 +136,6 @@ $arrow_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variati
<?php do_action( 'woocommerce_variable_product_bulk_edit_actions' ); ?>
<?php /* phpcs:enable */ ?>
</select>
<a class="button bulk_edit do_variation_action hidden"><?php esc_html_e( 'Go', 'woocommerce' ); ?></a>
<div class="variations-pagenav">
<?php /* translators: variations count */ ?>

View File

@ -513,6 +513,64 @@ class WC_Post_Types {
),
),
),
array(
'woocommerce/product-radio',
array(
'title' => __( 'Charge sales tax on', 'woocommerce' ),
'property' => 'tax_status',
'options' => array(
array(
'label' => __( 'Product and shipping', 'woocommerce' ),
'value' => 'taxable',
),
array(
'label' => __( 'Only shipping', 'woocommerce' ),
'value' => 'shipping',
),
array(
'label' => __( "Don't charge tax", 'woocommerce' ),
'value' => 'none',
),
),
),
),
array(
'woocommerce/collapsible',
array(
'toggleText' => __( 'Advanced', 'woocommerce' ),
'initialCollapsed' => true,
'persistRender' => true,
),
array(
array(
'woocommerce/product-radio',
array(
'title' => __( 'Tax class', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
__( 'Apply a tax rate if this product qualifies for tax reduction or exemption. %1$sLearn more%2$s.', 'woocommerce' ),
'<a href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class" target="_blank" rel="noreferrer">',
'</a>'
),
'property' => 'tax_class',
'options' => array(
array(
'label' => __( 'Standard', 'woocommerce' ),
'value' => '',
),
array(
'label' => __( 'Reduced rate', 'woocommerce' ),
'value' => 'reduced-rate',
),
array(
'label' => __( 'Zero rate', 'woocommerce' ),
'value' => 'zero-rate',
),
),
),
),
),
),
),
),
),

View File

@ -40,7 +40,7 @@ class Features {
protected static $beta_features = array(
'navigation',
'new-product-management-experience',
'block-editor-feature-enabled',
'product-block-editor',
'settings',
);

View File

@ -0,0 +1,92 @@
<?php
/**
* WooCommerce Product Editor Block Registration
*/
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Product block registration and style registration functionality.
*/
class BlockRegistry {
/**
* The directory where blocks are stored after build.
*/
const BLOCKS_DIR = 'product-editor/blocks';
/**
* Array of all available product blocks.
*/
const PRODUCT_BLOCKS = [
'woocommerce/product-name',
'woocommerce/product-pricing',
'woocommerce/product-section',
'woocommerce/product-tab',
];
/**
* Get a file path for a given block file.
*
* @param string $path File path.
*/
public function get_file_path( $path ) {
return WC_ABSPATH . WCAdminAssets::get_path( 'js' ) . trailingslashit( self::BLOCKS_DIR ) . $path;
}
/**
* Register all the product blocks.
*/
public function register_product_blocks() {
foreach ( self::PRODUCT_BLOCKS as $block_name ) {
$this->register_block( $block_name );
}
}
/**
* Get the block name without the "woocommerce/" prefix.
*
* @param string $block_name Block name.
*
* @return string
*/
public function remove_block_prefix( $block_name ) {
if ( 0 === strpos( $block_name, 'woocommerce/' ) ) {
return substr_replace( $block_name, '', 0, strlen( 'woocommerce/' ) );
}
return $block_name;
}
/**
* Register a single block.
*
* @param string $block_name Block name.
*
* @return WP_Block_Type|false The registered block type on success, or false on failure.
*/
public function register_block( $block_name ) {
$block_name = $this->remove_block_prefix( $block_name );
$block_json_file = $this->get_file_path( $block_name . '/block.json' );
if ( ! file_exists( $block_json_file ) ) {
return false;
}
// phpcs:disable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$metadata = json_decode( file_get_contents( $block_json_file ), true );
if ( ! is_array( $metadata ) || ! $metadata['name'] ) {
return false;
}
$registry = \WP_Block_Type_Registry::get_instance();
if ( $registry->is_registered( $metadata['name'] ) ) {
$registry->unregister( $metadata['name'] );
}
return register_block_type_from_metadata( $block_json_file );
}
}

View File

@ -1,21 +1,22 @@
<?php
/**
* WooCommerce Block Editor Feature Endabled
* WooCommerce Product Block Editor
*/
namespace Automattic\WooCommerce\Admin\Features;
namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\TransientNotices;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
use WP_Block_Editor_Context;
/**
* Loads assets related to the new product management experience page.
* Loads assets related to the product block editor.
*/
class BlockEditorFeatureEnabled {
class Init {
const FEATURE_ID = 'block-editor-feature-enabled';
const FEATURE_ID = 'product-block-editor';
/**
* Option name used to toggle this feature.
@ -33,6 +34,8 @@ class BlockEditorFeatureEnabled {
if ( Features::is_enabled( self::FEATURE_ID ) ) {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_filter( 'woocommerce_register_post_type_product', array( $this, 'add_rest_base_config' ) );
$block_registry = new BlockRegistry();
$block_registry->register_product_blocks();
}
}

View File

@ -365,7 +365,7 @@ class ListTable extends WP_List_Table {
$direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );
if ( ! in_array( $field, $sortable, true ) ) {
$this->order_query_args['orderby'] = 'id';
$this->order_query_args['orderby'] = 'date';
$this->order_query_args['order'] = 'DESC';
return;
}

View File

@ -294,7 +294,8 @@ LEFT JOIN $orders_table orders ON posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND posts.post_status != 'auto-draft'
AND orders.id IS NULL",
AND orders.id IS NULL
ORDER BY posts.ID ASC",
$order_post_types
);
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
@ -305,6 +306,7 @@ 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
";
break;
case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
@ -317,6 +319,7 @@ JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
posts.post_type IN ($order_post_type_placeholders)
AND orders.date_updated_gmt $operator posts.post_modified_gmt
ORDER BY orders.id ASC
",
$order_post_types
);

View File

@ -246,7 +246,7 @@ class OrdersTableFieldQuery {
$this->table_aliases[] = $alias;
if ( $join ) {
$this->join[] = $join;
$this->join[ $alias ] = $join;
}
return $alias;

View File

@ -755,9 +755,9 @@ class OrdersTableQuery {
if ( empty( $on ) ) {
if ( $this->tables['orders'] === $table ) {
$on = "{$this->tables['orders']}.id = {$alias}.id";
$on = "`{$this->tables['orders']}`.id = `{$alias}`.id";
} else {
$on = "{$this->tables['orders']}.id = {$alias}.order_id";
$on = "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
}
}
@ -775,8 +775,8 @@ class OrdersTableQuery {
}
$sql_join = '';
$sql_join .= "{$join_type} JOIN {$table} ";
$sql_join .= ( $alias !== $table ) ? "AS {$alias} " : '';
$sql_join .= "{$join_type} JOIN `{$table}` ";
$sql_join .= ( $alias !== $table ) ? "AS `{$alias}` " : '';
$sql_join .= "ON ( {$on} )";
$this->join[ $alias ] = $sql_join;

View File

@ -179,7 +179,6 @@ test.describe( 'Add New Variable Product Page', () => {
await page.selectOption( '#field_to_edit', 'toggle_downloadable', {
force: true,
} );
await page.click( 'a.do_variation_action' );
await page.click(
'#variable_product_options .toolbar-top a.expand_all'
);
@ -196,7 +195,6 @@ test.describe( 'Add New Variable Product Page', () => {
await page.click( 'a[href="#variable_product_options"]' );
await page.waitForLoadState( 'networkidle' );
await page.selectOption( '#field_to_edit', 'delete_all' );
await page.click( 'a.do_variation_action' );
await page.waitForSelector( '.woocommerce_variation', {
state: 'detached',
} );

View File

@ -269,4 +269,23 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertEquals( $coupon->get_id(), $coupon_data['id'] );
$this->assertEquals( $coupon_code, $coupon_data['code'] );
}
/**
* @testDox Cache does not interfere if wc_get_order returns a different class than WC_Order.
*/
public function test_cache_does_not_interferes_with_order_object() {
add_action(
'woocommerce_new_order',
function( $order_id ) {
// this makes the cache store a specific order class instance, but it's quickly replaced by a generic one
// as we're in the middle of a save and this gets executed before the logic in WC_Abstract_Order.
$order = wc_get_order( $order_id );
}
);
$order = new WC_Order();
$order->save();
$order = wc_get_order( $order->get_id() );
$this->assertInstanceOf( Automattic\WooCommerce\Admin\Overrides\Order::class, $order );
}
}

View File

@ -293,4 +293,22 @@ class DataSynchronizerTests extends WC_Unit_Test_Case {
'Meta data deleted from the CPT datastore should also be deleted from the HPOS datastore.'
);
}
/**
* @testDox Orders for migration are picked by ID sorted.
*/
public function test_migration_sort() {
global $wpdb;
$order1 = wc_get_order( OrderHelper::create_order() );
$order2 = wc_get_order( OrderHelper::create_order() );
// Let's update order1 id to be greater than order2 id.
// phpcs:ignore
$max_id = $wpdb->get_var( "SELECT MAX(id) FROM $wpdb->posts" );
$wpdb->update( $wpdb->posts, array( 'ID' => $max_id + 1 ), array( 'ID' => $order1->get_id() ) );
$orders_to_migrate = $this->sut->get_next_batch_to_process( 2 );
$this->assertEquals( $order2->get_id(), $orders_to_migrate[0] );
$this->assertEquals( $max_id + 1, $orders_to_migrate[1] );
}
}