Auto delete unused variations when auto generating variations (#39733)

* Add auto delete functionality for variations

* Add the remove confirmation modal

* Update delete unmatched product variation logic

* Add tests

* Add changelogs

* Fix lint errors

* Fix lint errors
This commit is contained in:
louwie17 2023-08-14 16:35:16 -03:00 committed by GitHub
parent e8d0a081ad
commit 3edd1bd823
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 698 additions and 195 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add isGeneratingVariations selector to product variations store.

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { combineReducers, registerStore, StoreConfig } from '@wordpress/data';
import { registerStore, StoreConfig } from '@wordpress/data';
import { Reducer } from 'redux';
/**
@ -28,8 +28,6 @@ export const createCrudDataStore = ( {
pluralResourceName,
storeConfig = {},
}: CrudDataStore ) => {
const crudReducer = createReducer();
const crudActions = createDispatchActions( {
resourceName,
namespace,
@ -54,13 +52,10 @@ export const createCrudDataStore = ( {
controls = {},
} = storeConfig;
const crudReducer = createReducer( reducer );
registerStore( storeName, {
reducer: reducer
? ( combineReducers( {
crudReducer,
reducer,
} ) as Reducer )
: ( crudReducer as Reducer< ResourceState > ),
reducer: crudReducer as Reducer< ResourceState >,
actions: { ...crudActions, ...actions },
selectors: { ...crudSelectors, ...selectors },
resolvers: { ...crudResolvers, ...resolvers },

View File

@ -27,7 +27,9 @@ export type ResourceState = {
requesting: Record< string, boolean >;
};
export const createReducer = () => {
export const createReducer = (
additionalReducer?: Reducer< ResourceState >
) => {
const reducer: Reducer< ResourceState, Actions > = (
state = {
items: {},
@ -284,11 +286,11 @@ export const createReducer = () => {
) ]: true,
},
};
default:
return state;
}
}
if ( additionalReducer ) {
return additionalReducer( state, payload );
}
return state;
};

View File

@ -1,4 +1,6 @@
export enum TYPES {
GENERATE_VARIATIONS_REQUEST = 'GENERATE_VARIATIONS_REQUEST',
GENERATE_VARIATIONS_SUCCESS = 'GENERATE_VARIATIONS_SUCCESS',
GENERATE_VARIATIONS_ERROR = 'GENERATE_VARIATIONS_ERROR',
BATCH_UPDATE_VARIATIONS_ERROR = 'BATCH_UPDATE_VARIATIONS_ERROR',
}

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
/**
* Internal dependencies
@ -10,22 +11,67 @@ import { getUrlParameters, getRestPath, parseId } from '../crud/utils';
import TYPES from './action-types';
import { IdQuery, IdType, Item } from '../crud/types';
import { WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants';
import type { BatchUpdateRequest, BatchUpdateResponse } from './types';
import type {
BatchUpdateRequest,
BatchUpdateResponse,
GenerateRequest,
} from './types';
import CRUD_ACTIONS from './crud-actions';
import { ProductAttribute } from '../products/types';
export function generateProductVariationsError( key: IdType, error: unknown ) {
return {
type: TYPES.GENERATE_VARIATIONS_ERROR as const,
key,
error,
errorType: 'GENERATE_VARIATIONS',
errorType: CRUD_ACTIONS.GENERATE_VARIATIONS,
};
}
export const generateProductVariations = function* ( idQuery: IdQuery ) {
export function generateProductVariationsRequest( key: IdType ) {
return {
type: TYPES.GENERATE_VARIATIONS_REQUEST as const,
key,
};
}
export function generateProductVariationsSuccess( key: IdType ) {
return {
type: TYPES.GENERATE_VARIATIONS_SUCCESS as const,
key,
};
}
export const generateProductVariations = function* (
idQuery: IdQuery,
productData: {
type?: string;
attributes: ProductAttribute[];
},
data: GenerateRequest
) {
const urlParameters = getUrlParameters(
WC_PRODUCT_VARIATIONS_NAMESPACE,
idQuery
);
const { key } = parseId( idQuery, urlParameters );
yield generateProductVariationsRequest( key );
try {
yield controls.dispatch(
'core',
'saveEntityRecord',
'postType',
'product',
{
id: urlParameters[ 0 ],
...productData,
}
);
} catch ( error ) {
yield generateProductVariationsError( key, error );
throw error;
}
try {
const result: Item = yield apiFetch( {
@ -35,12 +81,12 @@ export const generateProductVariations = function* ( idQuery: IdQuery ) {
urlParameters
),
method: 'POST',
data,
} );
yield generateProductVariationsSuccess( key );
return result;
} catch ( error ) {
const { key } = parseId( idQuery, urlParameters );
yield generateProductVariationsError( key, error );
throw error;
}
@ -86,3 +132,9 @@ export function* batchUpdateProductVariations(
throw error;
}
}
export type Actions = ReturnType<
| typeof generateProductVariationsRequest
| typeof generateProductVariationsError
| typeof generateProductVariationsSuccess
>;

View File

@ -0,0 +1,5 @@
export enum CRUD_ACTIONS {
GENERATE_VARIATIONS = 'GENERATE_VARIATIONS',
}
export default CRUD_ACTIONS;

View File

@ -1,9 +1,17 @@
/**
* External dependencies
*/
import { Reducer } from 'redux';
/**
* Internal dependencies
*/
import { STORE_NAME, WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants';
import { createCrudDataStore } from '../crud';
import * as actions from './actions';
import * as selectors from './selectors';
import { reducer } from './reducer';
import { ResourceState } from '../crud/reducer';
createCrudDataStore( {
storeName: STORE_NAME,
@ -11,7 +19,9 @@ createCrudDataStore( {
pluralResourceName: 'ProductVariations',
namespace: WC_PRODUCT_VARIATIONS_NAMESPACE,
storeConfig: {
reducer: reducer as Reducer< ResourceState >,
actions,
selectors,
},
} );

View File

@ -0,0 +1,73 @@
/**
* External dependencies
*/
import { Reducer } from 'redux';
/**
* Internal dependencies
*/
import { Actions } from './actions';
import { TYPES } from './action-types';
import { ResourceState } from '../crud/reducer';
import { getRequestIdentifier } from '../crud/utils';
import CRUD_ACTIONS from './crud-actions';
const reducer: Reducer< ResourceState, Actions > = (
state = {
items: {},
data: {},
itemsCount: {},
errors: {},
requesting: {},
},
payload
) => {
if ( payload && 'type' in payload ) {
switch ( payload.type ) {
case TYPES.GENERATE_VARIATIONS_REQUEST:
return {
...state,
requesting: {
...state.requesting,
[ getRequestIdentifier(
CRUD_ACTIONS.GENERATE_VARIATIONS,
payload.key
) ]: true,
},
};
case TYPES.GENERATE_VARIATIONS_SUCCESS:
return {
...state,
requesting: {
...state.requesting,
[ getRequestIdentifier(
CRUD_ACTIONS.GENERATE_VARIATIONS,
payload.key
) ]: false,
},
};
case TYPES.GENERATE_VARIATIONS_ERROR:
return {
...state,
errors: {
...state.errors,
[ getRequestIdentifier(
payload.errorType,
payload.key
) ]: payload.error,
},
requesting: {
...state.requesting,
[ getRequestIdentifier(
CRUD_ACTIONS.GENERATE_VARIATIONS,
payload.key
) ]: false,
},
};
default:
return state;
}
}
return state;
};
export { reducer };

View File

@ -0,0 +1,24 @@
/**
* Internal dependencies
*/
import { ResourceState } from '../crud/reducer';
import { IdQuery } from '../crud/types';
import { getRequestIdentifier, getUrlParameters, parseId } from '../crud/utils';
import { WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants';
import CRUD_ACTIONS from './crud-actions';
export const isGeneratingVariations = (
state: ResourceState,
idQuery: IdQuery
) => {
const urlParameters = getUrlParameters(
WC_PRODUCT_VARIATIONS_NAMESPACE,
idQuery
);
const { key } = parseId( idQuery, urlParameters );
const itemQuery = getRequestIdentifier(
CRUD_ACTIONS.GENERATE_VARIATIONS,
key
);
return state.requesting[ itemQuery ];
};

View File

@ -94,6 +94,10 @@ export type ProductVariationSelectors = CrudSelectors<
export type ActionDispatchers = DispatchFromMap< ProductVariationActions >;
export type GenerateRequest = {
delete?: boolean;
};
export type BatchUpdateRequest = {
create?: Partial< Omit< ProductVariation, 'id' > >[];
update?: ( Pick< ProductVariation, 'id' > &

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new option in attributes control for a delete confirmation modal instead of the window.alert.

View File

@ -1,4 +1,7 @@
.wp-block-woocommerce-product-variations-items-field {
.wp-block-woocommerce-product-variation-items-field {
@media ( min-width: #{ ($break-medium) } ) {
min-height: 420px;
}
}
.variation-items-product-tour {

View File

@ -90,6 +90,7 @@ export function Edit() {
] ) }
onChange={ handleChange }
createNewAttributesAsGlobal={ true }
useRemoveConfirmationModal={ true }
uiStrings={ {
globalAttributeHelperMessage: '',
customAttributeHelperMessage: '',
@ -117,8 +118,8 @@ export function Edit() {
'Remove variation option',
'woocommerce'
),
attributeRemoveConfirmationMessage: __(
'Remove this variation option?',
attributeRemoveConfirmationModalMessage: __(
'If you continue, some variations of this product will be deleted and customers will no longer be able to purchase them.',
'woocommerce'
),
} }

View File

@ -30,6 +30,7 @@ import {
} from './utils';
import { AttributeListItem } from '../attribute-list-item';
import { NewAttributeModal } from './new-attribute-modal';
import { RemoveConfirmationModal } from './remove-confirmation-modal';
type AttributeControlProps = {
value: EnhancedProductAttribute[];
@ -45,6 +46,7 @@ type AttributeControlProps = {
onEditModalClose?: ( attribute?: ProductAttribute ) => void;
onEditModalOpen?: ( attribute?: ProductAttribute ) => void;
createNewAttributesAsGlobal?: boolean;
useRemoveConfirmationModal?: boolean;
uiStrings?: {
emptyStateSubtitle?: string;
newAttributeListItemLabel?: string;
@ -54,6 +56,7 @@ type AttributeControlProps = {
customAttributeHelperMessage?: string;
attributeRemoveLabel?: string;
attributeRemoveConfirmationMessage?: string;
attributeRemoveConfirmationModalMessage?: string;
globalAttributeHelperMessage: string;
};
};
@ -73,6 +76,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
onRemoveCancel = () => {},
uiStrings,
createNewAttributesAsGlobal = false,
useRemoveConfirmationModal = false,
} ) => {
uiStrings = {
newAttributeListItemLabel: __( 'Add new', 'woocommerce' ),
@ -91,6 +95,8 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
...uiStrings,
};
const [ isNewModalVisible, setIsNewModalVisible ] = useState( false );
const [ removingAttribute, setRemovingAttribute ] =
useState< null | ProductAttribute >();
const [ currentAttributeId, setCurrentAttributeId ] = useState<
null | string
>( null );
@ -111,15 +117,24 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
};
const handleRemove = ( attribute: ProductAttribute ) => {
handleChange(
value.filter(
( attr ) =>
getAttributeId( attr ) !== getAttributeId( attribute )
)
);
onRemove( attribute );
setRemovingAttribute( null );
};
const showRemoveConfirmation = ( attribute: ProductAttribute ) => {
if ( useRemoveConfirmationModal ) {
setRemovingAttribute( attribute );
return;
}
// eslint-disable-next-line no-alert
if ( window.confirm( uiStrings?.attributeRemoveConfirmationMessage ) ) {
handleChange(
value.filter(
( attr ) =>
getAttributeId( attr ) !== getAttributeId( attribute )
)
);
onRemove( attribute );
handleRemove( attribute );
return;
}
onRemoveCancel( attribute );
@ -231,7 +246,9 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
removeLabel={ uiStrings?.attributeRemoveLabel }
key={ getAttributeId( attr ) }
onEditClick={ () => openEditModal( attr ) }
onRemoveClick={ () => handleRemove( attr ) }
onRemoveClick={ () =>
showRemoveConfirmation( attr )
}
/>
) ) }
</Sortable>
@ -288,6 +305,22 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
attribute={ currentAttribute }
/>
) }
{ removingAttribute && (
<RemoveConfirmationModal
title={ sprintf(
__( 'Delete %(attributeName)s', 'woocommerce' ),
{ attributeName: removingAttribute.name }
) }
description={
uiStrings.attributeRemoveConfirmationModalMessage
}
onRemove={ () => handleRemove( removingAttribute ) }
onCancel={ () => {
onRemoveCancel( removingAttribute );
setRemovingAttribute( null );
} }
/>
) }
</div>
);
};

View File

@ -0,0 +1,11 @@
.woocommerce-remove-attribute-modal {
max-width: 650px;
&__buttons {
margin-top: $gap-larger;
display: flex;
flex-direction: row;
gap: 8px;
justify-content: flex-end;
}
}

View File

@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createElement } from '@wordpress/element';
import { Button, Modal } from '@wordpress/components';
type RemoveConfirmationModalProps = {
title?: string;
description?: string | React.ReactElement;
onCancel: () => void;
onRemove: () => void;
};
export const RemoveConfirmationModal: React.FC<
RemoveConfirmationModalProps
> = ( {
title = __( 'Add attributes', 'woocommerce' ),
description = '',
onCancel,
onRemove,
} ) => {
return (
<Modal
title={ title }
onRequestClose={ (
event:
| React.KeyboardEvent< Element >
| React.MouseEvent< Element >
| React.FocusEvent< Element >
) => {
if ( ! event.isPropagationStopped() ) {
onCancel();
}
} }
className="woocommerce-remove-attribute-modal"
>
{ description && <p>{ description }</p> }
<div className="woocommerce-remove-attribute-modal__buttons">
<Button
isDestructive
variant="primary"
label={ __( 'Delete', 'woocommerce' ) }
onClick={ onRemove }
>
{ __( 'Delete', 'woocommerce' ) }
</Button>
<Button
variant="tertiary"
label={ __( 'Cancel', 'woocommerce' ) }
onClick={ onCancel }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
</div>
</Modal>
);
};

View File

@ -18,7 +18,7 @@ export function getAttributeKey(
/**
* Get an attribute ID that works universally across global and local attributes.
*
* @param attribute Product attribute.
* @param attribute Product attribute.
* @return string
*/
export const getAttributeId = ( attribute: ProductAttribute ) =>

View File

@ -1,17 +1,31 @@
.woocommerce-product-variations {
ol {
@media ( min-width: #{ ($break-medium) } ) {
min-height: 420px;
}
}
display: flex;
flex-direction: column;
position: relative;
> div {
display: flex;
flex-direction: column;
flex-grow: 1;
}
&__loading {
display: flex;
flex-direction: column;
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
gap: $gap;
.components-spinner {
width: $gap-largest;
height: $gap-largest;
}
}
&__status-dot {
margin-right: $gap-smaller;
&.green {
@ -83,20 +97,6 @@
}
}
&.is-loading {
min-height: 476px;
.components-spinner {
width: 34px;
height: 34px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
position: absolute;
margin: 0;
}
}
&__footer {
padding: $gap;
}

View File

@ -5,7 +5,6 @@ import { __ } from '@wordpress/i18n';
import { Button, Spinner, Tooltip } from '@wordpress/components';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
ProductAttribute,
ProductVariation,
} from '@woocommerce/data';
import {
@ -24,7 +23,7 @@ import { CurrencyContext } from '@woocommerce/currency';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityId, useEntityProp } from '@wordpress/core-data';
import { useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
@ -47,54 +46,60 @@ export function VariationsTable() {
const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >(
{}
);
const [ entityAttributes ] = useEntityProp< ProductAttribute[] >(
'postType',
'product',
'attributes'
);
const variableAttributeTags = entityAttributes
.filter( ( attr ) => attr.variation )
.map( ( attr ) => attr.options )
.flat();
const productId = useEntityId( 'postType', 'product' );
const context = useContext( CurrencyContext );
const { formatAmount } = context;
const { isLoading, variations, totalCount } = useSelect(
( select ) => {
const {
getProductVariations,
hasFinishedResolution,
getProductVariationsTotalCount,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const requestParams = {
product_id: productId,
page: currentPage,
per_page: perPage,
order: 'asc',
orderby: 'menu_order',
};
return {
isLoading: ! hasFinishedResolution( 'getProductVariations', [
requestParams,
] ),
variations:
getProductVariations< ProductVariation[] >( requestParams ),
totalCount:
getProductVariationsTotalCount< number >( requestParams ),
};
},
[ currentPage, perPage, productId ]
);
const { isLoading, variations, totalCount, isGeneratingVariations } =
useSelect(
( select ) => {
const {
getProductVariations,
hasFinishedResolution,
getProductVariationsTotalCount,
isGeneratingVariations: getIsGeneratingVariations,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const requestParams = {
product_id: productId,
page: currentPage,
per_page: perPage,
order: 'asc',
orderby: 'menu_order',
};
return {
isLoading: ! hasFinishedResolution(
'getProductVariations',
[ requestParams ]
),
isGeneratingVariations: getIsGeneratingVariations( {
product_id: productId,
} ),
variations:
getProductVariations< ProductVariation[] >(
requestParams
),
totalCount:
getProductVariationsTotalCount< number >(
requestParams
),
};
},
[ currentPage, perPage, productId ]
);
const { updateProductVariation } = useDispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
if ( ! variations || isLoading ) {
if ( ! variations && isLoading ) {
return (
<div className="woocommerce-product-variations is-loading">
<div className="woocommerce-product-variations__loading">
<Spinner />
{ isGeneratingVariations && (
<span>
{ __( 'Generating variations…', 'woocommerce' ) }
</span>
) }
</div>
);
}
@ -121,49 +126,52 @@ export function VariationsTable() {
return (
<div className="woocommerce-product-variations">
{ isLoading ||
( isGeneratingVariations && (
<div className="woocommerce-product-variations__loading">
<Spinner />
{ isGeneratingVariations && (
<span>
{ __(
'Generating variations…',
'woocommerce'
) }
</span>
) }
</div>
) ) }
<Sortable>
{ variations.map( ( variation ) => (
<ListItem key={ `${ variation.id }` }>
<div className="woocommerce-product-variations__attributes">
{ variation.attributes
.filter( ( attribute ) =>
variableAttributeTags.includes(
attribute.option
)
)
.map( ( attribute ) => {
const tag = (
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
/* @ts-ignore Additional props are not required. */
<Tag
id={ attribute.id }
className="woocommerce-product-variations__attribute"
key={ attribute.id }
label={ truncate(
attribute.option,
{
length: PRODUCT_VARIATION_TITLE_LIMIT,
}
) }
screenReaderLabel={
attribute.option
}
/>
);
{ variation.attributes.map( ( attribute ) => {
const tag = (
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
/* @ts-ignore Additional props are not required. */
<Tag
id={ attribute.id }
className="woocommerce-product-variations__attribute"
key={ attribute.id }
label={ truncate( attribute.option, {
length: PRODUCT_VARIATION_TITLE_LIMIT,
} ) }
screenReaderLabel={ attribute.option }
/>
);
return attribute.option.length <=
PRODUCT_VARIATION_TITLE_LIMIT ? (
tag
) : (
<Tooltip
key={ attribute.id }
text={ attribute.option }
position="top center"
>
<span>{ tag }</span>
</Tooltip>
);
} ) }
return attribute.option.length <=
PRODUCT_VARIATION_TITLE_LIMIT ? (
tag
) : (
<Tooltip
key={ attribute.id }
text={ attribute.option }
position="top center"
>
<span>{ tag }</span>
</Tooltip>
);
} ) }
</div>
<div
className={ classnames(

View File

@ -4,10 +4,7 @@
import { useDispatch } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { useCallback, useState } from '@wordpress/element';
import {
Product,
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
} from '@woocommerce/data';
import { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from '@woocommerce/data';
/**
* Internal dependencies
@ -20,7 +17,6 @@ export function useProductVariationsHelper() {
'product',
'id'
);
const { saveEntityRecord } = useDispatch( 'core' );
const {
generateProductVariations: _generateProductVariations,
invalidateResolutionForStoreSelector,
@ -32,36 +28,32 @@ export function useProductVariationsHelper() {
async ( attributes: EnhancedProductAttribute[] ) => {
setIsGenerating( true );
const updateProductAttributes = async () => {
const hasVariableAttribute = attributes.some(
( attr ) => attr.variation
);
await saveEntityRecord< Promise< Product > >(
'postType',
'product',
{
id: productId,
type: hasVariableAttribute ? 'variable' : 'simple',
attributes,
}
);
};
const hasVariableAttribute = attributes.some(
( attr ) => attr.variation
);
return updateProductAttributes()
return _generateProductVariations< {
count: number;
deleted_count: number;
} >(
{
product_id: productId,
},
{
type: hasVariableAttribute ? 'variable' : 'simple',
attributes,
},
{
delete: true,
}
)
.then( () => {
return _generateProductVariations< { count: number } >( {
product_id: productId,
} );
} )
.then( ( data ) => {
if ( data.count > 0 ) {
invalidateResolutionForStoreSelector(
'getProductVariations'
);
return invalidateResolutionForStoreSelector(
'getProductVariationsTotalCount'
);
}
invalidateResolutionForStoreSelector(
'getProductVariations'
);
return invalidateResolutionForStoreSelector(
'getProductVariationsTotalCount'
);
} )
.finally( () => {
setIsGenerating( false );

View File

@ -1,38 +1,39 @@
/* Editor */
@import 'components/editor/style.scss';
@import 'components/block-editor/style.scss';
@import "components/editor/style.scss";
@import "components/block-editor/style.scss";
/* Structural */
@import 'components/tabs/style.scss';
@import 'components/product-section-layout/style.scss';
@import 'components/header/style.scss';
@import 'components/footer/style.scss';
@import 'components/add-new-shipping-class-modal/style.scss';
@import "components/tabs/style.scss";
@import "components/product-section-layout/style.scss";
@import "components/header/style.scss";
@import "components/footer/style.scss";
@import "components/add-new-shipping-class-modal/style.scss";
/* Components */
@import 'components/content-preview/style.scss';
@import 'components/radio-field/style.scss';
@import 'components/iframe-editor/style.scss';
@import 'components/details-categories-field/style.scss';
@import 'components/details-categories-field/create-category-modal.scss';
@import 'components/modal-editor/style.scss';
@import 'components/feedback-bar/style.scss';
@import 'components/product-mvp-feedback-modal/style.scss';
@import 'components/edit-product-link-modal/style.scss';
@import 'components/edit-product-link-modal/style.scss';
@import 'components/details-categories-field/style.scss';
@import 'components/details-categories-field/create-category-modal.scss';
@import 'components/attribute-control/attribute-field.scss';
@import 'components/attribute-control/edit-attribute-modal.scss';
@import 'components/attribute-control/new-attribute-modal.scss';
@import 'components/attribute-input-field/attribute-input-field.scss';
@import 'components/attribute-list-item/attribute-list-item.scss';
@import 'components/attribute-term-input-field/attribute-term-input-field.scss';
@import 'components/variations-table/styles.scss';
@import "components/content-preview/style.scss";
@import "components/radio-field/style.scss";
@import "components/iframe-editor/style.scss";
@import "components/details-categories-field/style.scss";
@import "components/details-categories-field/create-category-modal.scss";
@import "components/modal-editor/style.scss";
@import "components/feedback-bar/style.scss";
@import "components/product-mvp-feedback-modal/style.scss";
@import "components/edit-product-link-modal/style.scss";
@import "components/edit-product-link-modal/style.scss";
@import "components/details-categories-field/style.scss";
@import "components/details-categories-field/create-category-modal.scss";
@import "components/attribute-control/attribute-field.scss";
@import "components/attribute-control/edit-attribute-modal.scss";
@import "components/attribute-control/new-attribute-modal.scss";
@import "components/attribute-control/remove-confirmation-modal.scss";
@import "components/attribute-input-field/attribute-input-field.scss";
@import "components/attribute-list-item/attribute-list-item.scss";
@import "components/attribute-term-input-field/attribute-term-input-field.scss";
@import "components/variations-table/styles.scss";
/* Field Blocks */
@import 'blocks/style.scss'
@import "blocks/style.scss";

View File

@ -8,7 +8,7 @@ import { recordEvent } from '@woocommerce/tracks';
/**
* Get additional props to be passed to all checkbox inputs.
*
* @param name Name of the checkbox.
* @param name Name of the checkbox.
* @return Props.
*/
export function getCheckboxTracks< T = Product >( name: string ) {

View File

@ -11,8 +11,8 @@ import { AUTO_DRAFT_NAME } from './constants';
/**
* Get the header title using the product name.
*
* @param editedProductName Name value entered for the product.
* @param initialProductName Name already persisted to the database.
* @param editedProductName Name value entered for the product.
* @param initialProductName Name already persisted to the database.
* @return The new title
*/
export const getHeaderTitle = (

View File

@ -11,9 +11,9 @@ import { AUTO_DRAFT_NAME } from './constants';
/**
* Get the product title for use in the header.
*
* @param name Name value entered for the product.
* @param type Product type.
* @param persistedName Name already persisted to the database.
* @param name Name value entered for the product.
* @param type Product type.
* @param persistedName Name already persisted to the database.
* @return string
*/
export const getProductTitle = (

View File

@ -11,7 +11,7 @@ import { PRODUCT_VARIATION_TITLE_LIMIT } from './constants';
/**
* Get the product variation title for use in the header.
*
* @param productVariation The product variation.
* @param productVariation The product variation.
* @return string
*/
export const getProductVariationTitle = (
@ -31,7 +31,7 @@ export const getProductVariationTitle = (
/**
* Get the truncated product variation title.
*
* @param productVariation The product variation.
* @param productVariation The product variation.
* @return string
*/
export const getTruncatedProductVariationTitle = (

View File

@ -16,7 +16,7 @@ interface BlockRepresentation< T extends Record< string, object > > {
/**
* Function to register an individual block.
*
* @param block The block to be registered.
* @param block The block to be registered.
* @return The block, if it has been successfully registered; otherwise `undefined`.
*/
export function initBlock<

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add delete option to generate variations API, to auto delete unmatched variations.

View File

@ -44,6 +44,10 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'description' => __( 'Unique identifier for the variable product.', 'woocommerce' ),
'type' => 'integer',
),
'delete' => array(
'description' => __( 'Deletes unused variations.', 'woocommerce' ),
'type' => 'boolean',
),
),
array(
'methods' => WP_REST_Server::CREATABLE,
@ -926,6 +930,40 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
return $params;
}
/**
* Deletes all unmatched variations (aka duplicates).
*
* @param WC_Product $product Variable product.
* @return int Number of deleted variations.
*/
private function delete_unmatched_product_variations( $product ) {
$deleted_count = 0;
if ( ! $product ) {
return $deleted_count;
}
$attributes = wc_list_pluck( array_filter( $product->get_attributes(), 'wc_attributes_array_filter_variation' ), 'get_slugs' );
// Get existing variations so we don't create duplicates.
$existing_variations = array_map( 'wc_get_product', $product->get_children() );
$possible_attribute_combinations = array_reverse( wc_array_cartesian( $attributes ) );
foreach ( $existing_variations as $existing_variation ) {
$matching_attribute_key = array_search( $existing_variation->get_attributes(), $possible_attribute_combinations ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
if ( $matching_attribute_key !== false ) {
// We only want one possible variation for each possible attribute combination.
unset( $possible_attribute_combinations[ $matching_attribute_key ] );
continue;
}
$existing_variation->delete( true );
$deleted_count ++;
}
return $deleted_count;
}
/**
* Generate all variations for a given product.
*
@ -947,6 +985,11 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
$data_store = $product->get_data_store();
$response['count'] = $data_store->create_all_product_variations( $product, Constants::get_constant( 'WC_MAX_LINKED_VARIATIONS' ) );
if ( isset( $request['delete'] ) && $request['delete'] ) {
$deleted_count = $this->delete_unmatched_product_variations( $product );
$response['deleted_count'] = $deleted_count;
}
$data_store->sort_all_product_variations( $product->get_id() );
return rest_ensure_response( $response );

View File

@ -497,4 +497,177 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'parent', $variation['manage_stock'] );
}
/**
* Test updating a variation stock.
*
* @since 8.1.0
*/
public function test_generate_new_variations() {
wp_set_current_user( $this->user );
$product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_variation_product();
$color_attribute_data = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_attribute( 'color', array( 'red', 'blue', 'yellow' ) );
$color_attribute = new WC_Product_Attribute();
$color_attribute->set_id( $color_attribute_data['attribute_id'] );
$color_attribute->set_name( $color_attribute_data['attribute_taxonomy'] );
$color_attribute->set_options( $color_attribute_data['term_ids'] );
$color_attribute->set_position( 1 );
$color_attribute->set_visible( true );
$color_attribute->set_variation( true );
$attributes = $product->get_attributes();
$attributes[] = $color_attribute;
$product->set_attributes( $attributes );
$product->save();
// Set stock to true.
$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
$response = $this->server->dispatch( $request );
$variation = $response->get_data();
$product = wc_get_product( $product->get_id() );
$children = $product->get_children();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 6, $variation['count'] );
// Generated 2 new ones.
$this->assertEquals( 8, count( $children ) );
}
/**
* Test updating a variation stock.
*
* @since 3.5.0
*/
public function test_generate_new_variations_with_delete_set_to_true() {
wp_set_current_user( $this->user );
$product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_variation_product();
$color_attribute_data = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_attribute( 'color', array( 'red', 'blue', 'yellow' ) );
$color_attribute = new WC_Product_Attribute();
$color_attribute->set_id( $color_attribute_data['attribute_id'] );
$color_attribute->set_name( $color_attribute_data['attribute_taxonomy'] );
$color_attribute->set_options( $color_attribute_data['term_ids'] );
$color_attribute->set_position( 1 );
$color_attribute->set_visible( true );
$color_attribute->set_variation( true );
$attributes = $product->get_attributes();
$attributes[] = $color_attribute;
$product->set_attributes( $attributes );
$product->save();
// Set stock to true.
$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
$request->set_body_params( array( 'delete' => true ) );
$response = $this->server->dispatch( $request );
$variation = $response->get_data();
$product = wc_get_product( $product->get_id() );
$children = $product->get_children();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 6, $variation['count'] );
$this->assertEquals( 2, $variation['deleted_count'] );
// Generated 2 new ones.
$this->assertEquals( 6, count( $children ) );
}
/**
* Test updating a variation stock.
*
* @since 3.5.0
*/
public function test_delete_unmatched_variations_when_removing_term() {
wp_set_current_user( $this->user );
$product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_variation_product();
$color_attribute = new WC_Product_Attribute();
$color_attribute->set_name( 'color' );
$color_attribute->set_visible( true );
$color_attribute->set_variation( true );
$color_attribute->set_options( array( 'red', 'green', 'blue' ) );
$color_attribute->set_position( 1 );
$attributes = $product->get_attributes();
$attributes[] = $color_attribute;
$product->set_attributes( $attributes );
$product->save();
// Set stock to true.
$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
$request->set_body_params( array( 'delete' => true ) );
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$product_attributes = get_post_meta( $product->get_id() , '_product_attributes' );
// Removing blue term from product.
$product = wc_get_product( $product->get_id() );
$attributes = $product->get_attributes();
$color_attribute->set_options( array( 'red', 'green' ) );
$attributes['color'] = $color_attribute;
$product->set_attributes( $attributes );
$product->save();
$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
$request->set_body_params( array( 'delete' => true ) );
$response = $this->server->dispatch( $request );
$variation = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 0, $variation['count'] );
$this->assertEquals( 2, $variation['deleted_count'] );
$product = wc_get_product( $product->get_id() );
// Removed two.
$this->assertEquals( 4, count( $product->get_children() ) );
}
/**
* Test updating a variation stock.
*
* @since 3.5.0
*/
public function test_delete_unmatched_variations_when_removing_attribute() {
wp_set_current_user( $this->user );
$product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_variation_product();
$color_attribute = new WC_Product_Attribute();
$color_attribute->set_name( 'color' );
$color_attribute->set_visible( true );
$color_attribute->set_variation( true );
$color_attribute->set_options( array( 'red', 'yellow', 'green' ) );
$color_attribute->set_position( 1 );
$attributes = $product->get_attributes();
$attributes[] = $color_attribute;
$product->set_attributes( $attributes );
$product->save();
// Set stock to true.
$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
$request->set_body_params( array( 'delete' => true ) );
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$product_attributes = get_post_meta( $product->get_id() , '_product_attributes' );
// Removing color attribute from product.
$product = wc_get_product( $product->get_id() );
$attributes = $product->get_attributes();
unset( $attributes['color'] );
$product->set_attributes( $attributes );
$product->save();
$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/variations/generate' );
$request->set_body_params( array( 'delete' => true ) );
$response = $this->server->dispatch( $request );
$variation = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 0, $variation['count'] );
$this->assertEquals( 4, $variation['deleted_count'] );
$product = wc_get_product( $product->get_id() );
// Removed four.
$this->assertEquals( 2, count( $product->get_children() ) );
}
}