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.
This commit is contained in:
louwie17 2022-10-19 16:28:29 -03:00 committed by GitHub
parent 5b6ddf0b88
commit 5f2c656e6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1553 additions and 61 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add disabled option to the Select Control input component and alter the onInputChange callback

View File

@ -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"

View File

@ -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
{ ...getLabelProps() }
className="woocommerce-experimental-select-control__label"
>
{ label }
</label>
{ label && (
<label
{ ...getLabelProps() }
className="woocommerce-experimental-select-control__label"
>
{ label }
</label>
) }
{ /* eslint-enable jsx-a11y/label-has-for */ }
<ComboBox
comboBoxProps={ getComboboxProps() }
@ -245,6 +250,7 @@ function SelectControl< ItemType = DefaultItemType >( {
},
onBlur: () => setIsFocused( false ),
placeholder,
disabled,
} ) }
>
<>

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update product attribute type name and export the product attribute types.

View File

@ -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 );

View File

@ -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,

View File

@ -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;

View File

@ -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
>;

View File

@ -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;
}
}

View File

@ -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 (
<>
<Form< AttributeForm >
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 (
<Modal
title={ __( 'Add attributes', 'woocommerce' ) }
onRequestClose={ () => onClose( values ) }
className="woocommerce-add-attribute-modal"
>
<Notice isDismissible={ false }>
<p>
{ __(
'By default, attributes are filterable and visible on the product page. You can change these settings for each attribute separately later.',
'woocommerce'
) }
</p>
</Notice>
<div className="woocommerce-add-attribute-modal__body">
<table className="woocommerce-add-attribute-modal__table">
<thead>
<tr className="woocommerce-add-attribute-modal__table-header">
<th>Attribute</th>
<th>Values</th>
</tr>
</thead>
<tbody>
{ values.attributes.map(
( { attribute, terms }, index ) => (
<tr
key={ index }
className={ `woocommerce-add-attribute-modal__table-row woocommerce-add-attribute-modal__table-row-${ index }` }
>
<td className="woocommerce-add-attribute-modal__table-attribute-column">
<AttributeInputField
placeholder={ __(
'Search or create attribute',
'woocommerce'
) }
value={ attribute }
onChange={ (
val
) => {
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
),
] }
/>
</td>
<td className="woocommerce-add-attribute-modal__table-attribute-value-column">
<AttributeTermInputField
placeholder={ __(
'Search or create value',
'woocommerce'
) }
disabled={
! attribute?.id
}
attributeId={
attribute?.id
}
value={ terms }
onChange={ (
val
) =>
setValue(
'attributes[' +
index +
'].terms',
val
)
}
/>
</td>
<td className="woocommerce-add-attribute-modal__table-attribute-trash-column">
<Button
icon={ trash }
disabled={
values
.attributes
.length ===
1 &&
! values
.attributes[ 0 ]
?.attribute
?.id
}
label={ __(
'Remove attribute',
'woocommerce'
) }
onClick={ () =>
onRemove(
index,
values,
setValue
)
}
></Button>
</td>
</tr>
)
) }
</tbody>
</table>
</div>
<div>
<Button
className="woocommerce-add-attribute-modal__add-attribute"
variant="tertiary"
label={ __(
'Add another attribute',
'woocommerce'
) }
onClick={ () =>
addAnother( values, setValue )
}
>
+&nbsp;
{ __( 'Add another', 'woocommerce' ) }
</Button>
</div>
<div className="woocommerce-add-attribute-modal__buttons">
<Button
isSecondary
label={ __( 'Cancel', 'woocommerce' ) }
onClick={ () => onClose( values ) }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button
isPrimary
label={ __(
'Add attributes',
'woocommerce'
) }
disabled={
values.attributes.length === 1 &&
! values.attributes[ 0 ]?.attribute
?.id &&
values.attributes[ 0 ]?.terms
?.length === 0
}
onClick={ () =>
onAddingAttributes( values )
}
>
{ __( 'Add', 'woocommerce' ) }
</Button>
</div>
</Modal>
);
} }
</Form>
{ showConfirmClose && (
<ConfirmDialog
cancelButtonText={ __( 'No thanks', 'woocommerce' ) }
confirmButtonText={ __( 'Yes please!', 'woocommerce' ) }
onCancel={ () => setShowConfirmClose( false ) }
onConfirm={ onCancel }
>
{ __(
'You have some attributes added to the list, are you sure you want to cancel?',
'woocommerce'
) }
</ConfirmDialog>
) }
</>
);
};

View File

