Product Editor: disable Add button when no terms or options when creating variations (#48928)

* fix doc. improve doc

* disabe `Add` button when not terms nor options

* variant="primary" > isPrimary

* move helpers to utils file. improve jsdoc

* add tests for hasTermsOrOptions helper

* add isAttributeFilledOut unit tests

* changelog

* allow any type in unit tests

* show a toolip with not possible to add attributes

* fix wrong inline code comment

* expect the Add button is initially disabled

* check the `Add attributes` button is enabled

* changelog

* remove obvios jsdoc

* check all terms are accepted

* minor changes

* update tests

* remove console.log to dev purpose

* improve E2E testing remviing attr row
This commit is contained in:
Damián Suárez 2024-07-02 12:30:24 +01:00 committed by GitHub
parent fe4141f663
commit fae23f0906
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 278 additions and 50 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Product Editor: disable `Add` button when no terms or options when creating variations

View File

@ -15,7 +15,7 @@ import {
type ProductAttributeTerm, type ProductAttributeTerm,
type ProductAttribute, type ProductAttribute,
} from '@woocommerce/data'; } from '@woocommerce/data';
import { Button, Modal, Notice } from '@wordpress/components'; import { Button, Modal, Notice, Tooltip } from '@wordpress/components';
import { recordEvent } from '@woocommerce/tracks'; import { recordEvent } from '@woocommerce/tracks';
/** /**
@ -25,6 +25,7 @@ import { TRACKS_SOURCE } from '../../constants';
import { AttributeTableRow } from './attribute-table-row'; import { AttributeTableRow } from './attribute-table-row';
import type { EnhancedProductAttribute } from '../../hooks/use-product-attributes'; import type { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
import type { AttributesComboboxControlItem } from '../attribute-combobox-field/types'; import type { AttributesComboboxControlItem } from '../attribute-combobox-field/types';
import { isAttributeFilledOut } from './utils';
type NewAttributeModalProps = { type NewAttributeModalProps = {
title?: string; title?: string;
@ -111,14 +112,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
onAddAnother(); onAddAnother();
}; };
const hasTermsOrOptions = ( attribute: EnhancedProductAttribute ) => { const isGlobalAttribute = (
return ( attribute: EnhancedProductAttribute
( attribute.terms && attribute.terms.length > 0 ) || ): boolean => {
( attribute.options && attribute.options.length > 0 )
);
};
const isGlobalAttribute = ( attribute: EnhancedProductAttribute ) => {
return attribute.id !== 0; return attribute.id !== 0;
}; };
@ -136,16 +132,6 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
: attribute.options; : attribute.options;
}; };
const isAttributeFilledOut = (
attribute: EnhancedProductAttribute | null
): attribute is EnhancedProductAttribute => {
return (
attribute !== null &&
attribute.name.length > 0 &&
hasTermsOrOptions( attribute )
);
};
const getVisibleOrTrue = ( attribute: EnhancedProductAttribute ) => const getVisibleOrTrue = ( attribute: EnhancedProductAttribute ) =>
attribute.visible !== undefined ? attribute.visible : defaultVisibility; attribute.visible !== undefined ? attribute.visible : defaultVisibility;
@ -238,6 +224,10 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue: ( name: string, value: any ) => void; setValue: ( name: string, value: any ) => void;
} ) => { } ) => {
const isAddButtonDisabled = ! values.attributes.every(
( attr ) => isAttributeFilledOut( attr )
);
/** /**
* Select the attribute in the form field. * Select the attribute in the form field.
* If the attribute does not exist, create it. * If the attribute does not exist, create it.
@ -327,14 +317,10 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
index: number, index: number,
attribute?: EnhancedProductAttribute attribute?: EnhancedProductAttribute
) { ) {
/*
* By convention, it's a global attribute if the attribute ID is 0.
* For global attributes, the field name suffix
* to set the attribute terms is 'options',
* for local attributes, the field name suffix is 'terms'.
*/
const attributeTermPropName = const attributeTermPropName =
attribute?.id === 0 ? 'options' : 'terms'; attribute && isGlobalAttribute( attribute )
? 'terms'
: 'options';
const fieldName = `attributes[${ index }].${ attributeTermPropName }`; const fieldName = `attributes[${ index }].${ attributeTermPropName }`;
@ -472,15 +458,26 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
> >
{ cancelLabel } { cancelLabel }
</Button> </Button>
<Button <Tooltip
isPrimary text={
label={ addAccessibleLabel } isAddButtonDisabled
disabled={ ? __(
values.attributes.length === 1 && 'Add at least one attribute and one value. Press Enter to select.',
( values.attributes[ 0 ] === null || 'woocommerce'
values.attributes[ 0 ] === )
undefined ) : ''
} }
>
{ /*
* we need to wrap the button in a div to make the tooltip work,
* since when the button is disabled, the tooltip is not shown.
*/ }
<div>
<Button
variant="primary"
label={ addAccessibleLabel }
showTooltip={ true }
disabled={ isAddButtonDisabled }
onClick={ () => onClick={ () =>
onAddingAttributes( values ) onAddingAttributes( values )
} }
@ -488,6 +485,8 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
{ addLabel } { addLabel }
</Button> </Button>
</div> </div>
</Tooltip>
</div>
</Modal> </Modal>
); );
} } } }

