Commit 0567d9eb1aa for woocommerce

commit 0567d9eb1aa136ea108d8fcdcc02bb03125f4bb9
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Wed Apr 22 16:43:20 2026 +0200

    Introduce new 'Create value' modal in variable product editor (#64251)

    * Introduce new 'Create value' modal in variable product editor

    * Linting

    * Add changelog

    * Remove unnecessary placeholder

    * Undo unnecessary style changes

    * Formatting

    * Undo unnecessary style changes (II)

    * Code cleanup

    * Fix submitting with Enter key

    * Linting

    * Add e2e test

    * Make locator more robust

    * Get rid of jQuery code and avoid interpolation

    * Fix test

diff --git a/plugins/woocommerce/changelog/update-create-term-modal b/plugins/woocommerce/changelog/update-create-term-modal
new file mode 100644
index 00000000000..2ddadf35666
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-create-term-modal
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Introduce new 'Create value' modal in variable product editor
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 0748782b8da..1175b850c77 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -4265,7 +4265,7 @@ table.wc_shipping {
 /**
 * New Shipping Settings Refresh Modal Styles
 **/
-
+.wc-backbone-modal-add-attribute-term,
 .wc-backbone-modal-add-shipping-method,
 .wc-backbone-modal-shipping-method-settings,
 .wc-shipping-class-modal {
@@ -4406,6 +4406,7 @@ table.wc_shipping {
 		}
 	}

+	.wc-add-attribute-term-fields,
 	.wc-shipping-zone-method-fields {

 		& > label {
@@ -4539,6 +4540,26 @@ table.wc_shipping {
 	}
 }

+.wc-backbone-modal-add-attribute-term.wc-backbone-modal {
+	.wc-backbone-modal-content {
+		min-width: auto;
+		max-width: 400px;
+	}
+
+	input[type="text"] {
+		display: block;
+		font-size: 13px;
+		line-height: 16px;
+		margin: 6px 0;
+		padding: 12px;
+		width: 100%;
+	}
+
+	.wc-backbone-modal-main footer {
+		border-top: none;
+	}
+}
+
 .wc-backbone-modal-add-shipping-method .wc-backbone-modal-main article {
 	padding: 0 32px 50px 32px;
 }
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
index ed2da6febea..4ec93fa91c4 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
@@ -144,11 +144,13 @@ jQuery( function ( $ ) {
 				$( 'input#_virtual' ).prop( 'checked', false );
 			}

-			const cogs_field_tip = $( '._cogs_value_field' ).find( '.woocommerce-help-tip' );
+			const cogs_field_tip = $( '._cogs_value_field' ).find(
+				'.woocommerce-help-tip'
+			);
 			const cogs_field_tip_text =
-				'variable' === select_val ?
-					woocommerce_admin_meta_boxes.cogs_value_tooltip_variable_products :
-					woocommerce_admin_meta_boxes.cogs_value_tooltip_simple_products;
+				'variable' === select_val
+					? woocommerce_admin_meta_boxes.cogs_value_tooltip_variable_products
+					: woocommerce_admin_meta_boxes.cogs_value_tooltip_simple_products;
 			$( cogs_field_tip ).attr( 'aria-label', cogs_field_tip_text );
 			$( cogs_field_tip ).tipTip( {
 				attribute: 'aria-label',
@@ -191,16 +193,16 @@ jQuery( function ( $ ) {
 		$( '#tiptip_holder' ).removeAttr( 'style' );
 		$( '#tiptip_arrow' ).removeAttr( 'style' );
 		$( '.woocommerce-product-type-tip' )
-		.attr( 'tabindex', '0' )
-		.attr( 'aria-label', $( '<div />' ).html( content ).text() ) // Remove HTML tags.
-		.tipTip( {
-			attribute: 'data-tip',
-			content: content,
-			fadeIn: 50,
-			fadeOut: 50,
-			delay: 200,
-			keepAlive: true,
-		} );
+			.attr( 'tabindex', '0' )
+			.attr( 'aria-label', $( '<div />' ).html( content ).text() ) // Remove HTML tags.
+			.tipTip( {
+				attribute: 'data-tip',
+				content: content,
+				fadeIn: 50,
+				fadeOut: 50,
+				delay: 200,
+				keepAlive: true,
+			} );
 	}

 	function get_product_tip_content( product_type ) {
@@ -259,7 +261,9 @@ jQuery( function ( $ ) {
 		$( '.hide_if_' + product_type, context ).hide();

 		// POS visibility - requires combination of type AND downloadable status.
-		var is_pos_supported = ( product_type === 'simple' || product_type === 'variable' ) && ! is_downloadable;
+		var is_pos_supported =
+			( product_type === 'simple' || product_type === 'variable' ) &&
+			! is_downloadable;
 		if ( is_pos_supported ) {
 			$( '#pos_visibility_supported', context ).show();
 			$( '#pos_visibility_unsupported', context ).hide();
@@ -435,18 +439,22 @@ jQuery( function ( $ ) {
 	const $product_attributes = $( '.product_attributes' );
 	if ( $product_attributes.length === 1 ) {
 		// When the attributes tab is shown, add an empty attribute to be filled out by the user.
-		$( '#product_attributes' ).on( 'woocommerce_tab_shown', function() {
+		$( '#product_attributes' ).on( 'woocommerce_tab_shown', function () {
 			remove_blank_custom_attribute_if_no_other_attributes();

-			const woocommerce_attribute_items = $product_attributes.find( '.woocommerce_attribute' ).get();
+			const woocommerce_attribute_items = $product_attributes
+				.find( '.woocommerce_attribute' )
+				.get();

 			// If the product has no attributes, add an empty attribute to be filled out by the user.
-			if ( woocommerce_attribute_items.length === 0  ) {
+			if ( woocommerce_attribute_items.length === 0 ) {
 				add_custom_attribute_to_list();
 			}
 		} );

-		const woocommerce_attribute_items = $product_attributes.find( '.woocommerce_attribute' ).get();
+		const woocommerce_attribute_items = $product_attributes
+			.find( '.woocommerce_attribute' )
+			.get();

 		// Sort the attributes by their position.
 		woocommerce_attribute_items.sort( function ( a, b ) {
@@ -477,6 +485,7 @@ jQuery( function ( $ ) {
 	}

 	var selectedAttributes = [];
+	var currentAttributeTermCreationContext = null;
 	$( '.product_attributes .woocommerce_attribute' ).each( function (
 		index,
 		el
@@ -491,13 +500,17 @@ jQuery( function ( $ ) {
 				.attr( 'disabled', 'disabled' );
 		}

-		if ( 'undefined' === $(el).attr( 'data-taxonomy' ) ||
-			false === $(el).attr( 'data-taxonomy' ) ||
-			'' === $(el).attr( 'data-taxonomy' ) ) {
-			add_placeholder_to_attribute_values_field( $(el) );
+		if (
+			'undefined' === $( el ).attr( 'data-taxonomy' ) ||
+			false === $( el ).attr( 'data-taxonomy' ) ||
+			'' === $( el ).attr( 'data-taxonomy' )
+		) {
+			add_placeholder_to_attribute_values_field( $( el ) );

-			$( '.woocommerce_attribute input.woocommerce_attribute_used_for_variations' ).on( 'change', function() {
-				add_placeholder_to_attribute_values_field( $(el) );
+			$(
+				'.woocommerce_attribute input.woocommerce_attribute_used_for_variations'
+			).on( 'change', function () {
+				add_placeholder_to_attribute_values_field( $( el ) );
 			} );
 		}
 	} );
@@ -552,15 +565,27 @@ jQuery( function ( $ ) {
 	}

 	function add_placeholder_to_attribute_values_field( $attributeListItem ) {
+		var $used_for_variations_checkbox = $attributeListItem.find(
+			'input.woocommerce_attribute_used_for_variations'
+		);

-		var $used_for_variations_checkbox = $attributeListItem.find( 'input.woocommerce_attribute_used_for_variations' );
-
-		if ( $used_for_variations_checkbox.length && $used_for_variations_checkbox.is( ':checked' ) ) {
-			$attributeListItem.find( 'textarea' )
-				.attr( 'placeholder', woocommerce_admin_meta_boxes.i18n_attributes_used_for_variations_placeholder );
+		if (
+			$used_for_variations_checkbox.length &&
+			$used_for_variations_checkbox.is( ':checked' )
+		) {
+			$attributeListItem
+				.find( 'textarea' )
+				.attr(
+					'placeholder',
+					woocommerce_admin_meta_boxes.i18n_attributes_used_for_variations_placeholder
+				);
 		} else {
-			$attributeListItem.find( 'textarea' )
-				.attr( 'placeholder', woocommerce_admin_meta_boxes.i18n_attributes_default_placeholder );
+			$attributeListItem
+				.find( 'textarea' )
+				.attr(
+					'placeholder',
+					woocommerce_admin_meta_boxes.i18n_attributes_default_placeholder
+				);
 		}
 	}

@@ -601,8 +626,12 @@ jQuery( function ( $ ) {
 			if ( 'undefined' === typeof globalAttributeId ) {
 				add_placeholder_to_attribute_values_field( $attributeListItem );

-				$( '.woocommerce_attribute input.woocommerce_attribute_used_for_variations' ).on( 'change', function() {
-					add_placeholder_to_attribute_values_field( $(this).closest( '.woocommerce_attribute' ) );
+				$(
+					'.woocommerce_attribute input.woocommerce_attribute_used_for_variations'
+				).on( 'change', function () {
+					add_placeholder_to_attribute_values_field(
+						$( this ).closest( '.woocommerce_attribute' )
+					);
 				} );
 			}

@@ -616,7 +645,9 @@ jQuery( function ( $ ) {
 				return;
 			}

-			alert( woocommerce_admin_meta_boxes.i18n_add_attribute_error_notice );
+			alert(
+				woocommerce_admin_meta_boxes.i18n_add_attribute_error_notice
+			);
 			throw error;
 		} finally {
 			unblock_attributes_tab_container();
@@ -663,14 +694,17 @@ jQuery( function ( $ ) {

 	// Handle the Attributes onboarding dismissible notice.
 	// If users dismiss the notice, never show it again.
-	if ( localStorage.getItem('attributes-notice-dismissed' ) ) {
+	if ( localStorage.getItem( 'attributes-notice-dismissed' ) ) {
 		$( '#product_attributes .notice' ).hide();
 	}

-	$( '#product_attributes .notice.woocommerce-message button' ).on( 'click', function( e ) {
-		$( '#product_attributes .notice' ).hide();
-		localStorage.setItem( 'attributes-notice-dismissed', 'true');
-	} );
+	$( '#product_attributes .notice.woocommerce-message button' ).on(
+		'click',
+		function ( e ) {
+			$( '#product_attributes .notice' ).hide();
+			localStorage.setItem( 'attributes-notice-dismissed', 'true' );
+		}
+	);

 	$( 'select.wc-attribute-search' ).on( 'select2:select', function ( e ) {
 		const attributeId = e && e.params && e.params.data && e.params.data.id;
@@ -861,12 +895,143 @@ jQuery( function ( $ ) {
 		},
 	} );

+	$( document.body ).on(
+		'wc_backbone_modal_loaded',
+		function ( event, target ) {
+			if ( 'wc-modal-add-attribute-term' !== target ) {
+				return;
+			}
+
+			const modal = document.querySelector(
+				'.wc-backbone-modal-add-attribute-term'
+			);
+
+			if ( ! modal ) {
+				return;
+			}
+
+			const termInput = modal.querySelector(
+				'#wc-modal-add-attribute-term-input'
+			);
+			if ( termInput ) {
+				termInput.focus();
+			}
+
+			const form = modal.querySelector( '.wc-add-attribute-term-fields' );
+			if ( form ) {
+				form.addEventListener( 'submit', ( submitEvent ) => {
+					submitEvent.preventDefault();
+
+					const submitButton = modal.querySelector( '#btn-ok' );
+					if ( submitButton && ! submitButton.disabled ) {
+						submitButton.click();
+					}
+				} );
+			}
+		}
+	);
+
+	$( document.body ).on(
+		'wc_backbone_modal_validation',
+		function ( event, target, postedData ) {
+			if ( 'wc-modal-add-attribute-term' !== target ) {
+				return;
+			}
+
+			const modal = document.querySelector(
+				'.wc-backbone-modal-add-attribute-term'
+			);
+
+			if ( ! modal ) {
+				return;
+			}
+
+			const submitButton = modal.querySelector( '#btn-ok' );
+
+			if ( ! submitButton ) {
+				return;
+			}
+
+			const termName =
+				postedData && postedData.term ? postedData.term.trim() : '';
+			const hasValue = termName.length > 0;
+
+			submitButton.disabled = ! hasValue;
+		}
+	);
+
+	$( document.body ).on(
+		'wc_backbone_modal_response',
+		function ( event, target, postedData ) {
+			if ( 'wc-modal-add-attribute-term' !== target ) {
+				return;
+			}
+
+			if (
+				! currentAttributeTermCreationContext ||
+				! currentAttributeTermCreationContext.wrapper ||
+				! currentAttributeTermCreationContext.attribute
+			) {
+				$( '.product_attributes' ).unblock();
+				return;
+			}
+
+			const termName =
+				postedData && postedData.term ? postedData.term.trim() : '';
+
+			if ( ! termName ) {
+				$( '.product_attributes' ).unblock();
+				return;
+			}
+
+			const wrapper = currentAttributeTermCreationContext.wrapper;
+			const data = {
+				action: 'woocommerce_add_new_attribute',
+				taxonomy: currentAttributeTermCreationContext.attribute,
+				term: termName,
+				security: woocommerce_admin_meta_boxes.add_attribute_nonce,
+			};
+
+			$.post(
+				woocommerce_admin_meta_boxes.ajax_url,
+				data,
+				function ( response ) {
+					if ( response.error ) {
+						// Error.
+						window.alert( response.error );
+					} else if ( response.slug ) {
+						// Success.
+						const select = wrapper.querySelector(
+							'select.attribute_values'
+						);
+						if ( select ) {
+							const option = document.createElement( 'option' );
+							option.value = String( response.term_id );
+							option.selected = true;
+							option.textContent = response.name;
+							select.appendChild( option );
+
+							// Trigger change event natively.
+							const changeEvent = new Event( 'change', {
+								bubbles: true,
+							} );
+							select.dispatchEvent( changeEvent );
+						}
+					}
+
+					$( '.product_attributes' ).unblock();
+					currentAttributeTermCreationContext = null;
+				}
+			);
+		}
+	);
+
 	// Add a new attribute (via ajax).
 	$( '.product_attributes' ).on(
 		'click',
 		'button.add_new_attribute',
 		function ( event ) {
-			// prevent form submission but allow event propagation
+			// Prevent form submission but allow event propagation.
 			event.preventDefault();

 			$( '.product_attributes' ).block( {
@@ -877,49 +1042,33 @@ jQuery( function ( $ ) {
 				},
 			} );

-			var $wrapper = $( this ).closest( '.woocommerce_attribute' );
-			var attribute = $wrapper.data( 'taxonomy' );
-			var new_attribute_name = window.prompt(
-				woocommerce_admin_meta_boxes.new_attribute_prompt
-			);
+			const wrapper = this.closest( '.woocommerce_attribute' );
+			const attribute = wrapper ? wrapper.dataset.taxonomy : '';

-			if ( new_attribute_name ) {
-				var data = {
-					action: 'woocommerce_add_new_attribute',
-					taxonomy: attribute,
-					term: new_attribute_name,
-					security: woocommerce_admin_meta_boxes.add_attribute_nonce,
-				};
-
-				$.post(
-					woocommerce_admin_meta_boxes.ajax_url,
-					data,
-					function ( response ) {
-						if ( response.error ) {
-							// Error.
-							window.alert( response.error );
-						} else if ( response.slug ) {
-							// Success.
-							$wrapper
-								.find( 'select.attribute_values' )
-								.append(
-									'<option value="' +
-										response.term_id +
-										'" selected="selected">' +
-										response.name +
-										'</option>'
-								);
-							$wrapper
-								.find( 'select.attribute_values' )
-								.trigger( 'change' );
-						}
+			currentAttributeTermCreationContext = {
+				wrapper,
+				attribute,
+			};

-						$( '.product_attributes' ).unblock();
-					}
-				);
-			} else {
-				$( '.product_attributes' ).unblock();
+			$( this ).WCBackboneModal( {
+				template: 'wc-modal-add-attribute-term',
+			} );
+		}
+	);
+
+	$( document.body ).on(
+		'wc_backbone_modal_before_remove',
+		function ( event, target, postedData, submitButtonCalled ) {
+			if ( 'wc-modal-add-attribute-term' !== target ) {
+				return;
 			}
+
+			if ( submitButtonCalled ) {
+				return;
+			}
+
+			$( '.product_attributes' ).unblock();
+			currentAttributeTermCreationContext = null;
 		}
 	);

@@ -1280,8 +1429,9 @@ jQuery( function ( $ ) {

 	// add a tooltip to the right of the product image meta box "Set product image" and "Add product gallery images"
 	const setProductImageLink = $( '#set-post-thumbnail' );
-	const tooltipMarkup =
-		`<span class="woocommerce-help-tip" tabindex="0" aria-label="${ woocommerce_admin_meta_boxes.i18n_product_image_tip }"></span>`;
+	const tooltipMarkup = `<span class="woocommerce-help-tip" tabindex="0" aria-label="${
+		woocommerce_admin_meta_boxes.i18n_product_image_tip
+	}"></span>`;
 	const tooltipData = {
 		attribute: 'data-tip',
 		content: woocommerce_admin_meta_boxes.i18n_product_image_tip,
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
index 5a38b5cda9a..34d19edcec1 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
@@ -42,7 +42,7 @@ $product_attributes = $product_object->get_attributes( 'edit' );
 		$i = -1;

 		foreach ( $product_attributes as $attribute ) {
-			$i++;
+			++$i;
 			$metabox_class = array();

 			if ( $attribute->is_taxonomy() ) {
@@ -62,3 +62,33 @@ $product_attributes = $product_object->get_attributes( 'edit' );
 	</div>
 	<?php do_action( 'woocommerce_product_options_attributes' ); ?>
 </div>
+
+<script type="text/template" id="tmpl-wc-modal-add-attribute-term">
+	<div class="wc-backbone-modal wc-backbone-modal-add-attribute-term">
+		<div class="wc-backbone-modal-content">
+			<section class="wc-backbone-modal-main" role="main">
+				<header class="wc-backbone-modal-header">
+					<h1><?php esc_html_e( 'Create value', 'woocommerce' ); ?></h1>
+					<button class="modal-close modal-close-link dashicons dashicons-no-alt">
+						<span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
+					</button>
+				</header>
+				<article>
+					<form class="wc-add-attribute-term-fields" action="" method="post">
+						<label for="wc-modal-add-attribute-term-input"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label>
+						<input id="wc-modal-add-attribute-term-input" type="text" name="term" value="" />
+					</form>
+				</article>
+				<footer>
+					<div class="inner">
+						<div>
+							<button class="modal-close button button-large"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></button>
+							<button id="btn-ok" disabled class="button button-primary button-large"><?php esc_html_e( 'OK', 'woocommerce' ); ?></button>
+						</div>
+					</div>
+				</footer>
+			</section>
+		</div>
+	</div>
+	<div class="wc-backbone-modal-backdrop modal-close"></div>
+</script>
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/product/create-product-attributes.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/product/create-product-attributes.spec.ts
index 111998011a5..e921d1ca19c 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/product/create-product-attributes.spec.ts
+++ b/plugins/woocommerce/tests/e2e-pw/tests/product/create-product-attributes.spec.ts
@@ -223,3 +223,113 @@ test( 'can add custom product attributes', async ( { page, product } ) => {
 		} );
 	}
 } );
+
+test( 'can create attribute terms from the attributes modal', async ( {
+	page,
+	restApi,
+} ) => {
+	const attributeName = 'Fabric';
+	const initialTerm = 'Cotton';
+	const newTerm = `Linen-${ Date.now() }`;
+	let attributeId: number | undefined;
+	let createdProductId: number | undefined;
+
+	try {
+		await test.step( `Create a global attribute "${ attributeName }"`, async () => {
+			const response = await restApi.post(
+				`${ WC_API_PATH }/products/attributes`,
+				{
+					name: attributeName,
+				}
+			);
+
+			attributeId = response.data.id;
+		} );
+
+		await test.step( 'Create a variable product with that global attribute', async () => {
+			const response = await restApi.post( `${ WC_API_PATH }/products`, {
+				...getFakeProduct( { type: 'variable' } ),
+				attributes: [
+					{
+						id: attributeId,
+						visible: true,
+						variation: true,
+						options: [ initialTerm ],
+					},
+				],
+			} );
+
+			createdProductId = response.data.id;
+		} );
+
+		await test.step( `Open "Edit product" page of product id ${ createdProductId }`, async () => {
+			await page.goto(
+				`wp-admin/post.php?post=${ createdProductId }&action=edit`
+			);
+			await toggleVariableProductTour( page, false );
+		} );
+
+		await goToAttributesTab( page );
+
+		await test.step( `Expand the "${ attributeName }" attribute`, async () => {
+			await page
+				.getByRole( 'heading', {
+					name: attributeName,
+				} )
+				.last()
+				.click();
+		} );
+
+		await test.step( `Create a new term "${ newTerm }" from the modal`, async () => {
+			await page.getByRole( 'button', { name: 'Create value' } ).click();
+
+			const modal = page.locator(
+				'.wc-backbone-modal-add-attribute-term .wc-backbone-modal-content'
+			);
+			await expect( modal ).toBeVisible();
+
+			const modalHeader = modal.getByRole( 'heading', {
+				name: 'Create value',
+			} );
+			await expect( modalHeader ).toBeVisible();
+
+			const submitButton = page.getByRole( 'button', { name: 'OK' } );
+			await expect( submitButton ).toBeDisabled();
+
+			await modal.getByLabel( 'Name' ).fill( newTerm );
+			await expect( submitButton ).toBeEnabled();
+			await submitButton.click();
+		} );
+
+		await test.step( `Expect "${ newTerm }" to be in attribute values`, async () => {
+			await expect(
+				page.locator(
+					'.woocommerce_attribute .attribute_values option',
+					{
+						hasText: newTerm,
+					}
+				)
+			).toBeVisible();
+		} );
+	} finally {
+		// eslint-disable-next-line playwright/no-conditional-in-test
+		if ( createdProductId ) {
+			await restApi.delete(
+				`${ WC_API_PATH }/products/${ createdProductId }`,
+				{
+					force: true,
+				}
+			);
+		}
+
+		// eslint-disable-next-line playwright/no-conditional-in-test
+		if ( attributeId ) {
+			await restApi.delete(
+				`${ WC_API_PATH }/products/attributes/${ attributeId }`,
+				{
+					force: true,
+				}
+			);
+		}
+	}
+} );