diff --git a/packages/js/product-editor/changelog/add-39455 b/packages/js/product-editor/changelog/add-39455
new file mode 100644
index 00000000000..24d50f193d3
--- /dev/null
+++ b/packages/js/product-editor/changelog/add-39455
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add product variation items block
diff --git a/packages/js/product-editor/src/blocks/index.ts b/packages/js/product-editor/src/blocks/index.ts
index 72691311569..17576c16270 100644
--- a/packages/js/product-editor/src/blocks/index.ts
+++ b/packages/js/product-editor/src/blocks/index.ts
@@ -23,4 +23,5 @@ export { init as initToggle } from './toggle';
export { init as attributesInit } from './attributes';
export { init as initVariations } from './variations';
export { init as initRequirePassword } from './password';
+export { init as initVariationItems } from './variation-items';
export { init as initVariationOptions } from './variation-options';
diff --git a/packages/js/product-editor/src/blocks/style.scss b/packages/js/product-editor/src/blocks/style.scss
index e1572eedac1..f70587d84b4 100644
--- a/packages/js/product-editor/src/blocks/style.scss
+++ b/packages/js/product-editor/src/blocks/style.scss
@@ -15,4 +15,5 @@
@import 'tab/editor.scss';
@import 'variations/editor.scss';
@import 'password/editor.scss';
+@import 'variation-items/editor.scss';
@import 'variation-options/editor.scss';
diff --git a/packages/js/product-editor/src/blocks/variation-items/block.json b/packages/js/product-editor/src/blocks/variation-items/block.json
new file mode 100644
index 00000000000..1be886724b0
--- /dev/null
+++ b/packages/js/product-editor/src/blocks/variation-items/block.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "woocommerce/product-variation-items-field",
+ "title": "Product variations items",
+ "category": "woocommerce",
+ "description": "The product variations items.",
+ "keywords": [ "products", "variations" ],
+ "textdomain": "default",
+ "attributes": {
+ "description": {
+ "type": "string",
+ "__experimentalRole": "content"
+ }
+ },
+ "supports": {
+ "align": false,
+ "html": false,
+ "multiple": false,
+ "reusable": false,
+ "inserter": false,
+ "lock": false,
+ "__experimentalToolbar": false
+ },
+ "editorStyle": "file:./editor.css"
+}
diff --git a/packages/js/product-editor/src/blocks/variation-items/edit.tsx b/packages/js/product-editor/src/blocks/variation-items/edit.tsx
new file mode 100644
index 00000000000..0549c6e3c3a
--- /dev/null
+++ b/packages/js/product-editor/src/blocks/variation-items/edit.tsx
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+import { createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { VariationsTable } from '../../components/variations-table';
+
+export function Edit() {
+ const blockProps = useBlockProps();
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/js/product-editor/src/blocks/variation-items/editor.scss b/packages/js/product-editor/src/blocks/variation-items/editor.scss
new file mode 100644
index 00000000000..7f71df3b059
--- /dev/null
+++ b/packages/js/product-editor/src/blocks/variation-items/editor.scss
@@ -0,0 +1,2 @@
+.wp-block-woocommerce-product-variations-items-field {
+}
diff --git a/packages/js/product-editor/src/blocks/variation-items/index.ts b/packages/js/product-editor/src/blocks/variation-items/index.ts
new file mode 100644
index 00000000000..8ef0d0cfd76
--- /dev/null
+++ b/packages/js/product-editor/src/blocks/variation-items/index.ts
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { BlockConfiguration } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { initBlock } from '../../utils/init-blocks';
+import blockConfiguration from './block.json';
+import { Edit } from './edit';
+import { VariationOptionsBlockAttributes } from './types';
+
+const { name, ...metadata } =
+ blockConfiguration as BlockConfiguration< VariationOptionsBlockAttributes >;
+
+export { metadata, name };
+
+export const settings: Partial<
+ BlockConfiguration< VariationOptionsBlockAttributes >
+> = {
+ example: {},
+ edit: Edit,
+};
+
+export function init() {
+ return initBlock( { name, metadata, settings } );
+}
diff --git a/packages/js/product-editor/src/blocks/variation-items/types.ts b/packages/js/product-editor/src/blocks/variation-items/types.ts
new file mode 100644
index 00000000000..243a8fafc43
--- /dev/null
+++ b/packages/js/product-editor/src/blocks/variation-items/types.ts
@@ -0,0 +1,8 @@
+/**
+ * External dependencies
+ */
+import { BlockAttributes } from '@wordpress/blocks';
+
+export interface VariationOptionsBlockAttributes extends BlockAttributes {
+ description: string;
+}
diff --git a/packages/js/product-editor/src/blocks/variation-options/editor.scss b/packages/js/product-editor/src/blocks/variation-options/editor.scss
index 88eaf1f96c2..2f8dee6f9a8 100644
--- a/packages/js/product-editor/src/blocks/variation-options/editor.scss
+++ b/packages/js/product-editor/src/blocks/variation-options/editor.scss
@@ -1,17 +1,20 @@
.wp-block-woocommerce-product-variations-options-field {
- .woocommerce-sortable {
+ .woocommerce-sortable {
padding: 0;
+
+ &__item:not(:last-child) .woocommerce-list-item {
+ border-bottom: 1px solid $gray-200;
+ }
}
.woocommerce-list-item {
background: none;
border: none;
- border-bottom: 1px solid $gray-200;
padding-left: 0;
- grid-template-columns: 26% auto 90px;
+ grid-template-columns: 26% auto 90px;
}
- .woocommerce-sortable__handle {
- display: none;
- }
+ .woocommerce-sortable__handle {
+ display: none;
+ }
}
diff --git a/packages/js/product-editor/src/components/variations-table/hidden-icon.tsx b/packages/js/product-editor/src/components/variations-table/hidden-icon.tsx
new file mode 100644
index 00000000000..92f5ee4e35d
--- /dev/null
+++ b/packages/js/product-editor/src/components/variations-table/hidden-icon.tsx
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+
+export default function HiddenIcon( {
+ width = 24,
+ height = 24,
+ ...props
+}: React.SVGProps< SVGSVGElement > ) {
+ return (
+
+ );
+}
diff --git a/packages/js/product-editor/src/components/variations-table/index.ts b/packages/js/product-editor/src/components/variations-table/index.ts
new file mode 100644
index 00000000000..8236a46fd33
--- /dev/null
+++ b/packages/js/product-editor/src/components/variations-table/index.ts
@@ -0,0 +1 @@
+export * from './variations-table';
diff --git a/packages/js/product-editor/src/components/variations-table/styles.scss b/packages/js/product-editor/src/components/variations-table/styles.scss
new file mode 100644
index 00000000000..6c62c10a0b4
--- /dev/null
+++ b/packages/js/product-editor/src/components/variations-table/styles.scss
@@ -0,0 +1,103 @@
+.woocommerce-product-variations {
+ ol {
+ @media ( min-width: #{ ($break-medium) } ) {
+ min-height: 420px;
+ }
+ }
+ display: flex;
+ flex-direction: column;
+ > div {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ }
+
+ &__status-dot {
+ margin-right: $gap-smaller;
+ &.green {
+ color: $alert-green;
+ }
+ &.yellow {
+ color: $alert-yellow;
+ }
+ &.red {
+ color: $alert-red;
+ }
+ }
+
+ &__price--fade,
+ &__quantity--fade {
+ opacity: 0.5;
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+
+ .components-button {
+ position: relative;
+ color: var(--wp-admin-theme-color);
+
+ &:disabled,
+ &[aria-disabled="true"] {
+ opacity: 1;
+ }
+
+ .components-spinner {
+ margin: 4px;
+ }
+ }
+
+ .components-button svg {
+ fill: none;
+ }
+
+ .components-button--visible {
+ color: $gray-700;
+ }
+
+ .components-button--hidden {
+ color: $alert-red;
+ }
+ }
+
+ .woocommerce-list-item {
+ display: grid;
+ grid-template-columns: auto 25% 25% 88px;
+ padding: 0;
+ min-height: calc($grid-unit * 9);
+ border: none;
+ }
+
+ .woocommerce-sortable {
+ margin: 0;
+ flex: 1 0 auto;
+
+ &__item:not(:last-child) .woocommerce-list-item {
+ border-bottom: 1px solid $gray-200;
+ }
+
+ &__handle {
+ display: none;
+ }
+ }
+
+ &.is-loading {
+ min-height: 476px;
+
+ .components-spinner {
+ width: 34px;
+ height: 34px;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ position: absolute;
+ margin: 0;
+ }
+ }
+
+ &__footer {
+ padding: $gap;
+ }
+}
diff --git a/packages/js/product-editor/src/components/variations-table/variations-table.tsx b/packages/js/product-editor/src/components/variations-table/variations-table.tsx
new file mode 100644
index 00000000000..4f16b59accf
--- /dev/null
+++ b/packages/js/product-editor/src/components/variations-table/variations-table.tsx
@@ -0,0 +1,266 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, Spinner, Tooltip } from '@wordpress/components';
+import {
+ EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
+ ProductVariation,
+} from '@woocommerce/data';
+import {
+ Link,
+ ListItem,
+ Pagination,
+ Sortable,
+ Tag,
+} from '@woocommerce/components';
+import { getNewPath } from '@woocommerce/navigation';
+import { useContext, useState, createElement } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+import classnames from 'classnames';
+import truncate from 'lodash/truncate';
+import { CurrencyContext } from '@woocommerce/currency';
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore No types for this exist yet.
+// eslint-disable-next-line @woocommerce/dependency-group
+import { useEntityId } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import HiddenIcon from './hidden-icon';
+import VisibleIcon from './visible-icon';
+import { getProductStockStatus, getProductStockStatusClass } from '../../utils';
+import {
+ DEFAULT_PER_PAGE_OPTION,
+ PRODUCT_VARIATION_TITLE_LIMIT,
+} from '../../constants';
+
+const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
+const VISIBLE_TEXT = __( 'Visible to customers', 'woocommerce' );
+const UPDATING_TEXT = __( 'Updating product variation', 'woocommerce' );
+
+export function VariationsTable() {
+ const [ currentPage, setCurrentPage ] = useState( 1 );
+ const [ perPage, setPerPage ] = useState( DEFAULT_PER_PAGE_OPTION );
+ const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >(
+ {}
+ );
+ const productId = useEntityId( 'postType', 'product' );
+ const context = useContext( CurrencyContext );
+ const { formatAmount } = context;
+ const { isLoading, variations, totalCount } = useSelect(
+ ( select ) => {
+ const {
+ getProductVariations,
+ hasFinishedResolution,
+ getProductVariationsTotalCount,
+ } = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
+ const requestParams = {
+ product_id: productId,
+ page: currentPage,
+ per_page: perPage,
+ order: 'asc',
+ orderby: 'menu_order',
+ };
+ return {
+ isLoading: ! hasFinishedResolution( 'getProductVariations', [
+ requestParams,
+ ] ),
+ variations:
+ getProductVariations< ProductVariation[] >( requestParams ),
+ totalCount:
+ getProductVariationsTotalCount< number >( requestParams ),
+ };
+ },
+ [ currentPage, perPage, productId ]
+ );
+
+ const { updateProductVariation } = useDispatch(
+ EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
+ );
+
+ if ( ! variations || isLoading ) {
+ return (
+
+
+
+ );
+ }
+
+ function handleCustomerVisibilityClick(
+ variationId: number,
+ status: 'private' | 'publish'
+ ) {
+ if ( isUpdating[ variationId ] ) return;
+ setIsUpdating( ( prevState ) => ( {
+ ...prevState,
+ [ variationId ]: true,
+ } ) );
+ updateProductVariation< Promise< ProductVariation > >(
+ { product_id: productId, id: variationId },
+ { status }
+ ).finally( () =>
+ setIsUpdating( ( prevState ) => ( {
+ ...prevState,
+ [ variationId ]: false,
+ } ) )
+ );
+ }
+
+ return (
+
+
+ { variations.map( ( variation ) => (
+
+
+ { variation.attributes.map( ( attribute ) => {
+ const tag = (
+ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
+ /* @ts-ignore Additional props are not required. */
+
+ );
+
+ return attribute.option.length <=
+ PRODUCT_VARIATION_TITLE_LIMIT ? (
+ tag
+ ) : (
+
+ { tag }
+
+ );
+ } ) }
+
+
+ { formatAmount( variation.price ) }
+
+
+
+ ●
+
+ { getProductStockStatus( variation ) }
+
+
+ { variation.status === 'private' && (
+
+
+
+ ) }
+
+ { variation.status === 'publish' && (
+
+
+
+ ) }
+
+
+ { __( 'Edit', 'woocommerce' ) }
+
+
+
+ ) ) }
+
+
+
+
+ );
+}
diff --git a/packages/js/product-editor/src/components/variations-table/visible-icon.tsx b/packages/js/product-editor/src/components/variations-table/visible-icon.tsx
new file mode 100644
index 00000000000..e9ffe2a930f
--- /dev/null
+++ b/packages/js/product-editor/src/components/variations-table/visible-icon.tsx
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+
+export default function VisibleIcon( {
+ width = 24,
+ height = 24,
+ ...props
+}: React.SVGProps< SVGSVGElement > ) {
+ return (
+
+ );
+}
diff --git a/packages/js/product-editor/src/constants.ts b/packages/js/product-editor/src/constants.ts
index 97e7a138338..6d7350a8016 100644
--- a/packages/js/product-editor/src/constants.ts
+++ b/packages/js/product-editor/src/constants.ts
@@ -52,3 +52,13 @@ export const PRODUCT_DETAILS_SLUG = 'product-details';
export const PRODUCT_SCHEDULED_SALE_SLUG = 'product-scheduled-sale';
export const TRACKS_SOURCE = 'product-block-editor-v1';
+
+/**
+ * Since the pagination component does not exposes the way of
+ * changing the per page options which are [25, 50, 75, 100]
+ * the default per page option will be the min in the list to
+ * keep compatibility.
+ *
+ * @see https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/components/src/pagination/index.js#L12
+ */
+export const DEFAULT_PER_PAGE_OPTION = 25;
diff --git a/packages/js/product-editor/src/style.scss b/packages/js/product-editor/src/style.scss
index 8ce015353a5..44ba9dbab08 100644
--- a/packages/js/product-editor/src/style.scss
+++ b/packages/js/product-editor/src/style.scss
@@ -32,6 +32,7 @@
@import 'components/attribute-list-item/attribute-list-item.scss';
@import 'components/attribute-term-input-field/attribute-term-input-field.scss';
@import 'components/attribute-term-input-field/create-attribute-term-modal.scss';
+@import 'components/variations-table/styles.scss';
/* Field Blocks */
diff --git a/plugins/woocommerce/changelog/add-39455 b/plugins/woocommerce/changelog/add-39455
new file mode 100644
index 00000000000..da876ceed77
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-39455
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Register the product variation items block
diff --git a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/BlockRegistry.php b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/BlockRegistry.php
index 246969f4bbc..0323e1a796d 100644
--- a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/BlockRegistry.php
+++ b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/BlockRegistry.php
@@ -42,6 +42,7 @@ class BlockRegistry {
'woocommerce/product-tab',
'woocommerce/product-inventory-quantity-field',
'woocommerce/product-toggle-field',
+ 'woocommerce/product-variation-items-field',
'woocommerce/product-variations-fields',
'woocommerce/product-password-field',
];
diff --git a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php
index 8afee4a76ce..c9852fe88b9 100644
--- a/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php
+++ b/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/Init.php
@@ -761,6 +761,13 @@ class Init {
),
array( array( 'woocommerce/product-variations-options-field' ) ),
),
+ array(
+ 'woocommerce/product-section',
+ array(
+ 'title' => __( 'Variations', 'woocommerce' ),
+ ),
+ array( array( 'woocommerce/product-variation-items-field' ) ),
+ ),
),
),
),