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:
parent
5b6ddf0b88
commit
5f2c656e6b
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add disabled option to the Select Control input component and alter the onInputChange callback
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
} ) }
|
||||
>
|
||||
<>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Update product attribute type name and export the product attribute types.
|
|
@ -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 );
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
>;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 )
|
||||
}
|
||||
>
|
||||
+
|
||||
{ __( '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>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -41,7 +41,7 @@
|
|||
padding: 0 $gap-large;
|
||||
|
||||
&:last-child {
|
||||
margin: -1px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
] );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './attribute-input-field';
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,5 @@
|
|||
.woocommerce-attribute-term-field {
|
||||
&__loading-spinner {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './attribute-term-input-field';
|
|
@ -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();
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -58,9 +58,6 @@
|
|||
}
|
||||
|
||||
.woocommerce-experimental-select-control {
|
||||
&__input {
|
||||
height: 30px;
|
||||
}
|
||||
&__combox-box-icon {
|
||||
box-sizing: unset;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add add attribute modal to the attribute field in the new product management MVP
|
Loading…
Reference in New Issue