diff --git a/plugins/woocommerce-blocks/assets/js/editor-components/product-attribute-term-control/index.tsx b/plugins/woocommerce-blocks/assets/js/editor-components/product-attribute-term-control/index.tsx index 5f6f515926a..2a951b1c509 100644 --- a/plugins/woocommerce-blocks/assets/js/editor-components/product-attribute-term-control/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/editor-components/product-attribute-term-control/index.tsx @@ -56,6 +56,7 @@ const ProductAttributeTermControl = ( { const renderItem = ( args: renderItemArgs ) => { const { item, search, depth = 0 } = args; + const count = item.count || 0; const classes = [ 'woocommerce-product-attributes__item', 'woocommerce-search-list__item', @@ -79,21 +80,21 @@ const ProductAttributeTermControl = ( { _n( '%d term', '%d terms', - item.count, + count, 'woo-gutenberg-products-block' ), - item.count + count ) } aria-label={ sprintf( /* translators: %1$s is the item name, %2$d is the count of terms for the item. */ _n( '%1$s, has %2$d term', '%1$s, has %2$d terms', - item.count, + count, 'woo-gutenberg-products-block' ), item.name, - item.count + count ) } /> ); @@ -111,21 +112,21 @@ const ProductAttributeTermControl = ( { _n( '%d product', '%d products', - item.count, + count, 'woo-gutenberg-products-block' ), - item.count + count ) } aria-label={ sprintf( /* translators: %1$s is the attribute name, %2$d is the count of products for that attribute. */ _n( '%1$s, has %2$d product', '%1$s, has %2$d products', - item.count, + count, 'woo-gutenberg-products-block' ), itemName, - item.count + count ) } /> ); diff --git a/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.js b/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx similarity index 61% rename from plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.js rename to plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx index 31d1ea30acb..a6dc1c3e75c 100644 --- a/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.js +++ b/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx @@ -3,26 +3,64 @@ */ import { __, _n, sprintf } from '@wordpress/i18n'; import { isEmpty } from '@woocommerce/types'; -import PropTypes from 'prop-types'; import { SearchListControl, SearchListItem, } from '@woocommerce/editor-components/search-list-control'; +import type { + SearchListControlProps, + renderItemArgs, +} from '@woocommerce/editor-components/search-list-control/types'; import { withInstanceId } from '@wordpress/compose'; import { withProductVariations, withSearchedProducts, withTransformSingleSelectToMultipleSelect, } from '@woocommerce/block-hocs'; +import type { + ProductResponseItem, + WithInjectedInstanceId, + WithInjectedProductVariations, + WithInjectedSearchedProducts, +} from '@woocommerce/types'; +import { convertProductResponseItemToSearchItem } from '@woocommerce/utils'; import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message'; import classNames from 'classnames'; -import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item.tsx'; +import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item'; /** * Internal dependencies */ import './style.scss'; +interface ProductControlProps { + /** + * Callback to update the selected products. + */ + onChange: () => void; + isCompact?: boolean; + /** + * The ID of the currently expanded product. + */ + expandedProduct: number | null; + /** + * Callback to search products by their name. + */ + onSearch: () => void; + /** + * Callback to render each item in the selection list, allows any custom object-type rendering. + */ + renderItem: SearchListControlProps[ 'renderItem' ] | null; + /** + * The ID of the currently selected item (product or variation). + */ + selected: number[]; + /** + * Whether to show variations in the list of items available. + */ + showVariations?: boolean; +} + const messages = { list: __( 'Products', 'woo-gutenberg-products-block' ), noItems: __( @@ -39,26 +77,35 @@ const messages = { ), }; -const ProductControl = ( { - expandedProduct, - error, - instanceId, - isCompact, - isLoading, - onChange, - onSearch, - products, - renderItem, - selected, - showVariations, - variations, - variationsLoading, -} ) => { - const renderItemWithVariations = ( args ) => { +const ProductControl = ( + props: ProductControlProps & + WithInjectedSearchedProducts & + WithInjectedProductVariations & + WithInjectedInstanceId +) => { + const { + expandedProduct = null, + error, + instanceId, + isCompact = false, + isLoading, + onChange, + onSearch, + products, + renderItem, + selected = [], + showVariations = false, + variations, + variationsLoading, + } = props; + + const renderItemWithVariations = ( + args: renderItemArgs< ProductResponseItem > + ) => { const { item, search, depth = 0, isSelected, onSelect } = args; const variationsCount = - item.variations && Array.isArray( item.variations ) - ? item.variations.length + item.details?.variations && Array.isArray( item.details.variations ) + ? item.details.variations.length : 0; const classes = classNames( 'woocommerce-search-product__item', @@ -74,6 +121,9 @@ const ProductControl = ( { // Top level items custom rendering based on SearchListItem. if ( ! item.breadcrumbs.length ) { + const hasVariations = + item.details?.variations && item.details.variations.length > 0; + return ( 0 + hasVariations ? sprintf( /* translators: %1$d is the number of variations of a product product. */ __( '%1$d variations', 'woo-gutenberg-products-block' ), - item.variations.length + item.details?.variations.length ) : null } name={ `products-${ instanceId }` } - aria-label={ sprintf( - /* translators: %1$s is the product name, %2$d is the number of variations of that product. */ - _n( - '%1$s, has %2$d variation', - '%1$s, has %2$d variations', - item.variations.length, - 'woo-gutenberg-products-block' - ), - item.name, - item.variations.length - ) } + aria-label={ + hasVariations + ? sprintf( + /* translators: %1$s is the product name, %2$d is the number of variations of that product. */ + _n( + '%1$s, has %2$d variation', + '%1$s, has %2$d variations', + item.details?.variations + ?.length as number, + 'woo-gutenberg-products-block' + ), + item.name, + item.details?.variations.length + ) + : undefined + } /> ); } - const itemArgs = isEmpty( item.variation ) + const itemArgs = isEmpty( item.details?.variation ) ? args : { ...args, item: { ...args.item, - name: item.variation, + name: item.details?.variation as string, }, - 'aria-label': `${ item.breadcrumbs[ 0 ] }: ${ item.variation }`, + 'aria-label': `${ item.breadcrumbs[ 0 ] }: ${ item.details?.variation }`, }; return ( @@ -142,7 +197,7 @@ const ProductControl = ( { } else if ( showVariations ) { return renderItemWithVariations; } - return null; + return undefined; }; if ( error ) { @@ -150,10 +205,12 @@ const ProductControl = ( { } const currentVariations = - variations && variations[ expandedProduct ] + variations && expandedProduct && variations[ expandedProduct ] ? variations[ expandedProduct ] : []; - const currentList = [ ...products, ...currentVariations ]; + const currentList = [ ...products, ...currentVariations ].map( + convertProductResponseItemToSearchItem + ); return ( - selected.includes( id ) + selected.includes( Number( id ) ) ) } onChange={ onChange } renderItem={ getRenderItemFunc() } @@ -174,45 +231,6 @@ const ProductControl = ( { ); }; -ProductControl.propTypes = { - /** - * Callback to update the selected products. - */ - onChange: PropTypes.func.isRequired, - isCompact: PropTypes.bool, - /** - * The ID of the currently expanded product. - */ - expandedProduct: PropTypes.number, - /** - * Callback to search products by their name. - */ - onSearch: PropTypes.func, - /** - * Query args to pass to getProducts. - */ - queryArgs: PropTypes.object, - /** - * Callback to render each item in the selection list, allows any custom object-type rendering. - */ - renderItem: PropTypes.func, - /** - * The ID of the currently selected item (product or variation). - */ - selected: PropTypes.arrayOf( PropTypes.number ), - /** - * Whether to show variations in the list of items available. - */ - showVariations: PropTypes.bool, -}; - -ProductControl.defaultProps = { - isCompact: false, - expandedProduct: null, - selected: [], - showVariations: false, -}; - export default withTransformSingleSelectToMultipleSelect( withSearchedProducts( withProductVariations( withInstanceId( ProductControl ) ) diff --git a/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/item.tsx b/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/item.tsx index d106adb7e69..fe67fd6fda4 100644 --- a/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/item.tsx +++ b/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/item.tsx @@ -40,7 +40,7 @@ const ItemLabel = ( props: { item: SearchListItemProps; search: string } ) => { ); }; -export const SearchListItem = ( { +export const SearchListItem = < T extends object = object >( { countLabel, className, depth = 0, @@ -53,7 +53,7 @@ export const SearchListItem = ( { selected, useExpandedPanelId, ...props -}: renderItemArgs ): JSX.Element => { +}: renderItemArgs< T > ): JSX.Element => { const [ expandedPanelId, setExpandedPanelId ] = useExpandedPanelId; const showCount = countLabel !== undefined && @@ -165,6 +165,7 @@ export const SearchListItem = ( { { isSingle ? ( <> ) : ( ) } diff --git a/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/search-list-control.tsx b/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/search-list-control.tsx index 27b057742c1..3cb0c64d867 100644 --- a/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/search-list-control.tsx +++ b/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/search-list-control.tsx @@ -101,14 +101,14 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => { ); }; -const SelectedListItems = ( { +const SelectedListItems = < T extends object = object >( { isLoading, isSingle, selected, messages, onChange, onRemove, -}: SearchListControlProps & { +}: SearchListControlProps< T > & { messages: SearchListMessages; onRemove: ( itemId: string | number ) => () => void; } ) => { @@ -148,14 +148,14 @@ const SelectedListItems = ( { ); }; -const ListItemsContainer = ( { +const ListItemsContainer = < T extends object = object >( { filteredList, search, onSelect, instanceId, useExpandedPanelId, ...props -}: SearchListItemsContainerProps ) => { +}: SearchListItemsContainerProps< T > ) => { const { messages, renderItem, selected, isSingle } = props; const renderItemCallback = renderItem || defaultRenderListItem; @@ -194,7 +194,9 @@ const ListItemsContainer = ( { /** * Component to display a searchable, selectable list of items. */ -export const SearchListControl = ( props: SearchListControlProps ) => { +export const SearchListControl = < T extends object = object >( + props: SearchListControlProps< T > +) => { const { className = '', isCompact, @@ -250,22 +252,25 @@ export const SearchListControl = ( props: SearchListControlProps ) => { ); const onSelect = useCallback( - ( item: SearchListItemProps | SearchListItemProps[] ) => () => { - if ( Array.isArray( item ) ) { - onChange( item ); - return; - } + ( item: SearchListItemProps< T > | SearchListItemProps< T >[] ) => + () => { + if ( Array.isArray( item ) ) { + onChange( item ); + return; + } - if ( selected.findIndex( ( { id } ) => id === item.id ) !== -1 ) { - onRemove( item.id )(); - return; - } - if ( isSingle ) { - onChange( [ item ] ); - } else { - onChange( [ ...selected, item ] ); - } - }, + if ( + selected.findIndex( ( { id } ) => id === item.id ) !== -1 + ) { + onRemove( item.id )(); + return; + } + if ( isSingle ) { + onChange( [ item ] ); + } else { + onChange( [ ...selected, item ] ); + } + }, [ isSingle, onRemove, onChange, selected ] ); diff --git a/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/types.ts b/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/types.ts index 043a1848c07..3362fc8c97f 100644 --- a/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/types.ts +++ b/plugins/woocommerce-blocks/assets/js/editor-components/search-list-control/types.ts @@ -1,14 +1,16 @@ /** * External dependencies */ -import type { ReactNode } from 'react'; +import type { InputHTMLAttributes, ReactNode } from 'react'; import { Require } from '@woocommerce/types'; -interface ItemProps { +interface ItemProps< T extends object = object > { // Depth, non-zero if the list is hierarchical. depth?: number; // Callback for selecting the item. - onSelect: ( item: SearchListItem | SearchListItem[] ) => () => void; + onSelect: ( + item: SearchListItem< T > | SearchListItem< T >[] + ) => () => void; // Search string, used to highlight the substring in the item name. search: string; useExpandedPanelId: [ @@ -17,15 +19,15 @@ interface ItemProps { ]; } -interface SearchListProps { +interface SearchListProps< T extends object = object > { //Restrict selections to one item. isSingle: boolean; // A complete list of item objects, each with id, name properties. This is displayed as a clickable/keyboard-able list, and possibly filtered by the search term (searches name). - list: SearchListItem[]; + list: SearchListItem< T >[]; // Callback to render each item in the selection list, allows any custom object-type rendering. - renderItem?: ( args: renderItemArgs ) => JSX.Element; + renderItem?: ( args: renderItemArgs< T > ) => JSX.Element; // The list of currently selected items. - selected: SearchListItem[]; + selected: SearchListItem< T >[]; } export interface ListItemsProps @@ -34,21 +36,22 @@ export interface ListItemsProps instanceId: string | number; } -export type SearchListItem = { +export type SearchListItem< T extends object = object > = { breadcrumbs: string[]; - children?: SearchListItem[]; - count: number; + children?: SearchListItem< T >[]; + count?: number; + details?: T; id: string | number; name: string; parent: number; value: string; }; -export interface SearchListItemsContainerProps +export interface SearchListItemsContainerProps< T extends object = object > extends SearchListControlProps, ItemProps { instanceId: string | number; - filteredList: SearchListItem[]; + filteredList: SearchListItem< T >[]; messages: SearchListMessages; } @@ -67,23 +70,30 @@ export interface SearchListMessages { updated: string; } -export interface renderItemArgs extends ItemProps { +export interface renderItemArgs< T extends object = object > + extends ItemProps, + Partial< + Omit< + InputHTMLAttributes< HTMLInputElement >, + 'onChange' | 'onSelect' + > + > { // Additional CSS classes. - className?: string | undefined; - // Whether the item is disable. - disabled?: boolean | undefined; + className?: string; + // Unique id of the parent control. + controlId: string | number; + // Label to display in the count bubble. Takes preference over `item.count`. + countLabel?: ReactNode; + // Whether the item is disabled. + disabled?: boolean; // Current item to display. - item: SearchListItem; + item: SearchListItem< T >; // Whether this item is selected. isSelected: boolean; // Whether this should only display a single item (controls radio vs checkbox icon). isSingle: boolean; // The list of currently selected items. - selected: SearchListItem[]; - // Unique id of the parent control. - controlId: string | number; - // Label to display in the count bubble. Takes preference over `item.count`. - countLabel?: ReactNode; + selected: SearchListItem< T >[]; /** * Name of the inputs. Used to group input controls together. See: * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-name @@ -94,7 +104,7 @@ export interface renderItemArgs extends ItemProps { ariaLabel?: string; } -export interface SearchListControlProps { +export interface SearchListControlProps< T extends object = object > { // Additional CSS classes. className?: string; // Whether it should be displayed in a compact way, so it occupies less space. @@ -106,17 +116,17 @@ export interface SearchListControlProps { //Restrict selections to one item. isSingle: boolean; // A complete list of item objects, each with id, name properties. This is displayed as a clickable/keyboard-able list, and possibly filtered by the search term (searches name). - list: SearchListItem[]; + list: SearchListItem< T >[]; // Messages displayed or read to the user. Configure these to reflect your object type. messages?: Partial< SearchListMessages >; // Callback fired when selected items change, whether added, cleared, or removed. Passed an array of item objects (as passed in via props.list). - onChange: ( search: SearchListItem[] ) => void; + onChange: ( search: SearchListItem< T >[] ) => void; // Callback fired when the search field is used. onSearch?: ( ( search: string ) => void ) | undefined; // Callback to render each item in the selection list, allows any custom object-type rendering. - renderItem?: ( args: renderItemArgs ) => JSX.Element; + renderItem?: ( ( args: renderItemArgs< T > ) => JSX.Element ) | undefined; // The list of currently selected items. - selected: SearchListItem[]; + selected: SearchListItem< T >[]; // Whether to show a text field or a token field as search // Defaults to `'text'` type?: 'text' | 'token'; diff --git a/plugins/woocommerce-blocks/assets/js/hocs/index.js b/plugins/woocommerce-blocks/assets/js/hocs/index.js index cdbd3fb8627..4ec928264f4 100644 --- a/plugins/woocommerce-blocks/assets/js/hocs/index.js +++ b/plugins/woocommerce-blocks/assets/js/hocs/index.js @@ -5,3 +5,7 @@ export { default as withProduct } from './with-product'; export { default as withProductVariations } from './with-product-variations'; export { default as withSearchedProducts } from './with-searched-products'; export { default as withTransformSingleSelectToMultipleSelect } from './with-transform-single-select-to-multiple-select'; +export { + SelectedOption, + WithMaybeSelectedOption, +} from './with-transform-single-select-to-multiple-select'; diff --git a/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.tsx b/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.tsx index ae620dab8d1..cb93a4997e9 100644 --- a/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.tsx +++ b/plugins/woocommerce-blocks/assets/js/hocs/with-searched-products.tsx @@ -5,20 +5,29 @@ import { useEffect, useState, useCallback, useRef } from '@wordpress/element'; import { blocksConfig } from '@woocommerce/block-settings'; import { getProducts } from '@woocommerce/editor-components/utils'; import { useDebouncedCallback } from 'use-debounce'; -import type { ProductResponseItem } from '@woocommerce/types'; +import type { + ProductResponseItem, + WithInjectedSearchedProducts, +} from '@woocommerce/types'; /** * Internal dependencies */ import { formatError } from '../base/utils/errors.js'; +interface WithSearchedProductProps { + selected: number[]; +} + /** * A higher order component that enhances the provided component with products from a search query. */ -const withSearchedProducts = ( - OriginalComponent: React.FunctionComponent< Record< string, unknown > > +const withSearchedProducts = < + T extends Record< string, unknown > & WithSearchedProductProps +>( + OriginalComponent: React.ComponentType< T & WithInjectedSearchedProducts > ) => { - return ( { selected, ...props }: { selected: number[] } ): JSX.Element => { + return ( { selected, ...props }: T ): JSX.Element => { const [ isLoading, setIsLoading ] = useState( true ); const [ error, setError ] = useState< { message: string; @@ -71,7 +80,7 @@ const withSearchedProducts = ( return ( >} OriginalComponent Component being wrapped. */ -const withTransformSingleSelectToMultipleSelect = ( - OriginalComponent: FunctionComponent< Record< string, unknown > > +const withTransformSingleSelectToMultipleSelect = < + T extends Record< string, unknown > +>( + OriginalComponent: FunctionComponent< T & WithMaybeSelectedOption > ) => { - return ( props: OriginalComponentProps ): JSX.Element => { + return ( props: T & WithMaybeSelectedOption ): JSX.Element => { let { selected } = props; selected = selected === undefined ? null : selected; const isNil = selected === null; diff --git a/plugins/woocommerce-blocks/assets/js/types/type-defs/hocs.ts b/plugins/woocommerce-blocks/assets/js/types/type-defs/hocs.ts new file mode 100644 index 00000000000..4fafb683028 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/types/type-defs/hocs.ts @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { ErrorObject } from '@woocommerce/editor-components/error-placeholder'; +import type { ProductResponseItem } from '@woocommerce/types'; + +export interface WithInjectedProductVariations { + error: ErrorObject | null; + /** + * The id of the currently expanded product + */ + expandedProduct: number | null; + variations: Record< number, ProductResponseItem[] >; + variationsLoading: boolean; +} + +export interface WithInjectedSearchedProducts { + error: ErrorObject | null; + isLoading: boolean; + onSearch: ( ( search: string ) => void ) | null; + products: ProductResponseItem[]; + selected: number[]; +} + +export interface WithInjectedInstanceId { + instanceId: string | number; +} diff --git a/plugins/woocommerce-blocks/assets/js/types/type-defs/index.ts b/plugins/woocommerce-blocks/assets/js/types/type-defs/index.ts index e6517f5c273..4369223fdfb 100644 --- a/plugins/woocommerce-blocks/assets/js/types/type-defs/index.ts +++ b/plugins/woocommerce-blocks/assets/js/types/type-defs/index.ts @@ -8,6 +8,7 @@ export * from './checkout'; export * from './contexts'; export * from './currency'; export * from './events'; +export * from './hocs'; export * from './hooks'; export * from './notices'; export * from './objects'; diff --git a/plugins/woocommerce-blocks/assets/js/types/type-defs/product-response.ts b/plugins/woocommerce-blocks/assets/js/types/type-defs/product-response.ts index 7746e2f6ca1..e3620f617ab 100644 --- a/plugins/woocommerce-blocks/assets/js/types/type-defs/product-response.ts +++ b/plugins/woocommerce-blocks/assets/js/types/type-defs/product-response.ts @@ -90,4 +90,5 @@ export interface ProductResponseItem { maximum: number; multiple_of: number; }; + slug: string; } diff --git a/plugins/woocommerce-blocks/assets/js/utils/products.js b/plugins/woocommerce-blocks/assets/js/utils/products.js deleted file mode 100644 index bf1f54f22d3..00000000000 --- a/plugins/woocommerce-blocks/assets/js/utils/products.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Get the src of the first image attached to a product (the featured image). - * - * @param {Object} product The product object to get the images from. - * @param {Array} product.images The array of images, destructured from the product object. - * @return {string} The full URL to the image. - */ -export function getImageSrcFromProduct( product ) { - if ( ! product || ! product.images || ! product.images.length ) { - return ''; - } - - return product.images[ 0 ].src || ''; -} - -/** - * Get the ID of the first image attached to a product (the featured image). - * - * @param {Object} product The product object to get the images from. - * @param {Array} product.images The array of images, destructured from the product object. - * @return {number} The ID of the image. - */ -export function getImageIdFromProduct( product ) { - if ( ! product || ! product.images || ! product.images.length ) { - return 0; - } - - return product.images[ 0 ].id || 0; -} diff --git a/plugins/woocommerce-blocks/assets/js/utils/products.ts b/plugins/woocommerce-blocks/assets/js/utils/products.ts new file mode 100644 index 00000000000..94b0cebe218 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/utils/products.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import type { SearchListItem } from '@woocommerce/editor-components/search-list-control/types'; +import type { ProductResponseItem } from '@woocommerce/types'; + +/** + * Converts a Product object into a shape compatible with the `SearchListControl` + */ +export const convertProductResponseItemToSearchItem = ( + product: ProductResponseItem +): SearchListItem< ProductResponseItem > => { + const { id, name, parent } = product; + + return { + id, + name, + parent, + breadcrumbs: [], + children: [], + details: product, + value: product.slug, + }; +}; + +/** + * Get the src of the first image attached to a product (the featured image). + */ +export function getImageSrcFromProduct( product: ProductResponseItem ) { + if ( ! product || ! product.images || ! product.images.length ) { + return ''; + } + + return product.images[ 0 ].src || ''; +} + +/** + * Get the ID of the first image attached to a product (the featured image). + */ +export function getImageIdFromProduct( product: ProductResponseItem ) { + if ( ! product || ! product.images || ! product.images.length ) { + return 0; + } + + return product.images[ 0 ].id || 0; +}