Extract attribute filtering and fetching logic out of attribute components (#36354)

* Move attribution fetching to separate hook

* Add changelog entry

* Set all attributes on update of subset of attributes

* Move filtering logic to hook

* Remove tests that filter attribute by type inside the component

* Rename AttributeField to AttributeControl and props from attributes to value
This commit is contained in:
Joshua T Flowers 2023-01-17 00:34:08 -08:00 committed by GitHub
parent 6377314b1b
commit ea64a98f54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 201 additions and 166 deletions

View File

@ -26,7 +26,7 @@ import {
AttributeTermInputField,
CustomAttributeTermInputField,
} from '../attribute-term-input-field';
import { HydratedAttributeType } from '../attribute-field';
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
import { getProductAttributeObject } from './utils';
type AddAttributeModalProps = {
@ -46,12 +46,12 @@ type AddAttributeModalProps = {
confirmCancelLabel?: string;
confirmConfirmLabel?: string;
onCancel: () => void;
onAdd: ( newCategories: HydratedAttributeType[] ) => void;
onAdd: ( newCategories: EnhancedProductAttribute[] ) => void;
selectedAttributeIds?: number[];
};
type AttributeForm = {
attributes: Array< HydratedAttributeType | null >;
attributes: Array< EnhancedProductAttribute | null >;
};
export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
@ -92,7 +92,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
};
const onAddingAttributes = ( values: AttributeForm ) => {
const newAttributesToAdd: HydratedAttributeType[] = [];
const newAttributesToAdd: EnhancedProductAttribute[] = [];
values.attributes.forEach( ( attr ) => {
if (
attr !== null &&
@ -105,7 +105,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
? ( attr.terms || [] ).map( ( term ) => term.name )
: attr.options;
newAttributesToAdd.push( {
...( attr as HydratedAttributeType ),
...( attr as EnhancedProductAttribute ),
options,
} );
}

View File

@ -2,13 +2,8 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element';
import {
ProductAttribute,
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
ProductAttributeTerm,
} from '@woocommerce/data';
import { resolveSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { ProductAttribute } from '@woocommerce/data';
import {
Sortable,
__experimentalSelectControlMenuSlot as SelectControlMenuSlot,
@ -24,42 +19,31 @@ import { getAdminLink } from '@woocommerce/settings';
import './attribute-field.scss';
import { AddAttributeModal } from './add-attribute-modal';
import { EditAttributeModal } from './edit-attribute-modal';
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
import {
getAttributeKey,
reorderSortableProductAttributePositions,
} from './utils';
import { sift } from '../../../utils';
import { AttributeEmptyState } from '../attribute-empty-state';
import {
AddAttributeListItem,
AttributeListItem,
} from '../attribute-list-item';
type AttributeFieldProps = {
type AttributeControlProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
// TODO: should we support an 'any' option to show all attributes?
attributeType?: 'regular' | 'for-variations';
};
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & {
options?: string[];
terms?: ProductAttributeTerm[];
visible?: boolean;
};
export const AttributeField: React.FC< AttributeFieldProps > = ( {
export const AttributeControl: React.FC< AttributeControlProps > = ( {
value,
onChange,
productId,
attributeType = 'regular',
onChange,
} ) => {
const [ showAddAttributeModal, setShowAddAttributeModal ] =
useState( false );
const [ hydratedAttributes, setHydratedAttributes ] = useState<
HydratedAttributeType[]
>( [] );
const [ editingAttributeId, setEditingAttributeId ] = useState<
null | string
>( null );
@ -72,73 +56,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
? 'product_add_options_modal_cancel_button_click'
: 'product_add_attributes_modal_cancel_button_click';
const fetchTerms = useCallback(
( attributeId: number ) => {
return resolveSelect(
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
)
.getProductAttributeTerms< ProductAttributeTerm[] >( {
attribute_id: attributeId,
product: productId,
} )
.then(
( attributeTerms ) => {
return attributeTerms;
},
( error ) => {
return error;
}
);
},
[ productId ]
);
useEffect( () => {
// I think we'll need to move the hydration out of the individual component
// instance. To where, I do not yet know... maybe in the form context
// somewhere so that a single hydration source can be shared between multiple
// instances? Something like a simple key-value store in the form context
// would be handy.
if ( ! value || hydratedAttributes.length !== 0 ) {
return;
}
const [ customAttributes, globalAttributes ]: ProductAttribute[][] =
sift( value, ( attr: ProductAttribute ) => attr.id === 0 );
Promise.all(
globalAttributes.map( ( attr ) => fetchTerms( attr.id ) )
).then( ( allResults ) => {
setHydratedAttributes( [
...globalAttributes.map( ( attr, index ) => {
const fetchedTerms = allResults[ index ];
const newAttr = {
...attr,
// I'm not sure this is quite right for handling unpersisted terms,
// but this gets things kinda working for now
terms:
fetchedTerms.length > 0 ? fetchedTerms : undefined,
options:
fetchedTerms.length === 0
? attr.options
: undefined,
};
return newAttr;
} ),
...customAttributes,
] );
} );
}, [ fetchTerms, hydratedAttributes, value ] );
const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
`${ attribute.id }-${ attribute.name }`;
const updateAttributes = ( attributes: HydratedAttributeType[] ) => {
setHydratedAttributes( attributes );
const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => {
onChange(
attributes.map( ( attr ) => {
newAttributes.map( ( attr ) => {
return {
...attr,
options: attr.terms
@ -157,8 +80,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
recordEvent(
'product_remove_attribute_confirmation_confirm_click'
);
updateAttributes(
hydratedAttributes.filter(
handleChange(
value.filter(
( attr ) =>
fetchAttributeId( attr ) !==
fetchAttributeId( attribute )
@ -169,9 +92,11 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
}
};
const onAddNewAttributes = ( newAttributes: HydratedAttributeType[] ) => {
updateAttributes( [
...( hydratedAttributes || [] ),
const onAddNewAttributes = (
newAttributes: EnhancedProductAttribute[]
) => {
handleChange( [
...( value || [] ),
...newAttributes
.filter(
( newAttr ) =>
@ -193,18 +118,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false );
};
const filteredAttributes = value
? value.filter(
( attribute: ProductAttribute ) =>
attribute.variation === isOnlyForVariations
)
: false;
if (
! filteredAttributes ||
filteredAttributes.length === 0 ||
hydratedAttributes.length === 0
) {
if ( ! value.length ) {
return (
<>
<AttributeEmptyState
@ -232,9 +146,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false );
} }
onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( filteredAttributes || [] ).map(
( attr ) => attr.id
) }
selectedAttributeIds={ [] }
/>
) }
<SelectControlMenuSlot />
@ -242,9 +154,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
);
}
const sortedAttributes = filteredAttributes.sort(
( a, b ) => a.position - b.position
);
const sortedAttributes = value.sort( ( a, b ) => a.position - b.position );
const attributeKeyValues = value.reduce(
(
keyValue: Record< number | string, ProductAttribute >,
@ -256,9 +167,9 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
{} as Record< number | string, ProductAttribute >
);
const attribute = hydratedAttributes.find(
const editingAttribute = value.find(
( attr ) => fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType;
) as EnhancedProductAttribute;
const editAttributeCopy = isOnlyForVariations
? __(
@ -332,15 +243,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
/>
) }
<SelectControlMenuSlot />
{ editingAttributeId && (
{ editingAttribute && (
<EditAttributeModal
title={
title={ sprintf(
/* translators: %s is the attribute name */
sprintf(
__( 'Edit %s', 'woocommerce' ),
attribute.name
)
}
__( 'Edit %s', 'woocommerce' ),
editingAttribute.name
) }
globalAttributeHelperMessage={ interpolateComponents( {
mixedString: editAttributeCopy,
components: {
@ -359,7 +268,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
} ) }
onCancel={ () => setEditingAttributeId( null ) }
onEdit={ ( changedAttribute ) => {
const newAttributesSet = [ ...hydratedAttributes ];
const newAttributesSet = [ ...value ];
const changedAttributeIndex: number =
newAttributesSet.findIndex( ( attr ) =>
attr.id !== 0
@ -373,10 +282,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
changedAttribute
);
updateAttributes( newAttributesSet );
handleChange( newAttributesSet );
setEditingAttributeId( null );
} }
attribute={ attribute }
attribute={ editingAttribute }
/>
) }
</div>

View File

@ -18,7 +18,7 @@ import {
AttributeTermInputField,
CustomAttributeTermInputField,
} from '../attribute-term-input-field';
import { HydratedAttributeType } from './attribute-field';
import { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
import './edit-attribute-modal.scss';
@ -36,8 +36,8 @@ type EditAttributeModalProps = {
updateAccessibleLabel?: string;
updateLabel?: string;
onCancel: () => void;
onEdit: ( alteredAttribute: HydratedAttributeType ) => void;
attribute: HydratedAttributeType;
onEdit: ( alteredAttribute: EnhancedProductAttribute ) => void;
attribute: EnhancedProductAttribute;
};
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
@ -64,7 +64,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
attribute,
} ) => {
const [ editableAttribute, setEditableAttribute ] = useState<
HydratedAttributeType | undefined
EnhancedProductAttribute | undefined
>( { ...attribute } );
const isCustomAttribute = editableAttribute?.id === 0;
@ -84,7 +84,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
}
onChange={ ( val ) =>
setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ),
...( editableAttribute as EnhancedProductAttribute ),
name: val,
} )
}
@ -102,7 +102,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
attributeId={ editableAttribute?.id }
onChange={ ( val ) => {
setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ),
...( editableAttribute as EnhancedProductAttribute ),
terms: val,
} );
} }
@ -115,7 +115,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
value={ editableAttribute?.options }
onChange={ ( val ) => {
setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ),
...( editableAttribute as EnhancedProductAttribute ),
options: val,
} );
} }
@ -126,7 +126,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
<CheckboxControl
onChange={ ( val ) =>
setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ),
...( editableAttribute as EnhancedProductAttribute ),
visible: val,
} )
}
@ -148,7 +148,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
isPrimary
label={ updateAccessibleLabel }
onClick={ () => {
onEdit( editableAttribute as HydratedAttributeType );
onEdit( editableAttribute as EnhancedProductAttribute );
} }
>
{ updateLabel }

View File

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

View File

@ -8,7 +8,7 @@ import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
import { AttributeControl } from '../attribute-control';
const attributeList: ProductAttribute[] = [
{
@ -102,7 +102,7 @@ jest.mock( '@woocommerce/components', () => ( {
},
} ) );
describe( 'AttributeField', () => {
describe( 'AttributeControl', () => {
beforeEach( () => {
jest.clearAllMocks();
} );
@ -110,17 +110,17 @@ describe( 'AttributeField', () => {
describe( 'empty state', () => {
it( 'should show subtitle and "Add first attribute" button', () => {
const { queryByText } = render(
<AttributeField value={ [] } onChange={ () => {} } />
<AttributeControl value={ [] } onChange={ () => {} } />
);
expect( queryByText( 'No attributes yet' ) ).toBeInTheDocument();
expect( queryByText( 'Add first attribute' ) ).toBeInTheDocument();
} );
} );
it( 'should render the list of existing attributes', async () => {
it( 'should render the list of all attributes', async () => {
act( () => {
render(
<AttributeField
<AttributeControl
value={ [ ...attributeList ] }
onChange={ () => {} }
/>
@ -128,20 +128,20 @@ describe( 'AttributeField', () => {
} );
expect(
await screen.findByText( 'No attributes yet' )
await screen.queryByText( 'No attributes yet' )
).not.toBeInTheDocument();
expect(
await screen.findByText( attributeList[ 0 ].name )
await screen.queryByText( attributeList[ 0 ].name )
).toBeInTheDocument();
expect(
await screen.queryByText( attributeList[ 1 ].name )
).not.toBeInTheDocument();
).toBeInTheDocument();
} );
it( 'should render the first two terms of each option, and show "+ n more" for the rest', async () => {
act( () => {
render(
<AttributeField
<AttributeControl
value={ [ ...attributeList ] }
onChange={ () => {} }
attributeType="for-variations"
@ -149,9 +149,6 @@ describe( 'AttributeField', () => {
);
} );
expect(
await screen.queryByText( attributeList[ 0 ].options[ 0 ] )
).not.toBeInTheDocument();
expect(
await screen.findByText( attributeList[ 1 ].options[ 0 ] )
).toBeInTheDocument();
@ -173,7 +170,7 @@ describe( 'AttributeField', () => {
jest.spyOn( global, 'confirm' ).mockReturnValueOnce( false );
act( () => {
render(
<AttributeField
<AttributeControl
value={ [ ...attributeList ] }
onChange={ () => {} }
/>
@ -191,7 +188,7 @@ describe( 'AttributeField', () => {
act( () => {
render(
<AttributeField
<AttributeControl
value={ [ ...attributeList ] }
onChange={ onChange }
/>
@ -211,7 +208,7 @@ describe( 'AttributeField', () => {
const onChange = jest.fn();
act( () => {
render(
<AttributeField
<AttributeControl
value={ [ ...attributeList ] }
onChange={ onChange }
/>
@ -232,7 +229,7 @@ describe( 'AttributeField', () => {
act( () => {
render(
<AttributeField
<AttributeControl
value={ [ ...attributeList ] }
onChange={ onChange }
/>

View File

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

View File

@ -22,12 +22,12 @@ import {
* Internal dependencies
*/
import './attribute-input-field.scss';
import { HydratedAttributeType } from '../attribute-field';
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
type NarrowedQueryAttribute = Pick< QueryProductAttribute, 'id' | 'name' >;
type AttributeInputFieldProps = {
value?: HydratedAttributeType | null;
value?: EnhancedProductAttribute | null;
onChange: (
value?:
| Omit< ProductAttribute, 'position' | 'visible' | 'variation' >

View File

@ -6,7 +6,8 @@ import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
import { AttributeControl } from '../attribute-control';
import { useProductAttributes } from '~/products/hooks/use-product-attributes';
type AttributesProps = {
value: ProductAttribute[];
@ -19,12 +20,17 @@ export const Attributes: React.FC< AttributesProps > = ( {
onChange,
productId,
} ) => {
const { attributes, handleChange } = useProductAttributes( {
allAttributes: value,
onChange,
productId,
} );
return (
<AttributeField
<AttributeControl
attributeType="regular"
value={ value }
onChange={ onChange }
productId={ productId }
value={ attributes }
onChange={ handleChange }
/>
);
};

View File

@ -7,7 +7,8 @@ import { useFormContext } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
import { AttributeControl } from '../attribute-control';
import { useProductAttributes } from '~/products/hooks/use-product-attributes';
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
type OptionsProps = {
@ -24,17 +25,24 @@ export const Options: React.FC< OptionsProps > = ( {
const { values } = useFormContext< Product >();
const { generateProductVariations } = useProductVariationsHelper();
const handleChange = async ( attributes: ProductAttribute[] ) => {
onChange( attributes );
generateProductVariations( { ...values, attributes } );
};
const { attributes, handleChange } = useProductAttributes( {
allAttributes: value,
isVariationAttributes: true,
onChange: ( newAttributes ) => {
onChange( newAttributes );
generateProductVariations( {
...values,
attributes: newAttributes,
} );
},
productId,
} );
return (
<AttributeField
<AttributeControl
attributeType="for-variations"
value={ value }
value={ attributes }
onChange={ handleChange }
productId={ productId }
/>
);
};

View File

@ -0,0 +1,111 @@
/**
* External dependencies
*/
import {
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
ProductAttribute,
ProductAttributeTerm,
} from '@woocommerce/data';
import { resolveSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { sift } from '../../utils';
type useProductAttributesProps = {
allAttributes: ProductAttribute[];
isVariationAttributes?: boolean;
onChange: ( attributes: ProductAttribute[] ) => void;
productId?: number;
};
export type EnhancedProductAttribute = ProductAttribute & {
terms?: ProductAttributeTerm[];
visible?: boolean;
};
export function useProductAttributes( {
allAttributes = [],
isVariationAttributes = false,
onChange,
productId,
}: useProductAttributesProps ) {
const getFilteredAttributes = () => {
return isVariationAttributes
? allAttributes.filter( ( attribute ) => !! attribute.variation )
: allAttributes.filter( ( attribute ) => ! attribute.variation );
};
const [ attributes, setAttributes ] = useState<
EnhancedProductAttribute[]
>( getFilteredAttributes() );
const [ localAttributes, globalAttributes ]: ProductAttribute[][] = sift(
attributes,
( attr: ProductAttribute ) => attr.id === 0
);
const fetchTerms = useCallback(
( attributeId: number ) => {
return resolveSelect(
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
)
.getProductAttributeTerms< ProductAttributeTerm[] >( {
attribute_id: attributeId,
product: productId,
} )
.then(
( attributeTerms ) => {
return attributeTerms;
},
( error ) => {
return error;
}
);
},
[ productId ]
);
const enhanceAttribute = (
globalAttribute: ProductAttribute,
terms: ProductAttributeTerm[]
) => {
return {
...globalAttribute,
terms: terms.length > 0 ? terms : undefined,
options: terms.length === 0 ? globalAttribute.options : [],
};
};
const handleChange = ( newAttributes: ProductAttribute[] ) => {
const otherAttributes = isVariationAttributes
? allAttributes.filter( ( attribute ) => ! attribute.variation )
: allAttributes.filter( ( attribute ) => !! attribute.variation );
setAttributes( newAttributes );
onChange( [ ...otherAttributes, ...newAttributes ] );
};
useEffect( () => {
if ( ! getFilteredAttributes().length || attributes.length ) {
return;
}
Promise.all(
globalAttributes.map( ( attr ) => fetchTerms( attr.id ) )
).then( ( termData ) => {
setAttributes( [
...globalAttributes.map( ( attr, index ) =>
enhanceAttribute( attr, termData[ index ] )
),
...localAttributes,
] );
} );
}, [ allAttributes, attributes, fetchTerms ] );
return {
attributes,
handleChange,
setAttributes,
};
}

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Move product attribute fetching logic into a separate hook