@ -41,7 +41,7 @@
padding: 0 $gap-large;
&:last-child {
margin: -1px;
margin-bottom: -1px;
}
}

View File

@ -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 (
<div className="woocommerce-attribute-field">
<div className="woocommerce-attribute-field__empty-container">
<img
src={ AttributeEmptyStateLogo }
alt="Completed"
className="woocommerce-attribute-field__empty-logo"
/>
<Text
variant="subtitle.small"
weight="600"
size="14"
lineHeight="20px"
className="woocommerce-attribute-field__empty-subtitle"
>
{ __( 'No attributes yet', 'woocommerce' ) }
</Text>
<Button
variant="secondary"
className="woocommerce-attribute-field__add-new"
disabled={ true }
>
{ __( 'Add first attribute', 'woocommerce' ) }
</Button>
</div>
</div>
<Card>
<CardBody>
<div className="woocommerce-attribute-field">
<div className="woocommerce-attribute-field__empty-container">
<img
src={ AttributeEmptyStateLogo }
alt="Completed"
className="woocommerce-attribute-field__empty-logo"
/>
<Text
variant="subtitle.small"
weight="600"
size="14"
lineHeight="20px"
className="woocommerce-attribute-field__empty-subtitle"
>
{ __( 'No attributes yet', 'woocommerce' ) }
</Text>
<Button
variant="secondary"
className="woocommerce-attribute-field__add-new"
onClick={ () =>
setShowAddAttributeModal( true )
}
>
{ __( 'Add first attribute', 'woocommerce' ) }
</Button>
</div>
{ showAddAttributeModal && (
<AddAttributeModal
onCancel={ () =>
setShowAddAttributeModal( false )
}
onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( value || [] ).map(
( attr ) => attr.id
) }
/>
) }
<Popover.Slot />
</div>
</CardBody>
</Card>
);
}
@ -127,11 +167,19 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
<Button
variant="secondary"
className="woocommerce-attribute-field__add-attribute"
disabled={ true }
onClick={ () => setShowAddAttributeModal( true ) }
>
{ __( 'Add attribute', 'woocommerce' ) }
</Button>
</ListItem>
{ showAddAttributeModal && (
<AddAttributeModal
onCancel={ () => setShowAddAttributeModal( false ) }
onAdd={ onAddNewAttributes }
selectedAttributeIds={ value.map( ( attr ) => attr.id ) }
/>
) }
<Popover.Slot />
</div>
);
};

View File

@ -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 <div>attribute_input_field</div>;
},
} ) );
let attributeTermOnChange: ( val: ProductAttributeTerm[] ) => void;
jest.mock( '../../attribute-term-input-field', () => ( {
AttributeTermInputField: ( {
onChange,
disabled,
}: {
onChange: ( value: ProductAttributeTerm[] ) => void;
disabled: boolean;
} ) => {
attributeTermOnChange = onChange;
return (
<div>
attribute_term_input_field: disabled:{ disabled.toString() }
</div>
);
},
} ) );
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(
<AddAttributeModal
onCancel={ () => {} }
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(
<AddAttributeModal
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(
<AddAttributeModal
onCancel={ () => {} }
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(
<AddAttributeModal
onCancel={ () => {} }
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(
<AddAttributeModal
onCancel={ () => {} }
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(
<AddAttributeModal
onCancel={ () => {} }
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(
<AddAttributeModal
onCancel={ () => {} }
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(
<AddAttributeModal
onCancel={ () => {} }
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,
],
},
] );
} );
} );
} );

View File

@ -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 (
<SelectControl< Pick< QueryProductAttribute, 'id' | 'name' > >
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 (
<Menu getMenuProps={ getMenuProps } isOpen={ isOpen }>
{ isLoading ? (
<Spinner />
) : (
renderItems.map( ( item, index: number ) => (
<MenuItem
key={ item.id }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
{ item.name }
</MenuItem>
) )
) }
</Menu>
);
} }
</SelectControl>
);
};

View File

@ -0,0 +1 @@
export * from './attribute-input-field';

View File