View File

@ -1,15 +1,21 @@
/** /**
* External dependencies * External dependencies
*/ */
import { ProductProductAttribute } from '@woocommerce/data'; import {
ProductAttributeTerm,
ProductProductAttribute,
} from '@woocommerce/data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { import {
getAttributeKey, getAttributeKey,
hasTermsOrOptions,
isAttributeFilledOut,
reorderSortableProductAttributePositions, reorderSortableProductAttributePositions,
} from '../utils'; } from '../utils';
import { EnhancedProductAttribute } from '../../../hooks/use-product-attributes';
const attributeList: Record< number | string, ProductProductAttribute > = { const attributeList: Record< number | string, ProductProductAttribute > = {
15: { 15: {
@ -74,3 +80,170 @@ describe( 'getAttributeKey', () => {
expect( getAttributeKey( attributeList.Quality ) ).toEqual( 'Quality' ); expect( getAttributeKey( attributeList.Quality ) ).toEqual( 'Quality' );
} ); } );
} ); } );
describe( 'hasTermsOrOptions', () => {
it( 'should return true if the attribute has local terms (options)', () => {
const attribute: EnhancedProductAttribute = {
name: 'Color',
id: 0,
slug: 'color',
position: 0,
visible: true,
variation: true,
options: [ 'Beige', 'black', 'Blue' ],
};
expect( hasTermsOrOptions( attribute ) ).toBe( true );
} );
it( 'should return true if the attribute has global terms', () => {
const terms: ProductAttributeTerm[] = [
{
id: 1,
name: 'red',
slug: 'red',
description: 'red color',
count: 1,
menu_order: 0,
},
{
id: 2,
name: 'blue',
slug: 'blue',
description: 'blue color',
count: 1,
menu_order: 1,
},
{
id: 3,
name: 'green',
slug: 'green',
description: 'green color',
count: 1,
menu_order: 2,
},
];
const attribute: EnhancedProductAttribute = {
name: 'Color',
id: 123,
slug: 'color',
position: 0,
visible: true,
variation: true,
terms,
options: [],
};
expect( hasTermsOrOptions( attribute ) ).toBe( true );
} );
it( 'should return false if the attribute has neither terms nor options', () => {
const attribute: EnhancedProductAttribute = {
name: 'Empty',
id: 999,
slug: 'empty',
position: 0,
visible: true,
variation: true,
options: [],
};
expect( hasTermsOrOptions( attribute ) ).toBe( false );
} );
it( 'should return false if the attribute is null', () => {
const attribute = null;
expect( hasTermsOrOptions( attribute ) ).toBe( false );
} );
} );
describe( 'isAttributeFilledOut', () => {
it( 'should return true if the attribute has a name and local terms (options)', () => {
const attribute: EnhancedProductAttribute = {
name: 'Color',
id: 0,
slug: 'color',
position: 0,
visible: true,
variation: true,
options: [ 'Beige', 'black', 'Blue' ],
};
expect( isAttributeFilledOut( attribute ) ).toBe( true );
} );
it( 'should return true if the attribute has a name and global terms', () => {
const terms: ProductAttributeTerm[] = [
{
id: 1,
name: 'red',
slug: 'red',
description: 'red color',
count: 1,
menu_order: 0,
},
{
id: 2,
name: 'blue',
slug: 'blue',
description: 'blue color',
count: 1,
menu_order: 1,
},
{
id: 3,
name: 'green',
slug: 'green',
description: 'green color',
count: 1,
menu_order: 2,
},
];
const attribute: EnhancedProductAttribute = {
name: 'Color',
id: 123,
slug: 'color',
position: 0,
visible: true,
variation: true,
terms,
options: [],
};
expect( isAttributeFilledOut( attribute ) ).toBe( true );
} );
it( 'should return false if the attribute has a name but no terms or options', () => {
const attribute: EnhancedProductAttribute = {
name: 'Empty',
id: 999,
slug: 'empty',
position: 0,
visible: true,
variation: true,
options: [],
};
expect( isAttributeFilledOut( attribute ) ).toBe( false );
} );
it( 'should return false if the attribute is null', () => {
const attribute = null;
expect( isAttributeFilledOut( attribute ) ).toBe( false );
} );
it( 'should return false if the attribute has no name', () => {
const attribute: EnhancedProductAttribute = {
name: '',
id: 0,
slug: 'color',
position: 0,
visible: true,
variation: true,
options: [ 'Beige', 'black', 'Blue' ],
};
expect( isAttributeFilledOut( attribute ) ).toBe( false );
} );
} );

