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' ) ), + ), ), ), ),