diff --git a/packages/js/product-editor/changelog/update-product-editor-disable-add-button-when-no-attributes b/packages/js/product-editor/changelog/update-product-editor-disable-add-button-when-no-attributes new file mode 100644 index 00000000000..66f0660cb2e --- /dev/null +++ b/packages/js/product-editor/changelog/update-product-editor-disable-add-button-when-no-attributes @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Product Editor: disable `Add` button when no terms or options when creating variations diff --git a/packages/js/product-editor/src/components/attribute-control/new-attribute-modal.tsx b/packages/js/product-editor/src/components/attribute-control/new-attribute-modal.tsx index 3a5b2808f1a..f5302d8e4ce 100644 --- a/packages/js/product-editor/src/components/attribute-control/new-attribute-modal.tsx +++ b/packages/js/product-editor/src/components/attribute-control/new-attribute-modal.tsx @@ -15,7 +15,7 @@ import { type ProductAttributeTerm, type ProductAttribute, } from '@woocommerce/data'; -import { Button, Modal, Notice } from '@wordpress/components'; +import { Button, Modal, Notice, Tooltip } from '@wordpress/components'; import { recordEvent } from '@woocommerce/tracks'; /** @@ -25,6 +25,7 @@ import { TRACKS_SOURCE } from '../../constants'; import { AttributeTableRow } from './attribute-table-row'; import type { EnhancedProductAttribute } from '../../hooks/use-product-attributes'; import type { AttributesComboboxControlItem } from '../attribute-combobox-field/types'; +import { isAttributeFilledOut } from './utils'; type NewAttributeModalProps = { title?: string; @@ -111,14 +112,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( { onAddAnother(); }; - const hasTermsOrOptions = ( attribute: EnhancedProductAttribute ) => { - return ( - ( attribute.terms && attribute.terms.length > 0 ) || - ( attribute.options && attribute.options.length > 0 ) - ); - }; - - const isGlobalAttribute = ( attribute: EnhancedProductAttribute ) => { + const isGlobalAttribute = ( + attribute: EnhancedProductAttribute + ): boolean => { return attribute.id !== 0; }; @@ -136,16 +132,6 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( { : attribute.options; }; - const isAttributeFilledOut = ( - attribute: EnhancedProductAttribute | null - ): attribute is EnhancedProductAttribute => { - return ( - attribute !== null && - attribute.name.length > 0 && - hasTermsOrOptions( attribute ) - ); - }; - const getVisibleOrTrue = ( attribute: EnhancedProductAttribute ) => 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 setValue: ( name: string, value: any ) => void; } ) => { + const isAddButtonDisabled = ! values.attributes.every( + ( attr ) => isAttributeFilledOut( attr ) + ); + /** * Select the attribute in the form field. * If the attribute does not exist, create it. @@ -327,14 +317,10 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( { index: number, 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 = - attribute?.id === 0 ? 'options' : 'terms'; + attribute && isGlobalAttribute( attribute ) + ? 'terms' + : 'options'; const fieldName = `attributes[${ index }].${ attributeTermPropName }`; @@ -472,21 +458,34 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( { > { cancelLabel } - + { /* + * 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. + */ } +
+ +
+ ); diff --git a/packages/js/product-editor/src/components/attribute-control/test/utils.spec.ts b/packages/js/product-editor/src/components/attribute-control/test/utils.spec.ts index 3eef3f4bcb5..5551612e87e 100644 --- a/packages/js/product-editor/src/components/attribute-control/test/utils.spec.ts +++ b/packages/js/product-editor/src/components/attribute-control/test/utils.spec.ts @@ -1,15 +1,21 @@ /** * External dependencies */ -import { ProductProductAttribute } from '@woocommerce/data'; +import { + ProductAttributeTerm, + ProductProductAttribute, +} from '@woocommerce/data'; /** * Internal dependencies */ import { getAttributeKey, + hasTermsOrOptions, + isAttributeFilledOut, reorderSortableProductAttributePositions, } from '../utils'; +import { EnhancedProductAttribute } from '../../../hooks/use-product-attributes'; const attributeList: Record< number | string, ProductProductAttribute > = { 15: { @@ -74,3 +80,170 @@ describe( 'getAttributeKey', () => { 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 ); + } ); +} ); diff --git a/packages/js/product-editor/src/components/attribute-control/utils.ts b/packages/js/product-editor/src/components/attribute-control/utils.ts index 7963ef02f07..6856f73c5aa 100644 --- a/packages/js/product-editor/src/components/attribute-control/utils.ts +++ b/packages/js/product-editor/src/components/attribute-control/utils.ts @@ -3,6 +3,11 @@ */ 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. * @@ -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 ); diff --git a/plugins/woocommerce/changelog/update-e2e-check-add-button-when-creating-new-global-attribute-terms b/plugins/woocommerce/changelog/update-e2e-check-add-button-when-creating-new-global-attribute-terms new file mode 100644 index 00000000000..e944b9b4662 --- /dev/null +++ b/plugins/woocommerce/changelog/update-e2e-check-add-button-when-creating-new-global-attribute-terms @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +E2E: check the `Add` button when creating product variations in the new Product Editor diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/block-editor/product-attributes-block-editor.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/block-editor/product-attributes-block-editor.spec.js index 0c47c889ab2..2c41ebf9368 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/block-editor/product-attributes-block-editor.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/block-editor/product-attributes-block-editor.spec.js @@ -120,6 +120,11 @@ test( .isVisible(); 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 () => { @@ -131,10 +136,7 @@ test( '.woocommerce-new-attribute-modal__table-row' ); - /* - * First, check the app loads the attributes, - * based on the Spinner visibility. - */ + // First, check the app loads the attributes, await waitForGlobalAttributesLoaded( page ); for ( const attribute of attributesData ) { @@ -168,15 +170,33 @@ test( await FormTokenFieldInputLocator.press( 'Enter' ); } - await page.getByLabel( 'Add another attribute' ).click(); - } + // Terms accepted, so the Add button should be enabled. + await expect( + page.getByRole( 'button', { name: 'Add attributes' } ) + ).toBeEnabled(); - // Add the product attributes - await page - .getByRole( 'button', { name: 'Add attributes' } ) - .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 + await page.getByRole( 'button', { name: 'Add attributes' } ).click(); + await test.step( 'verify attributes in product editor', async () => { // Locate the main attributes list element const attributesListLocator = page.locator(