From 3edd1bd8236c29312d6bead076112d562deb1f7c Mon Sep 17 00:00:00 2001 From: louwie17 Date: Mon, 14 Aug 2023 16:35:16 -0300 Subject: [PATCH] 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 --- .../add-39687_auto_delete_variations | 4 + packages/js/data/src/crud/index.ts | 13 +- packages/js/data/src/crud/reducer.ts | 10 +- .../src/product-variations/action-types.ts | 2 + .../js/data/src/product-variations/actions.ts | 62 ++++++- .../src/product-variations/crud-actions.ts | 5 + .../js/data/src/product-variations/index.ts | 10 + .../js/data/src/product-variations/reducer.ts | 73 ++++++++ .../data/src/product-variations/selectors.ts | 24 +++ .../js/data/src/product-variations/types.ts | 4 + .../add-39687_auto_delete_variations | 4 + .../src/blocks/variation-items/editor.scss | 5 +- .../src/blocks/variation-options/edit.tsx | 5 +- .../attribute-control/attribute-control.tsx | 49 ++++- .../remove-confirmation-modal.scss | 11 ++ .../remove-confirmation-modal.tsx | 59 ++++++ .../src/components/attribute-control/utils.ts | 2 +- .../components/variations-table/styles.scss | 38 ++-- .../variations-table/variations-table.tsx | 162 ++++++++-------- .../hooks/use-product-variations-helper.ts | 58 +++--- packages/js/product-editor/src/style.scss | 55 +++--- .../src/utils/get-checkbox-tracks.ts | 2 +- .../src/utils/get-header-title.ts | 4 +- .../src/utils/get-product-title.ts | 6 +- .../src/utils/get-product-variation-title.ts | 4 +- .../js/product-editor/src/utils/init-block.ts | 2 +- .../add-39687_auto_delete_variations | 4 + ...-wc-rest-product-variations-controller.php | 43 +++++ .../Tests/Version3/product-variations.php | 173 ++++++++++++++++++ 29 files changed, 698 insertions(+), 195 deletions(-) create mode 100644 packages/js/data/changelog/add-39687_auto_delete_variations create mode 100644 packages/js/data/src/product-variations/crud-actions.ts create mode 100644 packages/js/data/src/product-variations/reducer.ts create mode 100644 packages/js/data/src/product-variations/selectors.ts create mode 100644 packages/js/product-editor/changelog/add-39687_auto_delete_variations create mode 100644 packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.scss create mode 100644 packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.tsx create mode 100644 plugins/woocommerce/changelog/add-39687_auto_delete_variations diff --git a/packages/js/data/changelog/add-39687_auto_delete_variations b/packages/js/data/changelog/add-39687_auto_delete_variations new file mode 100644 index 00000000000..8972d0da78e --- /dev/null +++ b/packages/js/data/changelog/add-39687_auto_delete_variations @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add isGeneratingVariations selector to product variations store. diff --git a/packages/js/data/src/crud/index.ts b/packages/js/data/src/crud/index.ts index 6274f94fcad..dbb50738992 100644 --- a/packages/js/data/src/crud/index.ts +++ b/packages/js/data/src/crud/index.ts @@ -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 }, diff --git a/packages/js/data/src/crud/reducer.ts b/packages/js/data/src/crud/reducer.ts index baa2de63c1f..fedff709bff 100644 --- a/packages/js/data/src/crud/reducer.ts +++ b/packages/js/data/src/crud/reducer.ts @@ -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; }; diff --git a/packages/js/data/src/product-variations/action-types.ts b/packages/js/data/src/product-variations/action-types.ts index 89489d516ab..af6f8f0c429 100644 --- a/packages/js/data/src/product-variations/action-types.ts +++ b/packages/js/data/src/product-variations/action-types.ts @@ -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', } diff --git a/packages/js/data/src/product-variations/actions.ts b/packages/js/data/src/product-variations/actions.ts index 613b3864ce9..1edf39ab318 100644 --- a/packages/js/data/src/product-variations/actions.ts +++ b/packages/js/data/src/product-variations/actions.ts @@ -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 +>; diff --git a/packages/js/data/src/product-variations/crud-actions.ts b/packages/js/data/src/product-variations/crud-actions.ts new file mode 100644 index 00000000000..97f16d00779 --- /dev/null +++ b/packages/js/data/src/product-variations/crud-actions.ts @@ -0,0 +1,5 @@ +export enum CRUD_ACTIONS { + GENERATE_VARIATIONS = 'GENERATE_VARIATIONS', +} + +export default CRUD_ACTIONS; diff --git a/packages/js/data/src/product-variations/index.ts b/packages/js/data/src/product-variations/index.ts index c9b06818f33..2c07511339e 100644 --- a/packages/js/data/src/product-variations/index.ts +++ b/packages/js/data/src/product-variations/index.ts @@ -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, }, } ); diff --git a/packages/js/data/src/product-variations/reducer.ts b/packages/js/data/src/product-variations/reducer.ts new file mode 100644 index 00000000000..66193066564 --- /dev/null +++ b/packages/js/data/src/product-variations/reducer.ts @@ -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 }; diff --git a/packages/js/data/src/product-variations/selectors.ts b/packages/js/data/src/product-variations/selectors.ts new file mode 100644 index 00000000000..9f23f326c7b --- /dev/null +++ b/packages/js/data/src/product-variations/selectors.ts @@ -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 ]; +}; diff --git a/packages/js/data/src/product-variations/types.ts b/packages/js/data/src/product-variations/types.ts index de021010a2f..344731f4a89 100644 --- a/packages/js/data/src/product-variations/types.ts +++ b/packages/js/data/src/product-variations/types.ts @@ -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' > & diff --git a/packages/js/product-editor/changelog/add-39687_auto_delete_variations b/packages/js/product-editor/changelog/add-39687_auto_delete_variations new file mode 100644 index 00000000000..8a273737487 --- /dev/null +++ b/packages/js/product-editor/changelog/add-39687_auto_delete_variations @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add new option in attributes control for a delete confirmation modal instead of the window.alert. diff --git a/packages/js/product-editor/src/blocks/variation-items/editor.scss b/packages/js/product-editor/src/blocks/variation-items/editor.scss index e2b824187a2..4216560a91b 100644 --- a/packages/js/product-editor/src/blocks/variation-items/editor.scss +++ b/packages/js/product-editor/src/blocks/variation-items/editor.scss @@ -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 { diff --git a/packages/js/product-editor/src/blocks/variation-options/edit.tsx b/packages/js/product-editor/src/blocks/variation-options/edit.tsx index 543cafc5379..75672074385 100644 --- a/packages/js/product-editor/src/blocks/variation-options/edit.tsx +++ b/packages/js/product-editor/src/blocks/variation-options/edit.tsx @@ -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' ), } } diff --git a/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx b/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx index 83c9a9a7194..d3c920df142 100644 --- a/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx +++ b/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx @@ -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 ) + } /> ) ) } @@ -288,6 +305,22 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( { attribute={ currentAttribute } /> ) } + { removingAttribute && ( + handleRemove( removingAttribute ) } + onCancel={ () => { + onRemoveCancel( removingAttribute ); + setRemovingAttribute( null ); + } } + /> + ) } ); }; diff --git a/packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.scss b/packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.scss new file mode 100644 index 00000000000..1e832ded9b4 --- /dev/null +++ b/packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.scss @@ -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; + } +} diff --git a/packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.tsx b/packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.tsx new file mode 100644 index 00000000000..876ef370457 --- /dev/null +++ b/packages/js/product-editor/src/components/attribute-control/remove-confirmation-modal.tsx @@ -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 ( + + | React.MouseEvent< Element > + | React.FocusEvent< Element > + ) => { + if ( ! event.isPropagationStopped() ) { + onCancel(); + } + } } + className="woocommerce-remove-attribute-modal" + > + { description &&

{ description }

} + +
+ + +
+
+ ); +}; diff --git a/packages/js/product-editor/src/components/attribute-control/utils.ts b/packages/js/product-editor/src/components/attribute-control/utils.ts index 20b70f22627..73c10567fbb 100644 --- a/packages/js/product-editor/src/components/attribute-control/utils.ts +++ b/packages/js/product-editor/src/components/attribute-control/utils.ts @@ -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 ) => diff --git a/packages/js/product-editor/src/components/variations-table/styles.scss b/packages/js/product-editor/src/components/variations-table/styles.scss index 6c62c10a0b4..2760045b9be 100644 --- a/packages/js/product-editor/src/components/variations-table/styles.scss +++ b/packages/js/product-editor/src/components/variations-table/styles.scss @@ -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; } diff --git a/packages/js/product-editor/src/components/variations-table/variations-table.tsx b/packages/js/product-editor/src/components/variations-table/variations-table.tsx index c57d599e91c..4e50c9b80d5 100644 --- a/packages/js/product-editor/src/components/variations-table/variations-table.tsx +++ b/packages/js/product-editor/src/components/variations-table/variations-table.tsx @@ -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 ( -
+
+ { isGeneratingVariations && ( + + { __( 'Generating variations…', 'woocommerce' ) } + + ) }
); } @@ -121,49 +126,52 @@ export function VariationsTable() { return (
+ { isLoading || + ( isGeneratingVariations && ( +
+ + { isGeneratingVariations && ( + + { __( + 'Generating variations…', + 'woocommerce' + ) } + + ) } +
+ ) ) } { variations.map( ( variation ) => (
- { 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. */ - - ); + { variation.attributes.map( ( attribute ) => { + const tag = ( + /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ + /* @ts-ignore Additional props are not required. */ + + ); - return attribute.option.length <= - PRODUCT_VARIATION_TITLE_LIMIT ? ( - tag - ) : ( - - { tag } - - ); - } ) } + return attribute.option.length <= + PRODUCT_VARIATION_TITLE_LIMIT ? ( + tag + ) : ( + + { tag } + + ); + } ) }
{ 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 ); diff --git a/packages/js/product-editor/src/style.scss b/packages/js/product-editor/src/style.scss index 21f785ae6bb..0df9be94658 100644 --- a/packages/js/product-editor/src/style.scss +++ b/packages/js/product-editor/src/style.scss @@ -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"; diff --git a/packages/js/product-editor/src/utils/get-checkbox-tracks.ts b/packages/js/product-editor/src/utils/get-checkbox-tracks.ts index 8d219c91681..4e7d2536613 100644 --- a/packages/js/product-editor/src/utils/get-checkbox-tracks.ts +++ b/packages/js/product-editor/src/utils/get-checkbox-tracks.ts @@ -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 ) { diff --git a/packages/js/product-editor/src/utils/get-header-title.ts b/packages/js/product-editor/src/utils/get-header-title.ts index 9ebfd701855..fa417e9f382 100644 --- a/packages/js/product-editor/src/utils/get-header-title.ts +++ b/packages/js/product-editor/src/utils/get-header-title.ts @@ -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 = ( diff --git a/packages/js/product-editor/src/utils/get-product-title.ts b/packages/js/product-editor/src/utils/get-product-title.ts index dc090527ca0..4ac40537acc 100644 --- a/packages/js/product-editor/src/utils/get-product-title.ts +++ b/packages/js/product-editor/src/utils/get-product-title.ts @@ -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 = ( diff --git a/packages/js/product-editor/src/utils/get-product-variation-title.ts b/packages/js/product-editor/src/utils/get-product-variation-title.ts index 0480311c1bd..97f62fb18bd 100644 --- a/packages/js/product-editor/src/utils/get-product-variation-title.ts +++ b/packages/js/product-editor/src/utils/get-product-variation-title.ts @@ -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 = ( diff --git a/packages/js/product-editor/src/utils/init-block.ts b/packages/js/product-editor/src/utils/init-block.ts index 06404008120..49d157b4dd4 100644 --- a/packages/js/product-editor/src/utils/init-block.ts +++ b/packages/js/product-editor/src/utils/init-block.ts @@ -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< diff --git a/plugins/woocommerce/changelog/add-39687_auto_delete_variations b/plugins/woocommerce/changelog/add-39687_auto_delete_variations new file mode 100644 index 00000000000..9265d5602db --- /dev/null +++ b/plugins/woocommerce/changelog/add-39687_auto_delete_variations @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add delete option to generate variations API, to auto delete unmatched variations. diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php index 184e9888dbb..d8335b9eb8e 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php @@ -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 ); diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php index 640ee350b03..e65750223a4 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php @@ -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() ) ); + } }