From 5f2c656e6b4f8ee3749d05c2d0672addc88cf1a5 Mon Sep 17 00:00:00 2001 From: louwie17 Date: Wed, 19 Oct 2022 16:28:29 -0300 Subject: [PATCH] Add/34331 add attributes modal (#34999) * Add initial add attribute modal * Add async select control component and add attribute terms * Make use of AsyncSelectControl for attributes * Rearranged the add attribute form to make removing easier * Make sure add button is disabled if fields are empty * Remove the use of AsyncSelectControl for now * Add disabled option and fix merge conflict * Add attribute modal tests * Remove unused trigger drag * Add popover slot * Small update to select control and fix multi selection in term field * Add tests for attribute and attribute term fields * Add changelogs * Small fix after merge conflict * Fix some styling and issue with select control when clearing item * Fix lint error * Fix up some styling issues after rebase * Fix formatting, some styling issues, and address some PR feedback * And confirmation dialog for closing the modal. --- .../changelog/add-34331_add_attributes_modal | 4 + .../src/experimental-select-control/menu.tsx | 4 +- .../select-control.tsx | 34 +- .../changelog/add-34331_add_attributes_modal | 4 + packages/js/data/src/crud/resolvers.ts | 2 +- packages/js/data/src/index.ts | 8 + .../data/src/product-attribute-terms/types.ts | 2 +- .../js/data/src/product-attributes/types.ts | 8 +- .../attribute-field/add-attribute-modal.scss | 63 ++++ .../attribute-field/add-attribute-modal.tsx | 328 ++++++++++++++++++ .../attribute-field/attribute-field.scss | 2 +- .../attribute-field/attribute-field.tsx | 102 ++++-- .../test/add-attribute-modal.spec.tsx | 289 +++++++++++++++ .../attribute-input-field.tsx | 115 ++++++ .../fields/attribute-input-field/index.ts | 1 + .../test/attribute-input-field.spec.tsx | 226 ++++++++++++ .../attribute-term-input-field.scss | 5 + .../attribute-term-input-field.tsx | 190 ++++++++++ .../attribute-term-input-field/index.ts | 1 + .../test/attribute-term-input-field.spec.tsx | 208 +++++++++++ .../fields/category-field/category-field.scss | 3 - .../client/products/product-form-actions.scss | 4 +- .../products/sections/attributes-section.tsx | 7 +- .../changelog/add-34331_add_attributes_modal | 4 + 24 files changed, 1553 insertions(+), 61 deletions(-) create mode 100644 packages/js/components/changelog/add-34331_add_attributes_modal create mode 100644 packages/js/data/changelog/add-34331_add_attributes_modal create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-input-field/index.ts create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-input-field/test/attribute-input-field.spec.tsx create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.scss create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.tsx create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/index.ts create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/test/attribute-term-input-field.spec.tsx create mode 100644 plugins/woocommerce/changelog/add-34331_add_attributes_modal diff --git a/packages/js/components/changelog/add-34331_add_attributes_modal b/packages/js/components/changelog/add-34331_add_attributes_modal new file mode 100644 index 00000000000..e252934b519 --- /dev/null +++ b/packages/js/components/changelog/add-34331_add_attributes_modal @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add disabled option to the Select Control input component and alter the onInputChange callback diff --git a/packages/js/components/src/experimental-select-control/menu.tsx b/packages/js/components/src/experimental-select-control/menu.tsx index 5b5cd8de526..f4ad3a1878b 100644 --- a/packages/js/components/src/experimental-select-control/menu.tsx +++ b/packages/js/components/src/experimental-select-control/menu.tsx @@ -9,6 +9,7 @@ import { useRef, useState, createPortal, + Children, } from '@wordpress/element'; /** @@ -56,8 +57,7 @@ export const Menu = ( { 'woocommerce-experimental-select-control__popover-menu', { 'is-open': isOpen, - 'has-results': - Array.isArray( children ) && children.length > 0, + 'has-results': Children.count( children ) > 0, } ) } position="bottom center" diff --git a/packages/js/components/src/experimental-select-control/select-control.tsx b/packages/js/components/src/experimental-select-control/select-control.tsx index f6bc95457d5..8545e652112 100644 --- a/packages/js/components/src/experimental-select-control/select-control.tsx +++ b/packages/js/components/src/experimental-select-control/select-control.tsx @@ -48,7 +48,10 @@ type SelectControlProps< ItemType > = { ) => ItemType[]; hasExternalTags?: boolean; multiple?: boolean; - onInputChange?: ( value: string | undefined ) => void; + onInputChange?: ( + value: string | undefined, + changes: Partial< Omit< UseComboboxState< ItemType >, 'inputValue' > > + ) => void; onRemove?: ( item: ItemType ) => void; onSelect?: ( selected: ItemType ) => void; onFocus?: ( data: { inputValue: string } ) => void; @@ -59,6 +62,7 @@ type SelectControlProps< ItemType > = { placeholder?: string; selected: ItemType | ItemType[] | null; className?: string; + disabled?: boolean; }; export const selectControlStateChangeTypes = useCombobox.stateChangeTypes; @@ -102,6 +106,7 @@ function SelectControl< ItemType = DefaultItemType >( { placeholder, selected, className, + disabled, }: SelectControlProps< ItemType > ) { const [ isFocused, setIsFocused ] = useState( false ); const [ inputValue, setInputValue ] = useState( '' ); @@ -150,16 +155,14 @@ function SelectControl< ItemType = DefaultItemType >( { initialSelectedItem: singleSelectedItem, inputValue, items: filteredItems, - selectedItem: multiple ? null : undefined, + selectedItem: multiple ? null : singleSelectedItem, itemToString: getItemLabel, onSelectedItemChange: ( { selectedItem } ) => selectedItem && onSelect( selectedItem ), - onInputValueChange: ( changes ) => { - if ( changes.inputValue !== undefined ) { - setInputValue( changes.inputValue ); - if ( changes.isOpen ) { - onInputChange( changes.inputValue ); - } + onInputValueChange: ( { inputValue: value, ...changes } ) => { + if ( value !== undefined ) { + setInputValue( value ); + onInputChange( value, changes ); } }, stateReducer: ( state, actionAndChanges ) => { @@ -225,12 +228,14 @@ function SelectControl< ItemType = DefaultItemType >( { > { /* Downshift's getLabelProps handles the necessary label attributes. */ } { /* eslint-disable jsx-a11y/label-has-for */ } - + { label && ( + + ) } { /* eslint-enable jsx-a11y/label-has-for */ } ( { }, onBlur: () => setIsFocused( false ), placeholder, + disabled, } ) } > <> diff --git a/packages/js/data/changelog/add-34331_add_attributes_modal b/packages/js/data/changelog/add-34331_add_attributes_modal new file mode 100644 index 00000000000..49cb315e249 --- /dev/null +++ b/packages/js/data/changelog/add-34331_add_attributes_modal @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update product attribute type name and export the product attribute types. diff --git a/packages/js/data/src/crud/resolvers.ts b/packages/js/data/src/crud/resolvers.ts index 6ab06885867..ecfeef5b258 100644 --- a/packages/js/data/src/crud/resolvers.ts +++ b/packages/js/data/src/crud/resolvers.ts @@ -67,7 +67,7 @@ export const createResolvers = ( { } try { - const path = getRestPath( namespace, {}, urlParameters ); + const path = getRestPath( namespace, query || {}, urlParameters ); const { items, totalCount }: { items: Item[]; totalCount: number } = yield request< ItemQuery, Item >( path, resourceQuery ); diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index 8cde4e16128..be23c55eff3 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -76,7 +76,15 @@ export * from './countries/types'; export * from './onboarding/types'; export * from './plugins/types'; export * from './products/types'; +export { + QueryProductAttribute, + ProductAttributeSelectors, +} from './product-attributes/types'; export * from './product-shipping-classes/types'; +export { + ProductAttributeTerm, + ProductAttributeTermsSelectors, +} from './product-attribute-terms/types'; export * from './orders/types'; export { ProductCategory, diff --git a/packages/js/data/src/product-attribute-terms/types.ts b/packages/js/data/src/product-attribute-terms/types.ts index eb451eafc8a..6f539fb3df4 100644 --- a/packages/js/data/src/product-attribute-terms/types.ts +++ b/packages/js/data/src/product-attribute-terms/types.ts @@ -8,7 +8,7 @@ import { DispatchFromMap } from '@automattic/data-stores'; */ import { CrudActions, CrudSelectors } from '../crud/types'; -type ProductAttributeTerm = { +export type ProductAttributeTerm = { id: number; slug: string; name: string; diff --git a/packages/js/data/src/product-attributes/types.ts b/packages/js/data/src/product-attributes/types.ts index 11f38ddad18..7222846dead 100644 --- a/packages/js/data/src/product-attributes/types.ts +++ b/packages/js/data/src/product-attributes/types.ts @@ -8,7 +8,7 @@ import { DispatchFromMap } from '@automattic/data-stores'; */ import { CrudActions, CrudSelectors } from '../crud/types'; -type ProductAttribute = { +export type QueryProductAttribute = { id: number; slug: string; name: string; @@ -24,19 +24,19 @@ type Query = { type ReadOnlyProperties = 'id'; type MutableProperties = Partial< - Omit< ProductAttribute, ReadOnlyProperties > + Omit< QueryProductAttribute, ReadOnlyProperties > >; type ProductAttributeActions = CrudActions< 'ProductAttribute', - ProductAttribute, + QueryProductAttribute, MutableProperties >; export type ProductAttributeSelectors = CrudSelectors< 'ProductAttribute', 'ProductAttributes', - ProductAttribute, + QueryProductAttribute, Query, MutableProperties >; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss b/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss new file mode 100644 index 00000000000..3453461ef8a --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss @@ -0,0 +1,63 @@ +.woocommerce-add-attribute-modal { + .components-notice.is-info { + margin-left: 0; + margin-right: 0; + background-color: #f0f6fc; + } + + &__add-attribute { + margin-top: $gap-small; + } + + &__buttons { + margin-top: $gap-larger; + display: flex; + flex-direction: row; + gap: 8px; + justify-content: flex-end; + } + + .components-modal__content { + display: flex; + flex-direction: column; + } + + &__body { + min-height: 200px; + flex: 1 1 auto; + overflow: auto; + } + + &__table { + width: 100%; + margin-top: $gap-large; + + th { + text-align: left; + color: $gray-700; + font-weight: normal; + text-transform: uppercase; + } + } + &__table-header { + padding: 0 0 $gap; + } + &__table-header, + &__table-row { + display: grid; + grid-template-columns: 40% 55% 5%; + border-bottom: 1px solid $gray-300; + align-items: center; + } + &__table-row { + padding: $gap-large 0; + td:not(:last-child) { + margin-right: $gap; + } + } + + &__table-attribute-trash-column { + display: flex; + justify-content: center; + } +} diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx new file mode 100644 index 00000000000..6e2e4e3764d --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx @@ -0,0 +1,328 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { trash } from '@wordpress/icons'; +import { ProductAttribute, ProductAttributeTerm } from '@woocommerce/data'; +import { Form } from '@woocommerce/components'; +import { + Button, + Modal, + Notice, + // @ts-expect-error ConfirmDialog is not part of the typescript definition yet. + __experimentalConfirmDialog as ConfirmDialog, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './add-attribute-modal.scss'; +import { AttributeInputField } from '../attribute-input-field'; +import { AttributeTermInputField } from '../attribute-term-input-field'; + +type CreateCategoryModalProps = { + onCancel: () => void; + onAdd: ( newCategories: ProductAttribute[] ) => void; + selectedAttributeIds?: number[]; +}; + +type AttributeForm = { + attributes: { + attribute?: ProductAttribute; + terms: ProductAttributeTerm[]; + }[]; +}; + +export const AddAttributeModal: React.FC< CreateCategoryModalProps > = ( { + onCancel, + onAdd, + selectedAttributeIds = [], +} ) => { + const [ showConfirmClose, setShowConfirmClose ] = useState( false ); + const addAnother = ( + values: AttributeForm, + setValue: ( + name: string, + value: AttributeForm[ keyof AttributeForm ] + ) => void + ) => { + setValue( 'attributes', [ + ...values.attributes, + { + attribute: undefined, + terms: [], + }, + ] ); + }; + + const onAddingAttributes = ( values: AttributeForm ) => { + const newAttributesToAdd: ProductAttribute[] = []; + values.attributes.forEach( ( attr ) => { + if ( + attr.attribute && + attr.attribute.name && + attr.terms.length > 0 + ) { + newAttributesToAdd.push( { + ...( attr.attribute as ProductAttribute ), + options: attr.terms.map( ( term ) => term.name ), + } ); + } + } ); + onAdd( newAttributesToAdd ); + }; + + const onRemove = ( + index: number, + values: AttributeForm, + setValue: ( + name: string, + value: AttributeForm[ keyof AttributeForm ] + ) => void + ) => { + if ( values.attributes.length > 1 ) { + setValue( + 'attributes', + values.attributes.filter( ( val, i ) => i !== index ) + ); + } else { + setValue( `attributes[${ index }]`, [ + { attribute: undefined, terms: [] }, + ] ); + } + }; + + const focusValueField = ( index: number ) => { + const valueInputField: HTMLInputElement | null = document.querySelector( + '.woocommerce-add-attribute-modal__table-row-' + + index + + ' .woocommerce-add-attribute-modal__table-attribute-value-column .woocommerce-experimental-select-control__input' + ); + if ( valueInputField ) { + setTimeout( () => { + valueInputField.focus(); + }, 0 ); + } + }; + + const onClose = ( values: AttributeForm ) => { + const hasValuesSet = values.attributes.some( + ( value ) => value?.attribute?.id && value?.terms?.length > 0 + ); + if ( hasValuesSet ) { + setShowConfirmClose( true ); + } else { + onCancel(); + } + }; + + return ( + <> + + initialValues={ { + attributes: [ { attribute: undefined, terms: [] } ], + } } + > + { ( { + values, + setValue, + }: { + values: AttributeForm; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setValue: ( name: string, value: any ) => void; + } ) => { + return ( + onClose( values ) } + className="woocommerce-add-attribute-modal" + > + +

+ { __( + 'By default, attributes are filterable and visible on the product page. You can change these settings for each attribute separately later.', + 'woocommerce' + ) } +

+
+ +
+ + + + + + + + + { values.attributes.map( + ( { attribute, terms }, index ) => ( + + + + + + ) + ) } + +
AttributeValues
+ { + setValue( + 'attributes[' + + index + + '].attribute', + val + ); + if ( val ) { + focusValueField( + index + ); + } + } } + filteredAttributeIds={ [ + ...selectedAttributeIds, + ...values.attributes + .map( + ( + attr + ) => + attr + ?.attribute + ?.id + ) + .filter( + ( + id + ): id is number => + id !== + undefined + ), + ] } + /> + + + setValue( + 'attributes[' + + index + + '].terms', + val + ) + } + /> + + +
+
+
+ +
+
+ + +
+
+ ); + } } + + { showConfirmClose && ( + setShowConfirmClose( false ) } + onConfirm={ onCancel } + > + { __( + 'You have some attributes added to the list, are you sure you want to cancel?', + 'woocommerce' + ) } + + ) } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss index 275bc8869c1..b056534865b 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss @@ -41,7 +41,7 @@ padding: 0 $gap-large; &:last-child { - margin: -1px; + margin-bottom: -1px; } } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx index 67772f3ba66..491ed537c66 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx @@ -2,7 +2,8 @@ * External dependencies */ import { sprintf, __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; +import { Button, Card, CardBody, Popover } from '@wordpress/components'; +import { useState } from '@wordpress/element'; import { ProductAttribute } from '@woocommerce/data'; import { Text } from '@woocommerce/experimental'; import { Sortable, ListItem } from '@woocommerce/components'; @@ -13,6 +14,7 @@ import { closeSmall } from '@wordpress/icons'; */ import './attribute-field.scss'; import AttributeEmptyStateLogo from './attribute-empty-state-logo.svg'; +import { AddAttributeModal } from './add-attribute-modal'; import { reorderSortableProductAttributePositions } from './utils'; type AttributeFieldProps = { @@ -24,6 +26,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { value, onChange, } ) => { + const [ showAddAttributeModal, setShowAddAttributeModal ] = + useState( false ); const onRemove = ( attribute: ProductAttribute ) => { // eslint-disable-next-line no-alert if ( window.confirm( __( 'Remove this attribute?', 'woocommerce' ) ) ) { @@ -31,33 +35,69 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { } }; + const onAddNewAttributes = ( newAttributes: ProductAttribute[] ) => { + onChange( [ + ...( value || [] ), + ...newAttributes + .filter( + ( newAttr ) => + ! ( value || [] ).find( + ( attr ) => attr.id === newAttr.id + ) + ) + .map( ( newAttr, index ) => { + newAttr.position = ( value || [] ).length + index; + return newAttr; + } ), + ] ); + setShowAddAttributeModal( false ); + }; + if ( ! value || value.length === 0 ) { return ( -
-
- Completed - - { __( 'No attributes yet', 'woocommerce' ) } - - -
-
+ + +
+
+ Completed + + { __( 'No attributes yet', 'woocommerce' ) } + + +
+ { showAddAttributeModal && ( + + setShowAddAttributeModal( false ) + } + onAdd={ onAddNewAttributes } + selectedAttributeIds={ ( value || [] ).map( + ( attr ) => attr.id + ) } + /> + ) } + +
+
+
); } @@ -127,11 +167,19 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { + { showAddAttributeModal && ( + setShowAddAttributeModal( false ) } + onAdd={ onAddNewAttributes } + selectedAttributeIds={ value.map( ( attr ) => attr.id ) } + /> + ) } + ); }; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx new file mode 100644 index 00000000000..c88173c4fe9 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx @@ -0,0 +1,289 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { ProductAttribute, ProductAttributeTerm } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { AddAttributeModal } from '../add-attribute-modal'; + +let attributeOnChange: ( val: ProductAttribute ) => void; +jest.mock( '../../attribute-input-field', () => ( { + AttributeInputField: ( { + onChange, + }: { + onChange: ( + value?: Omit< + ProductAttribute, + 'position' | 'visible' | 'variation' + > + ) => void; + } ) => { + attributeOnChange = onChange; + return
attribute_input_field
; + }, +} ) ); +let attributeTermOnChange: ( val: ProductAttributeTerm[] ) => void; +jest.mock( '../../attribute-term-input-field', () => ( { + AttributeTermInputField: ( { + onChange, + disabled, + }: { + onChange: ( value: ProductAttributeTerm[] ) => void; + disabled: boolean; + } ) => { + attributeTermOnChange = onChange; + return ( +
+ attribute_term_input_field: disabled:{ disabled.toString() } +
+ ); + }, +} ) ); + +const attributeList: ProductAttribute[] = [ + { + id: 15, + name: 'Automotive', + position: 0, + visible: true, + variation: false, + options: [ 'test' ], + }, + { + id: 1, + name: 'Color', + position: 2, + visible: true, + variation: true, + options: [ + 'Beige', + 'black', + 'Blue', + 'brown', + 'Gray', + 'Green', + 'mint', + 'orange', + 'pink', + 'Red', + 'white', + 'Yellow', + ], + }, +]; + +const attributeTermList: ProductAttributeTerm[] = [ + { + id: 23, + name: 'XXS', + slug: 'xxs', + description: '', + menu_order: 1, + count: 1, + }, + { + id: 22, + name: 'XS', + slug: 'xs', + description: '', + menu_order: 2, + count: 1, + }, + { + id: 17, + name: 'S', + slug: 's', + description: '', + menu_order: 3, + count: 1, + }, + { + id: 18, + name: 'M', + slug: 'm', + description: '', + menu_order: 4, + count: 1, + }, + { + id: 19, + name: 'L', + slug: 'l', + description: '', + menu_order: 5, + count: 1, + }, +]; + +describe( 'AddAttributeModal', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should render at-least one row with the attribute dropdown fields', () => { + const { queryAllByText } = render( + {} } + onAdd={ () => {} } + selectedAttributeIds={ [] } + /> + ); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 1 ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ).length + ).toEqual( 1 ); + } ); + + it( 'should enable attribute term field once attribute is selected', () => { + const { queryAllByText } = render( + {} } + onAdd={ () => {} } + selectedAttributeIds={ [] } + /> + ); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 1 ); + attributeOnChange( attributeList[ 0 ] ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:false' ) + .length + ).toEqual( 1 ); + } ); + + it( 'should allow us to add multiple new rows with the attribute fields', () => { + const { queryAllByText, queryByRole } = render( + {} } + onAdd={ () => {} } + selectedAttributeIds={ [] } + /> + ); + queryByRole( 'button', { name: 'Add another attribute' } )?.click(); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 2 ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ).length + ).toEqual( 2 ); + queryByRole( 'button', { name: 'Add another attribute' } )?.click(); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 3 ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ).length + ).toEqual( 3 ); + } ); + + it( 'should allow us to remove the added fields', () => { + const { queryAllByText, queryByRole, queryAllByLabelText } = render( + {} } + onAdd={ () => {} } + selectedAttributeIds={ [] } + /> + ); + + queryByRole( 'button', { name: 'Add another attribute' } )?.click(); + queryByRole( 'button', { name: 'Add another attribute' } )?.click(); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 3 ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ).length + ).toEqual( 3 ); + + const removeButtons = queryAllByLabelText( 'Remove attribute' ); + + removeButtons[ 0 ].click(); + removeButtons[ 1 ].click(); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 1 ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ).length + ).toEqual( 1 ); + } ); + + it( 'should not allow us to remove all the rows', () => { + const { queryAllByText, queryAllByLabelText } = render( + {} } + onAdd={ () => {} } + selectedAttributeIds={ [] } + /> + ); + + const removeButtons = queryAllByLabelText( 'Remove attribute' ); + + removeButtons[ 0 ].click(); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( 1 ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ).length + ).toEqual( 1 ); + } ); + + describe( 'onAdd', () => { + it( 'should not return empty attribute rows', () => { + const onAddMock = jest.fn(); + const { queryAllByText, queryByLabelText, queryByRole } = render( + {} } + onAdd={ onAddMock } + selectedAttributeIds={ [] } + /> + ); + + const addAnotherButton = queryByLabelText( + 'Add another attribute' + ); + addAnotherButton?.click(); + addAnotherButton?.click(); + expect( queryAllByText( 'attribute_input_field' ).length ).toEqual( + 3 + ); + expect( + queryAllByText( 'attribute_term_input_field: disabled:true' ) + .length + ).toEqual( 3 ); + queryByRole( 'button', { name: 'Add attributes' } )?.click(); + expect( onAddMock ).toHaveBeenCalledWith( [] ); + } ); + + it( 'should not add attribute if no terms were selected', () => { + const onAddMock = jest.fn(); + const { queryByRole } = render( + {} } + onAdd={ onAddMock } + selectedAttributeIds={ [] } + /> + ); + + attributeOnChange( attributeList[ 0 ] ); + queryByRole( 'button', { name: 'Add attributes' } )?.click(); + expect( onAddMock ).toHaveBeenCalledWith( [] ); + } ); + + it( 'should add attribute with terms as string of options', () => { + const onAddMock = jest.fn(); + const { queryByRole } = render( + {} } + onAdd={ onAddMock } + selectedAttributeIds={ [] } + /> + ); + + attributeOnChange( attributeList[ 0 ] ); + attributeTermOnChange( [ + attributeTermList[ 0 ], + attributeTermList[ 1 ], + ] ); + queryByRole( 'button', { name: 'Add attributes' } )?.click(); + expect( onAddMock ).toHaveBeenCalledWith( [ + { + ...attributeList[ 0 ], + options: [ + attributeTermList[ 0 ].name, + attributeTermList[ 1 ].name, + ], + }, + ] ); + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx new file mode 100644 index 00000000000..3563992023b --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { Spinner } from '@wordpress/components'; +import { + EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME, + QueryProductAttribute, + ProductAttribute, + WCDataSelector, +} from '@woocommerce/data'; +import { + __experimentalSelectControl as SelectControl, + __experimentalSelectControlMenu as Menu, + __experimentalSelectControlMenuItem as MenuItem, +} from '@woocommerce/components'; + +type AttributeInputFieldProps = { + value?: ProductAttribute; + onChange: ( + value?: Omit< ProductAttribute, 'position' | 'visible' | 'variation' > + ) => void; + label?: string; + placeholder?: string; + disabled?: boolean; + filteredAttributeIds?: number[]; +}; + +export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( { + value, + onChange, + placeholder, + label, + disabled, + filteredAttributeIds = [], +} ) => { + const { attributes, isLoading } = useSelect( ( select: WCDataSelector ) => { + const { getProductAttributes, hasFinishedResolution } = select( + EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME + ); + return { + isLoading: ! hasFinishedResolution( 'getProductAttributes' ), + attributes: getProductAttributes(), + }; + } ); + + const getFilteredItems = ( + allItems: Pick< QueryProductAttribute, 'id' | 'name' >[], + inputValue: string + ) => { + return allItems.filter( + ( item ) => + filteredAttributeIds.indexOf( item.id ) < 0 && + ( item.name || '' ) + .toLowerCase() + .startsWith( inputValue.toLowerCase() ) + ); + }; + const selected: Pick< QueryProductAttribute, 'id' | 'name' > | null = value + ? { + id: value.id, + name: value.name, + } + : null; + + return ( + > + items={ attributes || [] } + label={ label || '' } + disabled={ disabled } + getFilteredItems={ getFilteredItems } + placeholder={ placeholder } + getItemLabel={ ( item ) => item?.name || '' } + getItemValue={ ( item ) => item?.id || '' } + selected={ selected } + onSelect={ ( attribute ) => + onChange( { + id: attribute.id, + name: attribute.name, + options: [], + } ) + } + onRemove={ () => onChange() } + > + { ( { + items: renderItems, + highlightedIndex, + getItemProps, + getMenuProps, + isOpen, + } ) => { + return ( + + { isLoading ? ( + + ) : ( + renderItems.map( ( item, index: number ) => ( + + { item.name } + + ) ) + ) } + + ); + } } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/index.ts new file mode 100644 index 00000000000..6000ad95f49 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/index.ts @@ -0,0 +1 @@ +export * from './attribute-input-field'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/test/attribute-input-field.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/test/attribute-input-field.spec.tsx new file mode 100644 index 00000000000..3b38ce81b45 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/test/attribute-input-field.spec.tsx @@ -0,0 +1,226 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { useSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { ProductAttribute, QueryProductAttribute } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { AttributeInputField } from '../attribute-input-field'; + +jest.mock( '@wordpress/data', () => ( { + ...jest.requireActual( '@wordpress/data' ), + useSelect: jest.fn(), +} ) ); + +jest.mock( '@wordpress/components', () => ( { + __esModule: true, + Spinner: () =>
spinner
, +} ) ); + +jest.mock( '@woocommerce/components', () => { + return { + __esModule: true, + __experimentalSelectControlMenu: ( { + children, + }: { + children: JSX.Element; + } ) => children, + __experimentalSelectControlMenuItem: ( { + children, + }: { + children: JSX.Element; + } ) =>
{ children }
, + __experimentalSelectControl: ( { + children, + items, + getFilteredItems, + onSelect, + onRemove, + }: { + children: ( options: { + isOpen: boolean; + items: QueryProductAttribute[]; + getMenuProps: () => Record< string, string >; + getItemProps: () => Record< string, string >; + } ) => JSX.Element; + items: QueryProductAttribute[]; + onSelect: ( item: QueryProductAttribute ) => void; + onRemove: ( item: QueryProductAttribute ) => void; + getFilteredItems: ( + allItems: QueryProductAttribute[], + inputValue: string, + selectedItems: QueryProductAttribute[] + ) => QueryProductAttribute[]; + } ) => { + const [ input, setInput ] = useState( '' ); + return ( +
+ attribute_input_field + + + +
+ { children( { + isOpen: true, + items: getFilteredItems( items, input, [] ), + getMenuProps: () => ( {} ), + getItemProps: () => ( {} ), + } ) } +
+
+ ); + }, + }; +} ); + +const attributeList: ProductAttribute[] = [ + { + id: 15, + name: 'Automotive', + position: 0, + visible: true, + variation: false, + options: [ 'test' ], + }, + { + id: 1, + name: 'Color', + position: 2, + visible: true, + variation: true, + options: [ + 'Beige', + 'black', + 'Blue', + 'brown', + 'Gray', + 'Green', + 'mint', + 'orange', + 'pink', + 'Red', + 'white', + 'Yellow', + ], + }, +]; + +describe( 'AttributeInputField', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should show spinner while attributes are loading', () => { + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: true, + attributes: undefined, + } ); + const { queryByText } = render( + + ); + expect( queryByText( 'spinner' ) ).toBeInTheDocument(); + } ); + + it( 'should render attributes when finished loading', () => { + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: false, + attributes: attributeList, + } ); + const { queryByText } = render( + + ); + expect( queryByText( 'spinner' ) ).not.toBeInTheDocument(); + expect( queryByText( attributeList[ 0 ].name ) ).toBeInTheDocument(); + expect( queryByText( attributeList[ 1 ].name ) ).toBeInTheDocument(); + } ); + + it( 'should filter out attribute ids passed into filteredAttributeIds', () => { + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: false, + attributes: attributeList, + } ); + const { queryByText } = render( + + ); + expect( queryByText( 'spinner' ) ).not.toBeInTheDocument(); + expect( + queryByText( attributeList[ 0 ].name ) + ).not.toBeInTheDocument(); + expect( queryByText( attributeList[ 1 ].name ) ).toBeInTheDocument(); + } ); + + it( 'should filter attributes by name case insensitive', () => { + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: false, + attributes: attributeList, + } ); + const { queryByText } = render( + + ); + queryByText( 'Update Input' )?.click(); + expect( + queryByText( attributeList[ 0 ].name ) + ).not.toBeInTheDocument(); + expect( queryByText( attributeList[ 1 ].name ) ).toBeInTheDocument(); + } ); + + it( 'should filter out attributes ids from filteredAttributeIds', () => { + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: false, + attributes: attributeList, + } ); + const { queryByText } = render( + + ); + expect( queryByText( attributeList[ 0 ].name ) ).toBeInTheDocument(); + expect( + queryByText( attributeList[ 1 ].name ) + ).not.toBeInTheDocument(); + } ); + + it( 'should trigger onChange when onSelect is triggered with attribute value', () => { + const onChangeMock = jest.fn(); + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: false, + attributes: attributeList, + } ); + const { queryByText } = render( + + ); + queryByText( 'select attribute' )?.click(); + expect( onChangeMock ).toHaveBeenCalledWith( { + id: attributeList[ 0 ].id, + name: attributeList[ 0 ].name, + options: [], + } ); + } ); + + it( 'should trigger onChange when onRemove is triggered with undefined', () => { + const onChangeMock = jest.fn(); + ( useSelect as jest.Mock ).mockReturnValue( { + isLoading: false, + attributes: attributeList, + } ); + const { queryByText } = render( + + ); + queryByText( 'remove attribute' )?.click(); + expect( onChangeMock ).toHaveBeenCalledWith(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.scss b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.scss new file mode 100644 index 00000000000..b4836d8ce10 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.scss @@ -0,0 +1,5 @@ +.woocommerce-attribute-term-field { + &__loading-spinner { + padding: 12px 0; + } +} diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.tsx new file mode 100644 index 00000000000..5d69aced711 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/attribute-term-input-field.tsx @@ -0,0 +1,190 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { CheckboxControl, Spinner } from '@wordpress/components'; +import { resolveSelect } from '@wordpress/data'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useDebounce } from '@wordpress/compose'; +import { + EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME, + ProductAttributeTerm, +} from '@woocommerce/data'; +import { + selectControlStateChangeTypes, + __experimentalSelectControl as SelectControl, + __experimentalSelectControlMenu as Menu, + __experimentalSelectControlMenuItem as MenuItem, +} from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import './attribute-term-input-field.scss'; + +type AttributeTermInputFieldProps = { + value?: ProductAttributeTerm[]; + onChange: ( value: ProductAttributeTerm[] ) => void; + attributeId?: number; + placeholder?: string; + disabled?: boolean; +}; + +export const AttributeTermInputField: React.FC< + AttributeTermInputFieldProps +> = ( { value = [], onChange, placeholder, disabled, attributeId } ) => { + const [ fetchedItems, setFetchedItems ] = useState< + ProductAttributeTerm[] + >( [] ); + const [ isFetching, setIsFetching ] = useState( false ); + + const fetchItems = useCallback( + ( searchString: string | undefined ) => { + setIsFetching( true ); + return resolveSelect( + EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME + ) + .getProductAttributeTerms< ProductAttributeTerm[] >( { + search: searchString || '', + attribute_id: attributeId, + } ) + .then( + ( attributeTerms ) => { + setFetchedItems( attributeTerms ); + setIsFetching( false ); + return attributeTerms; + }, + ( error ) => { + setIsFetching( false ); + return error; + } + ); + }, + [ attributeId ] + ); + + const debouncedSearch = useDebounce( fetchItems, 250 ); + + useEffect( () => { + if ( + ! disabled && + attributeId !== undefined && + ! fetchedItems.length + ) { + fetchItems( '' ); + } + }, [ disabled, attributeId ] ); + + const onRemove = ( item: ProductAttributeTerm ) => { + onChange( value.filter( ( opt ) => opt.slug !== item.slug ) ); + }; + + const onSelect = ( item: ProductAttributeTerm ) => { + const isSelected = value.find( ( i ) => i.slug === item.slug ); + if ( isSelected ) { + onRemove( item ); + return; + } + onChange( [ ...value, item ] ); + }; + + const selectedTermSlugs = ( value || [] ).map( ( term ) => term.slug ); + + return ( + + items={ fetchedItems } + multiple + disabled={ disabled || ! attributeId } + label="" + getFilteredItems={ ( allItems ) => allItems } + onInputChange={ debouncedSearch } + placeholder={ placeholder || '' } + getItemLabel={ ( item ) => item?.name || '' } + getItemValue={ ( item ) => item?.slug || '' } + stateReducer={ ( state, actionAndChanges ) => { + const { changes, type } = actionAndChanges; + switch ( type ) { + case selectControlStateChangeTypes.ControlledPropUpdatedSelectedItem: + return { + ...changes, + inputValue: state.inputValue, + }; + case selectControlStateChangeTypes.ItemClick: + return { + ...changes, + isOpen: true, + inputValue: state.inputValue, + highlightedIndex: state.highlightedIndex, + }; + default: + return changes; + } + } } + selected={ value } + onSelect={ onSelect } + onRemove={ onRemove } + className="woocommerce-attribute-term-field" + > + { ( { + items, + highlightedIndex, + getItemProps, + getMenuProps, + isOpen, + } ) => { + return ( + + { [ + isFetching ? ( +
+ +
+ ) : null, + ...items.map( ( item, menuIndex ) => { + const isSelected = selectedTermSlugs.includes( + item.slug + ); + + return ( + + <> + null } + checked={ isSelected } + label={ + + { item.name } + + } + /> + + + ); + } ), + ].filter( + ( child ): child is JSX.Element => child !== null + ) } +
+ ); + } } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/index.ts new file mode 100644 index 00000000000..562b73ccb93 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/index.ts @@ -0,0 +1 @@ +export * from './attribute-term-input-field'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/test/attribute-term-input-field.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/test/attribute-term-input-field.spec.tsx new file mode 100644 index 00000000000..94e9bae3d38 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-term-input-field/test/attribute-term-input-field.spec.tsx @@ -0,0 +1,208 @@ +/** + * External dependencies + */ +import { act, render, waitFor, screen } from '@testing-library/react'; +import { useState } from '@wordpress/element'; +import { resolveSelect } from '@wordpress/data'; +import { ProductAttribute, ProductAttributeTerm } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { AttributeTermInputField } from '../attribute-term-input-field'; + +jest.mock( '@wordpress/data', () => ( { + ...jest.requireActual( '@wordpress/data' ), + resolveSelect: jest.fn(), +} ) ); + +jest.mock( '@wordpress/components', () => { + return { + __esModule: true, + Spinner: () =>
spinner
, + }; +} ); + +jest.mock( '@woocommerce/components', () => { + return { + __esModule: true, + __experimentalSelectControlMenu: ( { + children, + }: { + children: JSX.Element; + } ) => children, + __experimentalSelectControlMenuItem: ( { + children, + }: { + children: JSX.Element; + } ) =>
{ children }
, + __experimentalSelectControl: ( { + children, + items, + getFilteredItems, + }: { + children: ( options: { + isOpen: boolean; + items: ProductAttributeTerm[]; + getMenuProps: () => Record< string, string >; + getItemProps: () => Record< string, string >; + } ) => JSX.Element; + items: ProductAttributeTerm[]; + getFilteredItems: ( + allItems: ProductAttributeTerm[], + inputValue: string, + selectedItems: ProductAttributeTerm[] + ) => ProductAttributeTerm[]; + } ) => { + const [ input, setInput ] = useState( '' ); + return ( +
+ attribute_input_field + +
+ { children( { + isOpen: true, + items: getFilteredItems( items, input, [] ), + getMenuProps: () => ( {} ), + getItemProps: () => ( {} ), + } ) } +
+
+ ); + }, + }; +} ); + +const attributeList: ProductAttribute[] = [ + { + id: 15, + name: 'Automotive', + position: 0, + visible: true, + variation: false, + options: [ 'test' ], + }, + { + id: 1, + name: 'Color', + position: 2, + visible: true, + variation: true, + options: [ + 'Beige', + 'black', + 'Blue', + 'brown', + 'Gray', + 'Green', + 'mint', + 'orange', + 'pink', + 'Red', + 'white', + 'Yellow', + ], + }, +]; + +const attributeTermList: ProductAttributeTerm[] = [ + { + id: 23, + name: 'XXS', + slug: 'xxs', + description: '', + menu_order: 1, + count: 1, + }, + { + id: 22, + name: 'XS', + slug: 'xs', + description: '', + menu_order: 2, + count: 1, + }, + { + id: 17, + name: 'S', + slug: 's', + description: '', + menu_order: 3, + count: 1, + }, + { + id: 18, + name: 'M', + slug: 'm', + description: '', + menu_order: 4, + count: 1, + }, + { + id: 19, + name: 'L', + slug: 'l', + description: '', + menu_order: 5, + count: 1, + }, +]; + +describe( 'AttributeTermInputField', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should not trigger resolveSelect if attributeId is not defined', () => { + render( ); + expect( resolveSelect ).not.toHaveBeenCalled(); + } ); + + it( 'should not trigger resolveSelect if attributeId is defined but field disabled', () => { + render( + + ); + expect( resolveSelect ).not.toHaveBeenCalled(); + } ); + + it( 'should trigger resolveSelect if attributeId is defined and field not disabled', () => { + const getProductAttributesMock = jest.fn().mockResolvedValue( [] ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getProductAttributeTerms: getProductAttributesMock, + } ); + render( + + ); + expect( getProductAttributesMock ).toHaveBeenCalledWith( { + search: '', + attribute_id: 2, + } ); + } ); + + it( 'should render spinner while retrieving products', async () => { + const getProductAttributesMock = jest + .fn() + .mockReturnValue( { then: () => {} } ); + ( resolveSelect as jest.Mock ).mockReturnValue( { + getProductAttributeTerms: getProductAttributesMock, + } ); + await act( async () => { + render( + + ); + } ); + // debug(); + await waitFor( () => { + expect( screen.queryByText( 'spinner' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss b/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss index 09eb2478645..a80d03fe3eb 100644 --- a/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss +++ b/plugins/woocommerce-admin/client/products/fields/category-field/category-field.scss @@ -58,9 +58,6 @@ } .woocommerce-experimental-select-control { - &__input { - height: 30px; - } &__combox-box-icon { box-sizing: unset; } diff --git a/plugins/woocommerce-admin/client/products/product-form-actions.scss b/plugins/woocommerce-admin/client/products/product-form-actions.scss index 2a7f384dd8c..724363ad170 100644 --- a/plugins/woocommerce-admin/client/products/product-form-actions.scss +++ b/plugins/woocommerce-admin/client/products/product-form-actions.scss @@ -1,5 +1,5 @@ -$gutenberg-blue: #007cba; -$gutenberg-blue-darker: #0063a1; +$gutenberg-blue: var(--wp-admin-theme-color); +$gutenberg-blue-darker: var(--wp-admin-theme-color-darker-20); .woocommerce-product-form-actions { display: flex; diff --git a/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx b/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx index cc569e491e3..ecd11453fd1 100644 --- a/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx +++ b/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx @@ -2,7 +2,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { Card, CardBody } from '@wordpress/components'; import { Link, useFormContext } from '@woocommerce/components'; import { Product } from '@woocommerce/data'; import { recordEvent } from '@woocommerce/tracks'; @@ -43,11 +42,7 @@ export const AttributesSection: React.FC = () => { } > - - - - - + ); }; diff --git a/plugins/woocommerce/changelog/add-34331_add_attributes_modal b/plugins/woocommerce/changelog/add-34331_add_attributes_modal new file mode 100644 index 00000000000..05ccdce5eb3 --- /dev/null +++ b/plugins/woocommerce/changelog/add-34331_add_attributes_modal @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add add attribute modal to the attribute field in the new product management MVP