Commit 4318636ecf6 for woocommerce
commit 4318636ecf60f90f9b81bcd825a427ac46e90764
Author: James Kemp <me@jckemp.com>
Date: Fri May 22 11:22:51 2026 +0100
Add read-only attribute tables to variation refresh (#65255)
* Add shared product attribute row helpers
* Add read-only attribute tables to the variations view
* Mount the read-only attributes table in the attributes tab
* Match variation attribute separators to DataViews rows
* Point global attribute links to the terms screen
* Refactor variation attributes handling: remove old implementation and introduce new structure
* improve comment
* rename php class
* improve message
* fix pagination
* refactor: update product entity types and improve attribute handling
* add changelog
---------
Co-authored-by: Luigi Teschio <gigitux@gmail.com>
diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
index 8e3388a0874..13dd0ed1ee9 100644
--- a/packages/js/experimental-products-app/src/fields/types.ts
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -6,7 +6,28 @@ import type {
ProductStatus as WooProductStatus,
} from '@woocommerce/data';
-export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
+export type ProductEntityAttribute = {
+ id: number;
+ name: string;
+ slug: string;
+ position: number;
+ visible: boolean;
+ variation: boolean;
+ options: string[];
+};
+
+export type ProductEntityDefaultAttribute = {
+ id: number;
+ name: string;
+ option: string;
+};
+
+export type ProductEntityRecord = Omit<
+ Product,
+ 'attributes' | 'categories' | 'default_attributes' | 'tags'
+> & {
+ attributes: ProductEntityAttribute[];
+ default_attributes: ProductEntityDefaultAttribute[];
cost_of_goods_sold?: {
values?: Array< {
defined_value?: number | string | null;
diff --git a/packages/js/experimental-products-app/src/index.ts b/packages/js/experimental-products-app/src/index.ts
index bfe124e06b3..56858c6c385 100644
--- a/packages/js/experimental-products-app/src/index.ts
+++ b/packages/js/experimental-products-app/src/index.ts
@@ -1,3 +1,6 @@
export * from './products';
export { ProductsApp } from './app';
-export { initializeVariationView } from './variation-view-app';
+export {
+ initializeProductAttributesView,
+ initializeVariationView,
+} from './variation-view-app';
diff --git a/packages/js/experimental-products-app/src/variation-view-app.tsx b/packages/js/experimental-products-app/src/variation-view-app.tsx
index f6926695f6e..c2363c9418f 100644
--- a/packages/js/experimental-products-app/src/variation-view-app.tsx
+++ b/packages/js/experimental-products-app/src/variation-view-app.tsx
@@ -22,15 +22,20 @@ const VariationView = lazy( () =>
} ) )
);
-/**
- * Initializes the classic product editor variation view.
- *
- * @param {string} containerId DOM element ID.
- * @param {number} productId Parent product ID.
- */
-export function initializeVariationView(
+const ProductAttributes = lazy( () =>
+ import(
+ /* webpackChunkName: "experimental-products-app-variation-view-main" */
+ './variation-view'
+ ).then( ( module ) => ( {
+ default: module.ProductAttributes,
+ } ) )
+);
+
+// The variations redesign mounts into separate PHP-provided metabox roots,
+// but each root needs the same app providers.
+function renderWithProviders(
containerId: string,
- productId: number
+ children: JSX.Element
): void {
const target = document.getElementById( containerId );
@@ -43,11 +48,43 @@ export function initializeVariationView(
<StrictMode>
<Suspense fallback={ null }>
<RouterProvider>
- <ThemeProvider>
- <VariationView productId={ productId } />
- </ThemeProvider>
+ <ThemeProvider>{ children }</ThemeProvider>
</RouterProvider>
</Suspense>
</StrictMode>
);
}
+
+/**
+ * Initializes the classic product editor variation view.
+ *
+ * @param {string} containerId DOM element ID.
+ * @param {number} productId Parent product ID.
+ */
+export function initializeVariationView(
+ containerId: string,
+ productId: number
+): void {
+ renderWithProviders(
+ containerId,
+ <VariationView productId={ productId } />
+ );
+}
+
+/**
+ * Initializes the product data attributes panel for the variations redesign.
+ * This is mounted separately from the variations view because the attributes
+ * panel has its own metabox DOM root.
+ *
+ * @param {string} containerId DOM element ID.
+ * @param {number} productId Parent product ID.
+ */
+export function initializeProductAttributesView(
+ containerId: string,
+ productId: number
+): void {
+ renderWithProviders(
+ containerId,
+ <ProductAttributes productId={ productId } />
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/index.tsx b/packages/js/experimental-products-app/src/variation-view/index.tsx
index 5927f307caf..580c0d8f7de 100644
--- a/packages/js/experimental-products-app/src/variation-view/index.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -2,8 +2,10 @@
* External dependencies
*/
import { DataViews, type Action, type View } from '@wordpress/dataviews';
-import { Button, Stack } from '@wordpress/ui';
+import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
+import { Stack } from '@wordpress/ui';
import { __ } from '@wordpress/i18n';
+import { moreVertical } from '@wordpress/icons';
import { useMemo, useState, useCallback, useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
@@ -21,11 +23,14 @@ import ProductEdit from '../product-edit';
import { getProductWithUpdatedVariation } from '../product-edit/utils';
import type { ProductEntityRecord } from '../fields/types';
import { unlock } from '../lock-unlock';
+import { VariationAttributes } from './variation-attributes';
import {
getProductListNavigationPath,
getSelectionFromPostId,
} from '../product-list/utils';
+export { ProductAttributes } from './variation-attributes';
+
const EMPTY_ARRAY: VariationEntityRecord[] = [];
const EMPTY_PRODUCT_RECORDS: ProductEntityRecord[] = [];
const { useHistory, useLocation } = unlock( routerPrivateApis );
@@ -34,6 +39,11 @@ type VariationViewProps = {
productId: number;
};
+type VariationMoreActionsProps = {
+ disabled: boolean;
+ onEditSelected: () => void;
+};
+
function variationMatchesSearch(
variation: VariationEntityRecord,
search: string
@@ -77,6 +87,46 @@ function sortVariations( variations: VariationEntityRecord[], view: View ) {
} );
}
+function VariationMoreActions( {
+ disabled,
+ onEditSelected,
+}: VariationMoreActionsProps ) {
+ return (
+ <DropdownMenu
+ icon={ moreVertical }
+ label={ __( 'More actions', 'woocommerce' ) }
+ className="woocommerce-variation-view__more-actions"
+ popoverProps={ {
+ placement: 'bottom-end',
+ } }
+ toggleProps={ {
+ size: 'compact',
+ } }
+ >
+ { ( { onClose } ) => (
+ <MenuGroup>
+ <MenuItem
+ disabled={ disabled }
+ onClick={ () => {
+ onEditSelected();
+ onClose();
+ } }
+ >
+ { __( 'Edit', 'woocommerce' ) }
+ </MenuItem>
+ <MenuItem
+ disabled={ disabled }
+ isDestructive
+ onClick={ onClose }
+ >
+ { __( 'Delete variation', 'woocommerce' ) }
+ </MenuItem>
+ </MenuGroup>
+ ) }
+ </DropdownMenu>
+ );
+}
+
export function VariationView( { productId }: VariationViewProps ) {
const { navigate } = useHistory();
const location = useLocation();
@@ -122,7 +172,7 @@ export function VariationView( { productId }: VariationViewProps ) {
: undefined,
};
},
- [ productId, query ]
+ [ query ]
);
const allVariations = useMemo< VariationEntityRecord[] >(
@@ -240,6 +290,10 @@ export function VariationView( { productId }: VariationViewProps ) {
return (
<div className="woocommerce-variation-view">
+ <VariationAttributes productId={ productId } />
+ <h3 className="woocommerce-variation-view__title">
+ { __( 'Variations', 'woocommerce' ) }
+ </h3>
<DataViews
data={ variations }
fields={ variationFields }
@@ -260,23 +314,36 @@ export function VariationView( { productId }: VariationViewProps ) {
direction="row"
align="center"
justify="space-between"
+ gap="sm"
className="woocommerce-variation-view__toolbar"
>
- <DataViews.Search
- label={ __( 'Search variations', 'woocommerce' ) }
- />
- <Stack direction="row" gap="xs">
+ <Stack
+ direction="row"
+ align="center"
+ gap="xs"
+ className="woocommerce-variation-view__toolbar-search"
+ >
+ <DataViews.Search
+ label={ __( 'Search variations', 'woocommerce' ) }
+ />
+ <DataViews.FiltersToggle />
+ </Stack>
+ <Stack
+ direction="row"
+ align="center"
+ gap="xs"
+ className="woocommerce-variation-view__toolbar-actions"
+ >
<DataViews.ViewConfig />
- <Button
+ <VariationMoreActions
disabled={ selection.length === 0 }
- onClick={ () =>
+ onEditSelected={ () =>
handleEditSelectedVariations( selection )
}
- >
- { __( 'Edit options', 'woocommerce' ) }
- </Button>
+ />
</Stack>
</Stack>
+ <DataViews.FiltersToggled className="woocommerce-variation-view__filters" />
<DataViews.Layout />
<DataViews.Footer />
</DataViews>
diff --git a/packages/js/experimental-products-app/src/variation-view/style.scss b/packages/js/experimental-products-app/src/variation-view/style.scss
index 8eac9afc249..0aee17eeded 100644
--- a/packages/js/experimental-products-app/src/variation-view/style.scss
+++ b/packages/js/experimental-products-app/src/variation-view/style.scss
@@ -1,32 +1,146 @@
+@use "@wordpress/base-styles/colors";
+
// overflow: hidden on .panel-wrap creates a scroll container that prevents
// position: sticky on .dataviews-footer from working relative to the viewport.
// overflow: clip preserves the float-clearfix BFC without creating a scroll
// container, so the footer can stick to the browser bottom.
-#woocommerce-product-data .panel-wrap:has(#woocommerce-variations-classic-root) {
+#woocommerce-product-data .panel-wrap:has(#woocommerce-variations-classic-root),
+#woocommerce-product-data .panel-wrap:has(#woocommerce-product-attributes-classic-root) {
overflow: clip;
}
+.woocommerce-variation-attributes {
+ &--has-separator {
+ border-bottom: 1px solid colors.$gray-100;
+ }
+
+ &__notice.components-notice {
+ margin: 32px 16px 16px;
+ border-left-color: var(--wpds-color-stroke-surface-info-strong);
+ background: linear-gradient(
+ 90deg,
+ var(--wpds-color-bg-surface-info) 0%,
+ var(--wpds-color-bg-surface-info-weak) 100%
+ );
+ color: var(--wpds-color-fg-content-neutral);
+
+ a {
+ color: var(--wpds-color-fg-interactive-brand);
+ }
+
+ .components-notice__dismiss {
+ color: var(--wpds-color-fg-content-neutral-weak);
+ }
+ }
+
+ &__header {
+ padding: 16px 24px;
+ }
+
+ &__title {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 20px;
+ }
+
+ &__title-group {
+ min-width: 0;
+ }
+
+ &__help {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ cursor: help;
+ outline: none;
+
+ &:hover,
+ &:focus-visible {
+ color: var(--wpds-color-fg-content-neutral);
+ }
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ &__body {
+ padding: 0;
+ }
+
+ &__pill-list {
+ min-width: 0;
+ }
+
+ &__pill {
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: 8px;
+ background: var(--wpds-color-bg-surface-neutral-strong);
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: 13px;
+ line-height: 20px;
+ }
+
+ &__muted {
+ color: var(--wpds-color-fg-content-neutral-weak);
+ }
+
+ &__global-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ color: var(--wpds-color-fg-interactive-brand);
+ text-decoration: underline;
+ }
+
+ svg {
+ flex-shrink: 0;
+ opacity: 0.6;
+ }
+
+ &:hover svg {
+ opacity: 1;
+ }
+ }
+
+}
+
.woocommerce-product-list__filters {
padding: var(--wpds-dimension-padding-sm, 8px) var(--wpds-dimension-padding-2xl, 24px);
}
.woocommerce-variation-view {
- // DataViews is designed for a constrained editor viewport. Override to let
- // the table grow to its natural height in the traditional metabox document flow.
- .dataviews-wrapper {
- height: auto;
+ &__title {
+ margin: 0;
+ padding: 16px 24px 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 20px;
}
- .dataviews-layout__container {
- overflow: visible;
+ &__toolbar {
+ padding: 16px 24px 24px;
}
- &__toolbar {
- padding: var(--wpds-dimension-padding-lg, 24px);
+ &__toolbar-search,
+ &__toolbar-actions {
+ min-width: 0;
+ }
+
+ &__filters {
+ padding: 0 24px 16px;
}
&__variation-options--empty {
- color: var(--wpds-color-fg-content-neutral-weak, #757575);
+ color: var(--wpds-color-fg-content-neutral-weak);
}
&__variation-options-cell {
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.test.ts b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.test.ts
new file mode 100644
index 00000000000..c53a36809fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.test.ts
@@ -0,0 +1,209 @@
+/**
+ * Internal dependencies
+ */
+import {
+ getProductAttributeRows,
+ getVariationAttributeRows,
+} from './attribute-rows';
+import type { ProductEntityRecord } from '../../fields/types';
+
+function getProduct(
+ overrides: Partial< ProductEntityRecord >
+): ProductEntityRecord {
+ return {
+ attributes: [],
+ default_attributes: [],
+ ...overrides,
+ } as ProductEntityRecord;
+}
+
+describe( 'getVariationAttributeRows', () => {
+ it( 'returns only attributes used for variations in product order', () => {
+ const rows = getVariationAttributeRows(
+ getProduct( {
+ attributes: [
+ {
+ id: 0,
+ name: 'Pattern',
+ slug: 'pattern',
+ options: [ 'Dots' ],
+ position: 3,
+ variation: false,
+ visible: true,
+ },
+ {
+ id: 2,
+ name: 'Color',
+ slug: 'pa_color',
+ options: [ 'Yellow', 'Blue' ],
+ position: 2,
+ variation: true,
+ visible: true,
+ },
+ {
+ id: 1,
+ name: 'Theme',
+ slug: 'pa_theme',
+ options: [ 'Unicorn', 'Pirate' ],
+ position: 1,
+ variation: true,
+ visible: true,
+ },
+ ],
+ } )
+ );
+
+ expect( rows ).toEqual( [
+ expect.objectContaining( {
+ attributeId: 1,
+ id: 'pa_theme',
+ isGlobal: true,
+ name: 'Theme',
+ slug: 'pa_theme',
+ values: [ 'Unicorn', 'Pirate' ],
+ } ),
+ expect.objectContaining( {
+ attributeId: 2,
+ id: 'pa_color',
+ isGlobal: true,
+ name: 'Color',
+ slug: 'pa_color',
+ values: [ 'Yellow', 'Blue' ],
+ } ),
+ ] );
+ } );
+
+ it( 'maps default values by attribute ID, name, and slug', () => {
+ const rows = getVariationAttributeRows(
+ getProduct( {
+ attributes: [
+ {
+ id: 10,
+ name: 'Theme',
+ slug: 'pa_theme',
+ options: [ 'Unicorn' ],
+ position: 1,
+ variation: true,
+ visible: true,
+ },
+ {
+ id: 0,
+ name: 'Material',
+ slug: 'material',
+ options: [ 'Cotton' ],
+ position: 2,
+ variation: true,
+ visible: true,
+ },
+ {
+ id: 11,
+ name: 'Color',
+ slug: 'pa_color',
+ options: [ 'Yellow' ],
+ position: 3,
+ variation: true,
+ visible: true,
+ },
+ ],
+ default_attributes: [
+ { id: 10, name: 'Theme', option: 'Unicorn' },
+ { id: 0, name: 'Material', option: 'Cotton' },
+ { id: 0, name: 'pa_color', option: 'Yellow' },
+ ],
+ } )
+ );
+
+ expect( rows.map( ( row ) => row.defaultValue ) ).toEqual( [
+ 'Unicorn',
+ 'Cotton',
+ 'Yellow',
+ ] );
+ } );
+
+ it( 'marks custom attributes as local', () => {
+ const rows = getVariationAttributeRows(
+ getProduct( {
+ attributes: [
+ {
+ id: 0,
+ name: 'Material',
+ slug: 'material',
+ options: [ 'Cotton' ],
+ position: 1,
+ variation: true,
+ visible: true,
+ },
+ ],
+ } )
+ );
+
+ expect( rows[ 0 ] ).toEqual(
+ expect.objectContaining( {
+ attributeId: 0,
+ id: 'material',
+ isGlobal: false,
+ name: 'Material',
+ slug: 'material',
+ } )
+ );
+ } );
+} );
+
+describe( 'getProductAttributeRows', () => {
+ it( 'returns only attributes not used for variations in product order', () => {
+ const rows = getProductAttributeRows(
+ getProduct( {
+ attributes: [
+ {
+ id: 2,
+ name: 'Color',
+ slug: 'pa_color',
+ options: [ 'Yellow', 'Blue' ],
+ position: 2,
+ variation: true,
+ visible: true,
+ },
+ {
+ id: 0,
+ name: 'Pattern',
+ slug: 'pattern',
+ options: [ 'Dots' ],
+ position: 3,
+ variation: false,
+ visible: true,
+ },
+ {
+ id: 1,
+ name: 'Material',
+ slug: 'pa_material',
+ options: [ 'Cotton' ],
+ position: 1,
+ variation: false,
+ visible: false,
+ },
+ ],
+ } )
+ );
+
+ expect( rows ).toEqual( [
+ expect.objectContaining( {
+ attributeId: 1,
+ id: 'pa_material',
+ isGlobal: true,
+ isVisible: false,
+ name: 'Material',
+ slug: 'pa_material',
+ values: [ 'Cotton' ],
+ } ),
+ expect.objectContaining( {
+ attributeId: 0,
+ id: 'pattern',
+ isGlobal: false,
+ isVisible: true,
+ name: 'Pattern',
+ slug: 'pattern',
+ values: [ 'Dots' ],
+ } ),
+ ] );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.ts b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.ts
new file mode 100644
index 00000000000..6b65eb16f23
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.ts
@@ -0,0 +1,93 @@
+/**
+ * Internal dependencies
+ */
+import type {
+ ProductEntityAttribute,
+ ProductEntityDefaultAttribute,
+ ProductEntityRecord,
+} from '../../fields/types';
+
+export type VariationAttributeRow = {
+ attributeId: number;
+ defaultValue: string;
+ id: string;
+ isGlobal: boolean;
+ isVisible: boolean;
+ name: string;
+ position: number;
+ slug: string;
+ values: string[];
+};
+
+type AttributeFilter = 'product' | 'variation';
+
+function getAttributeDefaultValue(
+ attribute: ProductEntityAttribute,
+ defaultAttributes: ProductEntityDefaultAttribute[]
+): string {
+ const matchingDefault = defaultAttributes.find( ( defaultAttribute ) => {
+ return (
+ ( attribute.id > 0 && defaultAttribute.id === attribute.id ) ||
+ defaultAttribute.name === attribute.name ||
+ defaultAttribute.name === attribute.slug
+ );
+ } );
+
+ return matchingDefault?.option ?? '';
+}
+
+function getAttributeRows(
+ product:
+ | Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+ | undefined,
+ filter: AttributeFilter
+): VariationAttributeRow[] {
+ if ( ! product?.attributes ) {
+ return [];
+ }
+
+ const defaultAttributes = product.default_attributes ?? [];
+ const isVariationAttribute = filter === 'variation';
+
+ return product.attributes
+ .map( ( attribute, index ) => ( {
+ attribute,
+ index,
+ } ) )
+ .filter(
+ ( { attribute } ) =>
+ Boolean( attribute.variation ) === isVariationAttribute
+ )
+ .sort( ( first, second ) => {
+ return (
+ first.attribute.position - second.attribute.position ||
+ first.index - second.index
+ );
+ } )
+ .map( ( { attribute } ) => ( {
+ attributeId: attribute.id,
+ defaultValue: getAttributeDefaultValue(
+ attribute,
+ defaultAttributes
+ ),
+ id: attribute.slug,
+ isGlobal: attribute.id > 0,
+ isVisible: Boolean( attribute.visible ),
+ name: attribute.name,
+ position: attribute.position,
+ slug: attribute.slug,
+ values: attribute.options ?? [],
+ } ) );
+}
+
+export function getProductAttributeRows(
+ product?: Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+): VariationAttributeRow[] {
+ return getAttributeRows( product, 'product' );
+}
+
+export function getVariationAttributeRows(
+ product?: Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+): VariationAttributeRow[] {
+ return getAttributeRows( product, 'variation' );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/dataviews.tsx b/packages/js/experimental-products-app/src/variation-view/variation-attributes/dataviews.tsx
new file mode 100644
index 00000000000..f665be91ab5
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/dataviews.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import { DataViews, type View, type ViewTable } from '@wordpress/dataviews';
+import { Tooltip } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Button, Stack } from '@wordpress/ui';
+import { help, Icon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../../fields/types';
+import { getAttributeTableFields, type AttributeTableColumn } from './fields';
+import type { VariationAttributeRow } from './attribute-rows';
+
+const EMPTY_ARRAY: VariationAttributeRow[] = [];
+const ATTRIBUTE_PAGE_SIZE = 50;
+const noop = () => undefined;
+
+type AttributeTableLayoutStyles = NonNullable<
+ ViewTable[ 'layout' ]
+>[ 'styles' ];
+type AttributeRowGetter = (
+ product?: Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+) => VariationAttributeRow[];
+
+function getAttributeTableView(
+ columns: AttributeTableColumn[],
+ styles: AttributeTableLayoutStyles
+): View {
+ return {
+ type: 'table',
+ page: 1,
+ perPage: ATTRIBUTE_PAGE_SIZE,
+ titleField: 'name',
+ fields: columns,
+ layout: {
+ styles,
+ },
+ };
+}
+
+export const DEFAULT_VARIATION_ATTRIBUTE_COLUMNS: AttributeTableColumn[] = [
+ 'values',
+ 'defaultValue',
+ 'isGlobal',
+];
+
+export const DEFAULT_PRODUCT_ATTRIBUTE_COLUMNS: AttributeTableColumn[] = [
+ 'values',
+ 'isVisible',
+ 'isGlobal',
+];
+
+export const DEFAULT_VARIATION_ATTRIBUTE_LAYOUT_STYLES = {
+ name: { width: '160px' },
+ values: { width: '100%' },
+ defaultValue: { width: '180px', minWidth: '180px' },
+ isGlobal: { width: '120px', minWidth: '120px' },
+};
+
+export const DEFAULT_PRODUCT_ATTRIBUTE_LAYOUT_STYLES = {
+ name: { width: '220px' },
+ values: { width: '100%' },
+ isVisible: { width: '240px', minWidth: '240px' },
+ isGlobal: { width: '120px', minWidth: '120px' },
+};
+
+type AttributeTableProps = {
+ columns: AttributeTableColumn[];
+ getRows: AttributeRowGetter;
+ hasSeparator?: boolean;
+ helpText: string;
+ hideWhenEmpty?: boolean;
+ nameLabel: string;
+ notice?: JSX.Element;
+ productId: number;
+ styles: AttributeTableLayoutStyles;
+ title: string;
+};
+
+export function AttributeTable( {
+ columns,
+ getRows,
+ hasSeparator = false,
+ helpText,
+ hideWhenEmpty = false,
+ nameLabel,
+ notice,
+ productId,
+ styles,
+ title,
+}: AttributeTableProps ) {
+ const [ view, setView ] = useState< View >( () =>
+ getAttributeTableView( columns, styles )
+ );
+ const fields = useMemo(
+ () => getAttributeTableFields( nameLabel ),
+ [ nameLabel ]
+ );
+ const { product, hasResolved } = useSelect(
+ ( select ) => {
+ const coreSelect = select( coreStore );
+ const resolutionArgs = [ 'root', 'product', productId ];
+
+ return {
+ hasResolved: coreSelect.hasFinishedResolution(
+ 'getEntityRecord',
+ resolutionArgs
+ ),
+ product: coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ productId
+ ) as unknown as ProductEntityRecord | undefined,
+ };
+ },
+ [ productId ]
+ );
+
+ const rows = useMemo(
+ () => ( hasResolved ? getRows( product ) : EMPTY_ARRAY ),
+ [ getRows, hasResolved, product ]
+ );
+ const perPage = view.perPage || ATTRIBUTE_PAGE_SIZE;
+ const pageRows = useMemo< VariationAttributeRow[] >( () => {
+ const page = view.page ?? 1;
+ const offset = ( page - 1 ) * perPage;
+
+ return rows.slice( offset, offset + perPage );
+ }, [ perPage, rows, view.page ] );
+ const paginationInfo = useMemo(
+ () => ( {
+ totalItems: rows.length,
+ totalPages: Math.ceil( rows.length / perPage ),
+ } ),
+ [ perPage, rows.length ]
+ );
+
+ if ( hideWhenEmpty && hasResolved && rows.length === 0 ) {
+ return null;
+ }
+
+ return (
+ <section
+ className={
+ hasSeparator
+ ? 'woocommerce-variation-attributes woocommerce-variation-attributes--has-separator'
+ : 'woocommerce-variation-attributes'
+ }
+ >
+ { notice }
+ <Stack
+ direction="row"
+ align="center"
+ justify="space-between"
+ className="woocommerce-variation-attributes__header"
+ >
+ <Stack
+ direction="row"
+ align="center"
+ gap="xs"
+ className="woocommerce-variation-attributes__title-group"
+ >
+ <h3 className="woocommerce-variation-attributes__title">
+ { title }
+ </h3>
+ <Tooltip text={ helpText }>
+ <span
+ className="woocommerce-variation-attributes__help"
+ tabIndex={ 0 }
+ aria-label={ __( 'Help', 'woocommerce' ) }
+ >
+ <Icon icon={ help } size={ 20 } />
+ </span>
+ </Tooltip>
+ </Stack>
+ <Button variant="outline" onClick={ noop }>
+ { __( 'Edit', 'woocommerce' ) }
+ </Button>
+ </Stack>
+ <div className="woocommerce-variation-attributes__body">
+ <div className="woocommerce-variation-attributes__dataview">
+ <DataViews
+ data={ pageRows }
+ fields={ fields }
+ view={ view }
+ onChangeView={ setView }
+ paginationInfo={ paginationInfo }
+ defaultLayouts={ { table: {} } }
+ getItemId={ ( item ) => item.id }
+ search={ false }
+ isLoading={ ! hasResolved }
+ >
+ <DataViews.Layout />
+ </DataViews>
+ </div>
+ </div>
+ </section>
+ );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/fields.tsx b/packages/js/experimental-products-app/src/variation-view/variation-attributes/fields.tsx
new file mode 100644
index 00000000000..682ed0eb4fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/fields.tsx
@@ -0,0 +1,159 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+import { Tooltip } from '@wordpress/components';
+import { addQueryArgs } from '@wordpress/url';
+import { getAdminLink } from '@woocommerce/settings';
+import { __ } from '@wordpress/i18n';
+import { Badge, Stack } from '@wordpress/ui';
+import { Icon, link as linkIcon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { VariationAttributeRow } from './attribute-rows';
+
+export type AttributeTableColumn =
+ | 'values'
+ | 'defaultValue'
+ | 'isVisible'
+ | 'isGlobal';
+
+function getGlobalAttributeTermsLink( attributeSlug: string ): string {
+ return getAdminLink(
+ addQueryArgs( 'edit-tags.php', {
+ taxonomy: attributeSlug,
+ post_type: 'product',
+ } )
+ );
+}
+
+function getBooleanLabel( value: boolean ) {
+ return value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
+}
+
+function EmptyDefaultValue() {
+ return (
+ <span className="woocommerce-variation-attributes__muted">—</span>
+ );
+}
+
+function BooleanValue( { value }: { value: boolean } ) {
+ const label = getBooleanLabel( value );
+
+ if ( value ) {
+ return <>{ label }</>;
+ }
+
+ return (
+ <span className="woocommerce-variation-attributes__muted">
+ { label }
+ </span>
+ );
+}
+
+function AttributeValuePills( { values }: { values: string[] } ) {
+ if ( values.length === 0 ) {
+ return <EmptyDefaultValue />;
+ }
+
+ return (
+ <Stack
+ direction="row"
+ gap="xs"
+ wrap="wrap"
+ className="woocommerce-variation-attributes__pill-list"
+ >
+ { values.map( ( value ) => (
+ <Badge
+ key={ value }
+ intent="none"
+ className="woocommerce-variation-attributes__pill"
+ >
+ { value }
+ </Badge>
+ ) ) }
+ </Stack>
+ );
+}
+
+export function getAttributeTableFields(
+ nameLabel: string
+): Field< VariationAttributeRow >[] {
+ return [
+ {
+ id: 'name',
+ label: nameLabel,
+ enableHiding: false,
+ enableSorting: false,
+ enableGlobalSearch: false,
+ getValue: ( { item } ) => item.name,
+ },
+ {
+ id: 'values',
+ label: __( 'Values', 'woocommerce' ),
+ enableSorting: false,
+ enableGlobalSearch: false,
+ getValue: ( { item } ) => item.values.join( ' ' ),
+ render: ( { item } ) => (
+ <AttributeValuePills values={ item.values } />
+ ),
+ },
+ {
+ id: 'defaultValue',
+ label: __( 'Default value', 'woocommerce' ),
+ enableSorting: false,
+ enableGlobalSearch: false,
+ getValue: ( { item } ) => item.defaultValue,
+ render: ( { item } ) =>
+ item.defaultValue ? (
+ <Badge
+ intent="none"
+ className="woocommerce-variation-attributes__pill"
+ >
+ { item.defaultValue }
+ </Badge>
+ ) : (
+ <EmptyDefaultValue />
+ ),
+ },
+ {
+ id: 'isVisible',
+ label: __( 'Visible on product page', 'woocommerce' ),
+ enableSorting: false,
+ enableGlobalSearch: false,
+ getValue: ( { item } ) => getBooleanLabel( item.isVisible ),
+ render: ( { item } ) => <BooleanValue value={ item.isVisible } />,
+ },
+ {
+ id: 'isGlobal',
+ label: __( 'Global', 'woocommerce' ),
+ enableSorting: false,
+ enableGlobalSearch: false,
+ getValue: ( { item } ) => getBooleanLabel( item.isGlobal ),
+ render: ( { item } ) =>
+ item.isGlobal ? (
+ <Tooltip
+ text={ __(
+ 'Available across all products. Customers can filter your catalog by this attribute.',
+ 'woocommerce'
+ ) }
+ >
+ <a
+ className="woocommerce-variation-attributes__global-link"
+ href={ getGlobalAttributeTermsLink( item.slug ) }
+ target="_blank"
+ rel="noreferrer"
+ onClick={ ( event ) => event.stopPropagation() }
+ >
+ { __( 'Yes', 'woocommerce' ) }
+ <Icon icon={ linkIcon } size={ 16 } />
+ </a>
+ </Tooltip>
+ ) : (
+ <BooleanValue value={ item.isGlobal } />
+ ),
+ },
+ ];
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/index.tsx b/packages/js/experimental-products-app/src/variation-view/variation-attributes/index.tsx
new file mode 100644
index 00000000000..39aa1c0a8f3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/index.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import { Notice } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import {
+ createInterpolateElement,
+ useEffect,
+ useState,
+} from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getProductAttributeRows,
+ getVariationAttributeRows,
+} from './attribute-rows';
+import type { ProductEntityRecord } from '../../fields/types';
+import {
+ AttributeTable,
+ DEFAULT_PRODUCT_ATTRIBUTE_COLUMNS,
+ DEFAULT_PRODUCT_ATTRIBUTE_LAYOUT_STYLES,
+ DEFAULT_VARIATION_ATTRIBUTE_COLUMNS,
+ DEFAULT_VARIATION_ATTRIBUTE_LAYOUT_STYLES,
+} from './dataviews';
+
+const VARIATIONS_PANEL_SELECTOR = '#variable_product_options';
+const VARIATIONS_TAB_SELECTOR =
+ '#woocommerce-product-data ul.product_data_tabs a[href="#variable_product_options"]';
+
+function openVariationsTab() {
+ const tabLink = document.querySelector< HTMLAnchorElement >(
+ VARIATIONS_TAB_SELECTOR
+ );
+ const panel = document.querySelector< HTMLElement >(
+ VARIATIONS_PANEL_SELECTOR
+ );
+ const tab = tabLink?.closest< HTMLElement >( 'li' );
+
+ if ( ! tabLink || ! panel || ! tab ) {
+ return;
+ }
+
+ if ( getComputedStyle( tab ).display === 'none' ) {
+ return;
+ }
+
+ tabLink.click();
+
+ if (
+ tab.classList.contains( 'active' ) &&
+ getComputedStyle( panel ).display !== 'none'
+ ) {
+ return;
+ }
+
+ const panelWrap = tabLink.closest< HTMLElement >( 'div.panel-wrap' );
+
+ if ( ! panelWrap ) {
+ return;
+ }
+
+ panelWrap
+ .querySelectorAll( 'ul.wc-tabs li' )
+ .forEach( ( item ) => item.classList.remove( 'active' ) );
+ tab.classList.add( 'active' );
+
+ panelWrap
+ .querySelectorAll< HTMLElement >( 'div.panel' )
+ .forEach( ( item ) => {
+ item.style.display = item === panel ? 'block' : 'none';
+ } );
+ panel.dispatchEvent( new Event( 'woocommerce_tab_shown' ) );
+}
+
+function ProductAttributesNotice() {
+ const [ isVisible, setIsVisible ] = useState( true );
+
+ useEffect( () => {
+ function handleVariationsLinkClick( event: MouseEvent ) {
+ const target = event.target;
+
+ if ( ! ( target instanceof Element ) ) {
+ return;
+ }
+
+ const link = target.closest< HTMLAnchorElement >(
+ '.woocommerce-variation-attributes__notice a[href="#variable_product_options"]'
+ );
+
+ if ( ! link ) {
+ return;
+ }
+
+ event.preventDefault();
+ openVariationsTab();
+ }
+
+ document.addEventListener( 'click', handleVariationsLinkClick, true );
+
+ return () => {
+ document.removeEventListener(
+ 'click',
+ handleVariationsLinkClick,
+ true
+ );
+ };
+ }, [] );
+
+ if ( ! isVisible ) {
+ return null;
+ }
+
+ return (
+ <Notice
+ status="info"
+ isDismissible
+ className="woocommerce-variation-attributes__notice"
+ onRemove={ () => setIsVisible( false ) }
+ >
+ { createInterpolateElement(
+ __(
+ 'Attributes used for variations have moved to the <variationsLink />.',
+ 'woocommerce'
+ ),
+ {
+ variationsLink: (
+ <a href="#variable_product_options">
+ { __( 'Variations tab', 'woocommerce' ) }
+ </a>
+ ),
+ }
+ ) }
+ </Notice>
+ );
+}
+
+type VariationAttributesProps = {
+ productId: number;
+};
+
+export function ProductAttributes( { productId }: VariationAttributesProps ) {
+ const { product, hasResolved } = useSelect(
+ ( select ) => {
+ const coreSelect = select( coreStore );
+ const resolutionArgs = [ 'root', 'product', productId ];
+
+ return {
+ hasResolved: coreSelect.hasFinishedResolution(
+ 'getEntityRecord',
+ resolutionArgs
+ ),
+ product: coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ productId
+ ) as unknown as ProductEntityRecord | undefined,
+ };
+ },
+ [ productId ]
+ );
+
+ const notice =
+ hasResolved && product?.type === 'variable' ? (
+ <ProductAttributesNotice />
+ ) : undefined;
+
+ return (
+ <AttributeTable
+ columns={ DEFAULT_PRODUCT_ATTRIBUTE_COLUMNS }
+ getRows={ getProductAttributeRows }
+ helpText={ __(
+ 'Product attributes describe details customers can use to search, filter, and compare products.',
+ 'woocommerce'
+ ) }
+ nameLabel={ __( 'Name', 'woocommerce' ) }
+ notice={ notice }
+ productId={ productId }
+ styles={ DEFAULT_PRODUCT_ATTRIBUTE_LAYOUT_STYLES }
+ title={ __( 'Product attributes', 'woocommerce' ) }
+ />
+ );
+}
+
+export function VariationAttributes( { productId }: VariationAttributesProps ) {
+ return (
+ <AttributeTable
+ columns={ DEFAULT_VARIATION_ATTRIBUTE_COLUMNS }
+ getRows={ getVariationAttributeRows }
+ hasSeparator
+ helpText={ __(
+ 'Edit attribute values to update combinations. Customers see attributes in the order shown, with the default value pre-selected on the product page.',
+ 'woocommerce'
+ ) }
+ hideWhenEmpty
+ nameLabel={ __( 'Attribute', 'woocommerce' ) }
+ productId={ productId }
+ styles={ DEFAULT_VARIATION_ATTRIBUTE_LAYOUT_STYLES }
+ title={ __( 'Variation attributes', 'woocommerce' ) }
+ />
+ );
+}
diff --git a/plugins/woocommerce/changelog/jamesckemp-readonly-variation-attributes b/plugins/woocommerce/changelog/jamesckemp-readonly-variation-attributes
new file mode 100644
index 00000000000..557f4a315f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/jamesckemp-readonly-variation-attributes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add readonly variation attributes
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 f26e4e94f4f..9534c2236e0 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
@@ -5,10 +5,22 @@
* @package WooCommerce\Admin
*/
+use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+if ( Features::exists( ProductVariationsClassicRedesign::FEATURE_ID ) ) {
+ ?>
+ <div id="product_attributes" class="panel wc-metaboxes-wrapper hidden">
+ <div id="<?php echo esc_attr( ProductVariationsClassicRedesign::ATTRIBUTES_ROOT_ID ); ?>"></div>
+ </div>
+ <?php
+ return;
+}
+
global $wc_product_attributes;
// Array of defined attribute taxonomies.
$attribute_taxonomies = wc_get_attribute_taxonomies();
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
index 9e0d6b29297..1131b5b51a3 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
@@ -6,18 +6,18 @@
*/
use Automattic\WooCommerce\Admin\Features\Features;
-use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign\Init;
+use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-if ( Features::exists( Init::FEATURE_ID ) ) {
+if ( Features::exists( ProductVariationsClassicRedesign::FEATURE_ID ) ) {
?>
<div id="variable_product_options" class="panel wc-metaboxes-wrapper hidden">
<div id="variable_product_options_inner">
- <div id="<?php echo esc_attr( Init::ROOT_ID ); ?>"></div>
+ <div id="<?php echo esc_attr( ProductVariationsClassicRedesign::ROOT_ID ); ?>"></div>
</div>
</div>
<?php
diff --git a/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign.php
similarity index 72%
rename from plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
rename to plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign.php
index 295251798ea..5796b28d34c 100644
--- a/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
+++ b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign.php
@@ -5,16 +5,17 @@
declare( strict_types = 1 );
-namespace Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+namespace Automattic\WooCommerce\Admin\Features;
/**
* Loads assets for the product variations classic redesign feature.
*/
-class Init {
- const FEATURE_ID = 'product-variations-classic-redesign';
- const SCRIPT_HANDLE = 'wc-experimental-products-app';
- const SCRIPT_PATH = 'experimental-products-app';
- const ROOT_ID = 'woocommerce-variations-classic-root';
+class ProductVariationsClassicRedesign {
+ const FEATURE_ID = 'product-variations-classic-redesign';
+ const SCRIPT_HANDLE = 'wc-experimental-products-app';
+ const SCRIPT_PATH = 'experimental-products-app';
+ const ROOT_ID = 'woocommerce-variations-classic-root';
+ const ATTRIBUTES_ROOT_ID = 'woocommerce-product-attributes-classic-root';
/**
* Constructor
@@ -78,14 +79,20 @@ class Init {
global $post;
$product_id = $post ? $post->ID : 0;
+ $script = sprintf(
+ 'window.wc.experimentalProductsApp.initializeVariationView( %s, %d );',
+ wp_json_encode( self::ROOT_ID ),
+ $product_id
+ );
+ $script .= sprintf(
+ ' window.wc.experimentalProductsApp.initializeProductAttributesView( %s, %d );',
+ wp_json_encode( self::ATTRIBUTES_ROOT_ID ),
+ $product_id
+ );
wp_add_inline_script(
self::SCRIPT_HANDLE,
- sprintf(
- 'window.wc.experimentalProductsApp.initializeVariationView( %s, %d );',
- wp_json_encode( self::ROOT_ID ),
- $product_id
- ),
+ $script,
'after'
);
}