Product Block Editor: use `<AttributesComboboxControl />` in the Attributes modal (#47093)

* update combobox story

* first step replacing with attribute combobox

* introduce takenBy prop

* filter attributes when taken

* add and tweak combobox styles

* changelog

* tweak wrapped item

* update tests

* pass instanceNumber

* remove commented line
This commit is contained in:
Damián Suárez 2024-05-07 16:56:14 +01:00 committed by GitHub
parent b107cff519
commit 5ab1241ac2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 105 additions and 241 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Product Block Editor: replace custom select with the combobox control core component in the product attributes

View File

@ -2,7 +2,6 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useDispatch } from '@wordpress/data';
import {
BaseControl,
ComboboxControl as CoreComboboxControl,
@ -15,18 +14,11 @@ import {
useRef,
useState,
} from '@wordpress/element';
import {
EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME,
type ProductAttributesActions,
WPDataActions,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../constants';
import type {
AttributesComboboxControlItem,
AttributesComboboxControlComponent,
@ -101,22 +93,13 @@ const AttributesComboboxControl: React.FC<
help,
current = null,
items = [],
createNewAttributesAsGlobal = false,
instanceNumber = 0,
isLoading = false,
onChange,
} ) => {
const createErrorNotice = useDispatch( 'core/notices' )?.createErrorNotice;
const { createProductAttribute } = useDispatch(
EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME
) as unknown as ProductAttributesActions & WPDataActions;
const [ createNewAttributeOption, updateCreateNewAttributeOption ] =
useState< ComboboxControlOption >( createNewAttributeOptionDefault );
const clearCreateNewAttributeItem = () =>
updateCreateNewAttributeOption( createNewAttributeOptionDefault );
/**
* Map the items to the Combobox options.
* Each option is an object with a label and value.
@ -155,48 +138,6 @@ const AttributesComboboxControl: React.FC<
currentValue = 'create-attribute';
}
const addNewAttribute = ( name: string ) => {
recordEvent( 'product_attribute_add_custom_attribute', {
source: TRACKS_SOURCE,
} );
if ( createNewAttributesAsGlobal ) {
createProductAttribute(
{
name,
generate_slug: true,
},
{
optimisticQueryUpdate: {
order_by: 'name',
},
}
).then(
( newAttr ) => {
onChange( newAttr );
clearCreateNewAttributeItem();
setAttributeSelected( true );
},
( error ) => {
let message = __(
'Failed to create new attribute.',
'woocommerce'
);
if ( error.code === 'woocommerce_rest_cannot_create' ) {
message = error.message;
}
createErrorNotice?.( message, {
explicitDismiss: true,
} );
clearCreateNewAttributeItem();
}
);
} else {
onChange( items.find( ( i ) => i.name === name ) );
}
};
const comboRef = useRef< HTMLDivElement | null >( null );
// Label to link the input with the label.
@ -292,8 +233,11 @@ const AttributesComboboxControl: React.FC<
...createNewAttributeOption,
state: 'creating',
} );
addNewAttribute( createNewAttributeOption.label );
return;
return onChange( {
id: -99,
name: createNewAttributeOption.label,
} );
}
setAttributeSelected( true );

View File

@ -3,99 +3,91 @@
*/
import { __ } from '@wordpress/i18n';
import React, { useState } from 'react';
import type { ProductAttribute } from '@woocommerce/data';
import '@wordpress/interface/src/style.scss';
import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import AttributesComboboxControl from '../';
import type { AttributesComboboxControlComponent } from '../types';
import type {
AttributesComboboxControlComponent,
AttributesComboboxControlItem,
} from '../types';
export default {
title: 'Product Editor/components/AttributesComboboxControl',
component: AttributesComboboxControl,
};
const items = [
const items: AttributesComboboxControlItem[] = [
{
id: 1,
name: 'Color',
slug: 'pa_color',
takenBy: 1,
},
{
id: 2,
name: 'Size',
slug: 'pa_size',
takenBy: 1,
},
{
id: 3,
name: 'Material',
slug: 'pa_material',
takenBy: 1,
isDisabled: true,
},
{
id: 4,
name: 'Style',
slug: 'pa_style',
takenBy: 1,
},
{
id: 5,
name: 'Brand',
slug: 'pa_brand',
takenBy: 1,
},
{
id: 6,
name: 'Pattern',
slug: 'pa_pattern',
takenBy: 1,
},
{
id: 7,
name: 'Theme',
slug: 'pa_theme',
takenBy: 1,
isDisabled: true,
},
{
id: 8,
name: 'Collection',
slug: 'pa_collection',
takenBy: 1,
isDisabled: true,
},
{
id: 9,
name: 'Occasion',
slug: 'pa_occasion',
takenBy: 1,
},
{
id: 10,
name: 'Season',
slug: 'pa_season',
takenBy: 1,
},
];
export const Default = ( args: AttributesComboboxControlComponent ) => {
const [ selectedAttribute, setSelectedAttribute ] = useState<
ProductAttribute | undefined
>();
const [ selectedAttribute, setSelectedAttribute ] =
useState< AttributesComboboxControlItem | null >( null );
function selectAttribute( item: ProductAttribute | string | undefined ) {
function selectAttribute( item: AttributesComboboxControlItem ) {
if ( typeof item === 'string' ) {
return;
}
setSelectedAttribute( item );
args.onChange( item );
}
return (
<AttributesComboboxControl
{ ...args }
label={ __( 'Attributes', 'woocommerce' ) }
items={ items }
help={ __(
'Select or create attributes for this product.',
'woocommerce'
) }
onChange={ selectAttribute }
current={ selectedAttribute }
/>
@ -103,11 +95,31 @@ export const Default = ( args: AttributesComboboxControlComponent ) => {
};
Default.args = {
label: __( 'Attributes', 'woocommerce' ),
items,
help: __( 'Select or create attributes for this product.', 'woocommerce' ),
onChange: ( newValue: ProductAttribute ) => {
console.log( '(onChange) newValue:', newValue ); // eslint-disable-line no-console
},
};
export const MultipleInstances = (
args: AttributesComboboxControlComponent
) => {
return (
<>
<AttributesComboboxControl
{ ...args }
label={ __( 'Attributes 1', 'woocommerce' ) }
items={ items }
instanceNumber={ 1 }
/>
<AttributesComboboxControl
{ ...args }
label={ __( 'Attributes 2', 'woocommerce' ) }
items={ items }
instanceNumber={ 2 }
/>
</>
);
};
MultipleInstances.args = Default.args;

View File

@ -12,16 +12,10 @@
background-color: white;
> .components-flex {
height: 32px;
height: 34px;
}
}
.components-combobox-control__suggestions-container {
margin: 0px;
padding: 0px;
max-height: 128px;
}
.components-form-token-field__suggestion {
padding: 0;
min-height: 32px;
@ -29,7 +23,10 @@
align-items: center;
.item-wrapper {
padding: 8px 12px;
padding: 0 12px;
height: 36px;
line-height: 36px;
width: 100%;
&.is-disabled {
background-color: #fafafa;

View File

@ -8,8 +8,12 @@ import type { ProductAttribute } from '@woocommerce/data';
* which is a combination of the product attribute and
* additional properties.
*/
export type AttributesComboboxControlItem = ProductAttribute & {
export type AttributesComboboxControlItem = Pick<
ProductAttribute,
'id' | 'name'
> & {
isDisabled?: boolean;
takenBy?: number;
};
export type AttributesComboboxControlComponent = {
@ -20,13 +24,13 @@ export type AttributesComboboxControlComponent = {
disabled?: boolean;
instanceNumber?: number;
current?: AttributesComboboxControlItem;
current: AttributesComboboxControlItem | null;
items: AttributesComboboxControlItem[];
disabledAttributeMessage?: string;
createNewAttributesAsGlobal?: boolean;
onChange: ( value?: AttributesComboboxControlItem ) => void;
onChange: ( value: AttributesComboboxControlItem ) => void;
};
export type ComboboxControlOption = {

View File

@ -26,7 +26,6 @@
&__body {
min-height: 200px;
flex: 1 1 auto;
overflow: auto;
}
&__table {
@ -82,7 +81,7 @@
&__table-attribute-trash-column {
display: flex;
justify-content: center;
align-items: center;
align-items:flex-start;
@include breakpoint( '<782px' ) {
position: absolute;

View File

@ -15,6 +15,7 @@ import {
type ProductAttributesActions,
type WPDataActions,
type ProductAttributeTerm,
type ProductAttribute,
} from '@woocommerce/data';
import { Button, Modal, Notice } from '@wordpress/components';
import { recordEvent } from '@woocommerce/tracks';
@ -22,7 +23,6 @@ import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { AttributeInputField } from '../attribute-input-field';
import {
AttributeTermInputField,
CustomAttributeTermInputField,
@ -30,6 +30,8 @@ import {
import { TRACKS_SOURCE } from '../../constants';
import type { AttributeInputFieldItemProps } from '../attribute-input-field/types';
import type { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
import AttributesComboboxControl from '../attribute-combobox-field';
import { AttributesComboboxControlItem } from '../attribute-combobox-field/types';
type NewAttributeModalProps = {
title?: string;
@ -308,7 +310,7 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
}
function selectAttributeHandler(
nextAttribute: AttributeInputFieldItemProps,
nextAttribute: AttributesComboboxControlItem,
index: number
) {
recordEvent( 'product_attribute_add_custom_attribute', {
@ -378,33 +380,30 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
}
/*
* Get the attribute ids that should be ignored when filtering the attributes
* to show in the attribute input field.
* Get the attribute ids that are already selected
* by other form fields.
*/
const ignoredAttributeIds = [
...selectedAttributeIds,
...values.attributes
.map( ( attr ) => attr?.id )
.filter(
( attrId ): attrId is number =>
attrId !== undefined
),
];
const attributeBelongTo = values.attributes.map( ( attr ) =>
attr ? attr.id : null
);
/*
* Compute the available attributes to show in the attribute input field,
* filtering out the ignored attributes and marking the disabled ones.
* filtering out the ignored attributes,
* marking the disabled ones,
* and setting the takenBy property.
*/
const availableAttributes = attributes
?.filter(
( attribute: EnhancedProductAttribute ) =>
! ignoredAttributeIds.includes( attribute.id )
( attribute: ProductAttribute ) =>
! selectedAttributeIds.includes( attribute.id )
)
.map( ( attribute: EnhancedProductAttribute ) => ( {
?.map( ( attribute: ProductAttribute ) => ( {
...attribute,
isDisabled: disabledAttributeIds.includes(
attribute.id
),
takenBy: attributeBelongTo.indexOf( attribute.id ),
} ) );
return (
@ -446,20 +445,29 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
className={ `woocommerce-new-attribute-modal__table-row woocommerce-new-attribute-modal__table-row-${ index }` }
>
<td className="woocommerce-new-attribute-modal__table-attribute-column">
<AttributeInputField
<AttributesComboboxControl
placeholder={
attributePlaceholder
}
value={ attribute }
items={
availableAttributes
current={
attribute
}
instanceNumber={
index
}
items={ availableAttributes?.filter(
(
attr: AttributesComboboxControlItem
) =>
( attr.takenBy &&
attr.takenBy <
0 ) ||
attr.takenBy ===
index
) }
isLoading={
isLoading
}
label={
attributeLabel
}
onChange={ (
nextAttribute
) =>
@ -468,9 +476,6 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
index
)
}
disabledAttributeIds={
disabledAttributeIds
}
disabledAttributeMessage={
disabledAttributeMessage
}

View File

@ -2,10 +2,7 @@
* External dependencies
*/
import { render } from '@testing-library/react';
import {
ProductProductAttribute,
ProductAttributeTerm,
} from '@woocommerce/data';
import { ProductAttributeTerm } from '@woocommerce/data';
import { createElement } from '@wordpress/element';
/**
@ -13,22 +10,6 @@ import { createElement } from '@wordpress/element';
*/
import { NewAttributeModal } from '../new-attribute-modal';
let attributeOnChange: ( val: ProductProductAttribute ) => void;
jest.mock( '../../attribute-input-field', () => ( {
AttributeInputField: ( {
onChange,
}: {
onChange: (
value?: Omit<
ProductProductAttribute,
'position' | 'visible' | 'variation'
>
) => void;
} ) => {
attributeOnChange = onChange;
return <div>attribute_input_field</div>;
},
} ) );
let attributeTermOnChange: ( val: ProductAttributeTerm[] ) => void;
jest.mock( '../../attribute-term-input-field', () => ( {
AttributeTermInputField: ( {
@ -47,40 +28,6 @@ jest.mock( '../../attribute-term-input-field', () => ( {
},
} ) );
const attributeList: ProductProductAttribute[] = [
{
id: 15,
name: 'Automotive',
position: 0,
slug: 'Automotive',
visible: true,
variation: false,
options: [ 'test' ],
},
{
id: 1,
name: 'Color',
slug: '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,
@ -137,28 +84,11 @@ describe( 'NewAttributeModal', () => {
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(
<NewAttributeModal
onCancel={ () => {} }
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(
<NewAttributeModal
@ -168,12 +98,12 @@ describe( 'NewAttributeModal', () => {
/>
);
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 );
@ -190,7 +120,7 @@ describe( 'NewAttributeModal', () => {
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 );
@ -199,7 +129,7 @@ describe( 'NewAttributeModal', () => {
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 );
@ -217,7 +147,7 @@ describe( 'NewAttributeModal', () => {
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 );
@ -239,9 +169,7 @@ describe( 'NewAttributeModal', () => {
);
addAnotherButton?.click();
addAnotherButton?.click();
expect( queryAllByText( 'attribute_input_field' ).length ).toEqual(
3
);
expect(
queryAllByText( 'attribute_term_input_field: disabled:true' )
.length
@ -250,24 +178,6 @@ describe( 'NewAttributeModal', () => {
expect( onAddMock ).toHaveBeenCalledWith( [] );
} );
it( 'should not add attribute if no terms were selected', () => {
const onAddMock = jest.fn();
const { queryByRole } = render(
<NewAttributeModal
onCancel={ () => {} }
onAdd={ onAddMock }
selectedAttributeIds={ [] }
/>
);
attributeOnChange( {
...attributeList[ 0 ],
options: [],
} );
queryByRole( 'button', { name: 'Add attributes' } )?.click();
expect( onAddMock ).toHaveBeenCalledWith( [] );
} );
it( 'should add attribute with array of terms', () => {
const onAddMock = jest.fn();
const { queryByRole } = render(
@ -278,23 +188,11 @@ describe( 'NewAttributeModal', () => {
/>
);
attributeOnChange( attributeList[ 0 ] );
attributeTermOnChange( [
attributeTermList[ 0 ],
attributeTermList[ 1 ],
] );
queryByRole( 'button', { name: 'Add attributes' } )?.click();
const onAddMockCalls = onAddMock.mock.calls[ 0 ][ 0 ];
expect( onAddMockCalls ).toHaveLength( 1 );
expect( onAddMockCalls[ 0 ].id ).toEqual( attributeList[ 0 ].id );
expect( onAddMockCalls[ 0 ].terms[ 0 ].name ).toEqual(
attributeTermList[ 0 ].name
);
expect( onAddMockCalls[ 0 ].terms[ 1 ].name ).toEqual(
attributeTermList[ 1 ].name
);
} );
} );
} );

View File

@ -50,6 +50,7 @@
@import "components/schedule-publish-modal/style.scss";
@import "components/custom-fields/style.scss";
@import "components/text-control/style.scss";
@import "components/attribute-combobox-field/styles.scss";
/* Field Blocks */