@ -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: () => <div>spinner</div>,
} ) );
jest.mock( '@woocommerce/components', () => {
return {
__esModule: true,
__experimentalSelectControlMenu: ( {
children,
}: {
children: JSX.Element;
} ) => children,
__experimentalSelectControlMenuItem: ( {
children,
}: {
children: JSX.Element;
} ) => <div>{ children }</div>,
__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 (
<div>
attribute_input_field
<button onClick={ () => setInput( 'Co' ) }>
Update Input
</button>
<button onClick={ () => onSelect( items[ 0 ] ) }>
select attribute
</button>
<button onClick={ () => onRemove( items[ 0 ] ) }>
remove attribute
</button>
<div>
{ children( {
isOpen: true,
items: getFilteredItems( items, input, [] ),
getMenuProps: () => ( {} ),
getItemProps: () => ( {} ),
} ) }
</div>
</div>
);
},
};
} );
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(
<AttributeInputField onChange={ jest.fn() } />
);
expect( queryByText( 'spinner' ) ).toBeInTheDocument();
} );
it( 'should render attributes when finished loading', () => {
( useSelect as jest.Mock ).mockReturnValue( {
isLoading: false,
attributes: attributeList,
} );
const { queryByText } = render(
<AttributeInputField onChange={ jest.fn() } />
);
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(
<AttributeInputField
onChange={ jest.fn() }
filteredAttributeIds={ [ attributeList[ 0 ].id ] }
/>
);
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(
<AttributeInputField onChange={ jest.fn() } />
);
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(
<AttributeInputField
onChange={ jest.fn() }
filteredAttributeIds={ [ attributeList[ 1 ].id ] }
/>
);
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(
<AttributeInputField onChange={ onChangeMock } />
);
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(
<AttributeInputField onChange={ onChangeMock } />
);
queryByText( 'remove attribute' )?.click();
expect( onChangeMock ).toHaveBeenCalledWith();
} );
} );

View File

@ -0,0 +1,5 @@
.woocommerce-attribute-term-field {
&__loading-spinner {
padding: 12px 0;
}
}

View File

@ -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 (
<SelectControl< ProductAttributeTerm >
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 (
<Menu isOpen={ isOpen } getMenuProps={ getMenuProps }>
{ [
isFetching ? (
<div
key="loading-spinner"
className="woocommerce-attribute-term-field__loading-spinner"
>
<Spinner />
</div>
) : null,
...items.map( ( item, menuIndex ) => {
const isSelected = selectedTermSlugs.includes(
item.slug
);
return (
<MenuItem
key={ `${ item.slug }` }
index={ menuIndex }
isActive={
highlightedIndex === menuIndex
}
item={ item }
getItemProps={ getItemProps }
>
<>
<CheckboxControl
onChange={ () => null }
checked={ isSelected }
label={
<span
style={ {
fontWeight:
isSelected
? 'bold'
: 'normal',
} }
>
{ item.name }
</span>
}
/>
</>
</MenuItem>
);
} ),
].filter(
( child ): child is JSX.Element => child !== null
) }
</Menu>
);
} }
</SelectControl>
);
};

View File

@ -0,0 +1 @@
export * from './attribute-term-input-field';

View File

@ -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: () => <div>spinner</div>,
};
} );
jest.mock( '@woocommerce/components', () => {
return {
__esModule: true,
__experimentalSelectControlMenu: ( {
children,
}: {
children: JSX.Element;
} ) => children,
__experimentalSelectControlMenuItem: ( {
children,
}: {
children: JSX.Element;
} ) => <div>{ children }</div>,
__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 (
<div>
attribute_input_field
<button onClick={ () => setInput( 'Co' ) }>
Update Input
</button>
<div>
{ children( {
isOpen: true,
items: getFilteredItems( items, input, [] ),
getMenuProps: () => ( {} ),
getItemProps: () => ( {} ),
} ) }
</div>
</div>
);
},
};
} );
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( <AttributeTermInputField onChange={ jest.fn() } /> );
expect( resolveSelect ).not.toHaveBeenCalled();
} );
it( 'should not trigger resolveSelect if attributeId is defined but field disabled', () => {
render(
<AttributeTermInputField
onChange={ jest.fn() }
attributeId={ 2 }
disabled={ true }
/>
);
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(
<AttributeTermInputField onChange={ jest.fn() } attributeId={ 2 } />
);
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(
<AttributeTermInputField
onChange={ jest.fn() }
attributeId={ 2 }
/>
);
} );
// debug();
await waitFor( () => {
expect( screen.queryByText( 'spinner' ) ).toBeInTheDocument();
} );
} );
} );

View File

@ -58,9 +58,6 @@
}
.woocommerce-experimental-select-control {
&__input {
height: 30px;
}
&__combox-box-icon {
box-sizing: unset;
}

View File

@ -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;

View File

@ -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 = () => {
</>
}
>
<Card>
<CardBody>
<AttributeField { ...getInputProps( 'attributes' ) } />
</CardBody>
</Card>
<AttributeField { ...getInputProps( 'attributes' ) } />
</ProductSectionLayout>
);
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add add attribute modal to the attribute field in the new product management MVP