View File

@ -3,6 +3,11 @@
*/ */
import type { ProductProductAttribute } from '@woocommerce/data'; import type { ProductProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import type { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
/** /**
* Returns the attribute key. The key will be the `id` or the `name` when the id is 0. * Returns the attribute key. The key will be the `id` or the `name` when the id is 0.
* *
@ -48,3 +53,26 @@ export function reorderSortableProductAttributePositions(
} }
); );
} }
/**
* Checks if the given attribute has
* either terms (global attributes) or options (local attributes).
*
* @param {EnhancedProductAttribute} attribute - The attribute to check.
* @return {boolean} True if the attribute has terms or options, false otherwise.
*/
export const hasTermsOrOptions = (
attribute: EnhancedProductAttribute | null
): boolean => !! ( attribute?.terms?.length || attribute?.options?.length );
/**
* Checks if the given attribute is filled out,
* meaning it has a name and either terms or options.
*
* @param {EnhancedProductAttribute | null} attribute - The attribute to check.
* @return {attribute is EnhancedProductAttribute} - True if the attribute is filled out, otherwise false.
*/
export const isAttributeFilledOut = (
attribute: EnhancedProductAttribute | null
): attribute is EnhancedProductAttribute =>
!! attribute?.name.length && hasTermsOrOptions( attribute );

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
E2E: check the `Add` button when creating product variations in the new Product Editor

View File

@ -120,6 +120,11 @@ test(
.isVisible(); .isVisible();
await page.waitForLoadState( 'domcontentloaded' ); await page.waitForLoadState( 'domcontentloaded' );
// Confirm the Add button is disabled
await expect(
page.getByRole( 'button', { name: 'Add attributes' } )
).toBeDisabled();
} ); } );
await test.step( 'create local attributes with terms', async () => { await test.step( 'create local attributes with terms', async () => {
@ -131,10 +136,7 @@ test(
'.woocommerce-new-attribute-modal__table-row' '.woocommerce-new-attribute-modal__table-row'
); );
/* // First, check the app loads the attributes,
* First, check the app loads the attributes,
* based on the Spinner visibility.
*/
await waitForGlobalAttributesLoaded( page ); await waitForGlobalAttributesLoaded( page );
for ( const attribute of attributesData ) { for ( const attribute of attributesData ) {
@ -168,14 +170,32 @@ test(
await FormTokenFieldInputLocator.press( 'Enter' ); await FormTokenFieldInputLocator.press( 'Enter' );
} }
// Terms accepted, so the Add button should be enabled.
await expect(
page.getByRole( 'button', { name: 'Add attributes' } )
).toBeEnabled();
await page.getByLabel( 'Add another attribute' ).click(); await page.getByLabel( 'Add another attribute' ).click();
// Attribute no defined, so the Add button should be disabled.
await expect(
page.getByRole( 'button', { name: 'Add attributes' } )
).toBeDisabled();
} }
} );
// Remove the last row, as it was added by the last click on "Add another attribute".
await page
.getByRole( 'button', { name: 'Remove attribute' } )
.last()
.click();
await expect(
page.getByRole( 'button', { name: 'Add attributes' } )
).toBeEnabled();
// Add the product attributes // Add the product attributes
await page await page.getByRole( 'button', { name: 'Add attributes' } ).click();
.getByRole( 'button', { name: 'Add attributes' } )
.click();
} );
await test.step( 'verify attributes in product editor', async () => { await test.step( 'verify attributes in product editor', async () => {
// Locate the main attributes list element // Locate the main attributes list element