rebase from live enviornment

This commit is contained in:
Rachit Bhargava
2024-01-09 22:14:20 -05:00
parent ff0b49a046
commit 3a22fcaa4a
15968 changed files with 2344674 additions and 45234 deletions

View File

@@ -0,0 +1,6 @@
// Ensure textarea bg color is transparent for block titles.
// Some themes (e.g. Twenty Twenty) set a non-white background for the editor, and Gutenberg sets white background for text inputs, creating this issue.
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1204
.wc-block-editor-components-title {
background-color: transparent;
}

View File

@@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { PlainText } from '@wordpress/block-editor';
import { withInstanceId } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './editor.scss';
interface BlockTitleProps {
className: string;
headingLevel: number;
onChange: ( value: string ) => void;
heading: string;
instanceId: number;
}
const BlockTitle = ( {
className,
headingLevel,
onChange,
heading,
instanceId,
}: BlockTitleProps ) => {
const TagName = `h${ headingLevel }` as keyof JSX.IntrinsicElements;
return (
<TagName className={ className }>
<label
className="screen-reader-text"
htmlFor={ `block-title-${ instanceId }` }
>
{ __( 'Block title', 'woo-gutenberg-products-block' ) }
</label>
<PlainText
id={ `block-title-${ instanceId }` }
className="wc-block-editor-components-title"
value={ heading }
onChange={ onChange }
style={ { backgroundColor: 'transparent' } }
/>
</TagName>
);
};
export default withInstanceId( BlockTitle );

View File

@@ -0,0 +1,158 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import {
store as blockEditorStore,
getColorClassName,
InspectorControls,
useBlockEditContext,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalColorGradientSettingsDropdown as ColorGradientSettingsDropdown,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import {
ColorSetting,
ColorAttributes,
ColorPaletteOption,
GradientPaletteOption,
ColorGradientOptionsItems,
CustomColorsMap,
} from './types';
const flattenColors = (
colorGradientOptions: ColorGradientOptionsItems
): ColorPaletteOption[] & GradientPaletteOption[] => {
const flattenedColors: ColorPaletteOption[] & GradientPaletteOption[] = [];
if ( colorGradientOptions.colors ) {
colorGradientOptions.colors.forEach( ( colorItem ) => {
flattenedColors.push( ...colorItem.colors );
} );
}
if ( colorGradientOptions.gradients ) {
colorGradientOptions.gradients.forEach( ( gradientItem ) => {
flattenedColors.push( ...gradientItem.gradients );
} );
}
return flattenedColors;
};
const getColorObject = (
colors: ColorPaletteOption[] & GradientPaletteOption[],
colorValue: string | undefined,
context: string
) => {
if ( ! colorValue ) {
return;
}
const colorObject =
( colors?.find( ( color ) => {
return color.color === colorValue || color.slug === colorValue;
} ) as {
color: string;
slug?: string | undefined;
class?: string | undefined;
} ) || {};
if ( ! colorObject?.color ) {
colorObject.color = colorValue;
}
colorObject.class = getColorClassName( context, colorObject?.slug );
return colorObject;
};
const createSetColor = (
colorName: string,
context: string,
colors: ColorPaletteOption[] & GradientPaletteOption[],
setAttributes: ( attributes: Record< string, unknown > ) => void
): ( ( colorValue?: string ) => void ) => {
return ( colorValue?: string ) => {
const colorObject = getColorObject( colors, colorValue, context ) || {};
setAttributes( {
[ colorName ]: colorObject,
} );
};
};
const createSettings = (
colorTypes: CustomColorsMap,
colors: ColorPaletteOption[] & GradientPaletteOption[],
attributes: ColorAttributes | undefined,
setAttributes: ( attributes: Record< string, unknown > ) => void
) => {
return Object.entries( colorTypes ).reduce(
( settingsAccumulator, [ colorAttributeName, colorAttribute ] ) => {
const colorSetter = createSetColor(
colorAttributeName,
colorAttribute.context,
colors,
setAttributes
);
const colorSetting = {
colorValue:
attributes?.[ colorAttributeName ]?.color ?? undefined,
label: colorAttribute.label,
onColorChange: colorSetter,
resetAllFilter: () => colorSetter(),
};
settingsAccumulator.push( colorSetting );
return settingsAccumulator;
},
[] as ColorSetting[]
);
};
export const ColorPanel = ( {
colorTypes,
}: {
colorTypes: CustomColorsMap;
} ) => {
const colorGradientOptions = useMultipleOriginColorsAndGradients();
const flattenedColors = flattenColors( colorGradientOptions );
const { clientId } = useBlockEditContext();
const attributes = useSelect(
( select ) => {
// @ts-ignore @wordpress/block-editor/store types not provided
const { getBlockAttributes } = select( blockEditorStore );
return getBlockAttributes( clientId ) || {};
},
[ clientId ]
);
// @ts-ignore @wordpress/block-editor/store types not provided
const { updateBlockAttributes } = useDispatch( blockEditorStore );
const settings = useMemo( () => {
return createSettings(
colorTypes,
flattenedColors,
attributes,
( newAttributes ) =>
updateBlockAttributes( clientId, newAttributes )
);
}, [
colorTypes,
flattenedColors,
updateBlockAttributes,
attributes,
clientId,
] );
return (
colorGradientOptions.hasColorsOrGradients && (
// @ts-ignore The dev package version doesn't have types for group.
<InspectorControls group="color">
<ColorGradientSettingsDropdown
__experimentalIsRenderedInSidebar
settings={ settings }
panelId={ clientId }
{ ...colorGradientOptions }
/>
</InspectorControls>
)
);
};

View File

@@ -0,0 +1,46 @@
export interface ColorSetting {
colorValue: string | undefined;
onColorChange: ( value: string ) => void;
label: string;
resetAllFilter: () => void;
}
export interface ColorAttributes {
[ key: string ]: {
[ key: string ]: string;
};
}
export interface CustomColorsMap {
[ key: string ]: {
label: string;
context: string;
};
}
export interface ColorPaletteOption {
name: string;
slug: string | undefined;
color: string;
}
export interface GradientPaletteOption {
name: string;
gradient: string;
slug: string;
}
interface ColorGradientOptionsColorItem {
name: string;
colors: ColorPaletteOption[];
}
interface ColorGradientOptionsGradientItem {
name: string;
gradients: GradientPaletteOption[];
}
export interface ColorGradientOptionsItems {
colors: [ ColorGradientOptionsColorItem ];
gradients: [ ColorGradientOptionsGradientItem ];
}

View File

@@ -0,0 +1,18 @@
.wc-default-page-notice.is-dismissible {
margin: 0;
padding-right: 16px;
.components-notice__dismiss {
min-width: 24px;
}
.components-notice__content {
margin: 4px 0;
}
svg {
width: 16px;
height: 16px;
}
}
.wc-blocks-legacy-page-notice {
margin: 0;
}

View File

@@ -0,0 +1,158 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { store as editorStore } from '@wordpress/editor';
import triggerFetch from '@wordpress/api-fetch';
import { store as coreStore } from '@wordpress/core-data';
import { Notice, Button } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { CHECKOUT_PAGE_ID, CART_PAGE_ID } from '@woocommerce/block-settings';
import { useCallback, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import './editor.scss';
export function DefaultNotice( { block }: { block: string } ) {
// To avoid having the same logic twice, we're going to handle both pages here.
const ORIGINAL_PAGE_ID =
block === 'checkout' ? CHECKOUT_PAGE_ID : CART_PAGE_ID;
const settingName =
block === 'checkout'
? 'woocommerce_checkout_page_id'
: 'woocommerce_cart_page_id';
const noticeContent =
block === 'checkout'
? __(
'If you would like to use this block as your default checkout, update your page settings',
'woo-gutenberg-products-block'
)
: __(
'If you would like to use this block as your default cart, update your page settings',
'woo-gutenberg-products-block'
);
// Everything below works the same for Cart/Checkout
const { saveEntityRecord } = useDispatch( coreStore );
const { editPost, savePost } = useDispatch( editorStore );
const { slug, isLoadingPage, postPublished, currentPostId } = useSelect(
( select ) => {
const { getEntityRecord, isResolving } = select( coreStore );
const { isCurrentPostPublished, getCurrentPostId } =
select( editorStore );
return {
slug:
getEntityRecord( 'postType', 'page', ORIGINAL_PAGE_ID )
?.slug || block,
isLoadingPage: isResolving( 'getEntityRecord', [
'postType',
'page',
ORIGINAL_PAGE_ID,
] ),
postPublished: isCurrentPostPublished(),
currentPostId: getCurrentPostId(),
};
},
[]
);
const [ settingStatus, setStatus ] = useState( 'pristine' );
const updatePage = useCallback( () => {
setStatus( 'updating' );
Promise.resolve()
.then( () =>
triggerFetch( {
path: `/wc/v3/settings/advanced/${ settingName }`,
method: 'GET',
} )
)
.catch( ( error ) => {
if ( error.code === 'rest_setting_setting_invalid' ) {
setStatus( 'error' );
}
} )
.then( () => {
if ( ! postPublished ) {
editPost( { status: 'publish' } );
return savePost();
}
} )
.then( () =>
// Make this page ID the default cart/checkout.
triggerFetch( {
path: `/wc/v3/settings/advanced/${ settingName }`,
method: 'POST',
data: {
value: currentPostId.toString(),
},
} )
)
// Append `-2` to the original link so we can use it here.
.then( () => {
if ( ORIGINAL_PAGE_ID !== 0 ) {
return saveEntityRecord( 'postType', 'page', {
id: ORIGINAL_PAGE_ID,
slug: `${ slug }-2`,
} );
}
} )
// Use the original link for this page.
.then( () => editPost( { slug } ) )
// Save page.
.then( () => savePost() )
.then( () => setStatus( 'updated' ) );
}, [
postPublished,
editPost,
savePost,
settingName,
currentPostId,
ORIGINAL_PAGE_ID,
saveEntityRecord,
slug,
] );
// Avoid showing the notice on the site editor, if already set, or if dismissed earlier.
if (
( typeof pagenow === 'string' && pagenow === 'site-editor' ) ||
currentPostId === ORIGINAL_PAGE_ID ||
settingStatus === 'dismissed'
) {
return null;
}
return (
<Notice
className="wc-default-page-notice"
status={ settingStatus === 'updated' ? 'success' : 'warning' }
onRemove={ () => setStatus( 'dismissed' ) }
spokenMessage={
settingStatus === 'updated'
? __(
'Page settings updated',
'woo-gutenberg-products-block'
)
: noticeContent
}
>
{ settingStatus === 'updated' ? (
__( 'Page settings updated', 'woo-gutenberg-products-block' )
) : (
<>
<p>{ noticeContent }</p>
<Button
onClick={ updatePage }
variant="secondary"
isBusy={ settingStatus === 'updating' }
disabled={ isLoadingPage }
isSmall={ true }
>
{ __(
'Update your page settings',
'woo-gutenberg-products-block'
) }
</Button>
</>
) }
</Notice>
);
}

View File

@@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, external } from '@wordpress/icons';
import { ADMIN_URL } from '@woocommerce/settings';
import { InspectorControls } from '@wordpress/block-editor';
import { useProductDataContext } from '@woocommerce/shared-context';
interface EditProductLinkProps {
id?: number | undefined;
productId?: number | undefined;
}
/**
* Component to render an edit product link in the sidebar.
*
* @param {Object} props Component props.
*/
const EditProductLink = ( props: EditProductLinkProps ): JSX.Element | null => {
const productDataContext = useProductDataContext();
const product = productDataContext.product || {};
const productId = product.id || props.productId || 0;
if ( ! productId || productId === 1 ) {
return null;
}
return (
<InspectorControls>
<div className="wc-block-single-product__edit-card">
<div className="wc-block-single-product__edit-card-title">
<a
href={ `${ ADMIN_URL }post.php?post=${ productId }&action=edit` }
target="_blank"
rel="noopener noreferrer"
>
{ __(
"Edit this product's details",
'woo-gutenberg-products-block'
) }
<Icon icon={ external } size={ 16 } />
</a>
</div>
<div className="wc-block-single-product__edit-card-description">
{ __(
'Edit details such as title, price, description and more.',
'woo-gutenberg-products-block'
) }
</div>
</div>
</InspectorControls>
);
};
export default EditProductLink;

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import Button, { ButtonProps } from '@woocommerce/base-components/button';
import { RichText } from '@wordpress/block-editor';
export interface EditableButtonProps
extends Omit< ButtonProps, 'onChange' | 'placeholder' | 'value' > {
/**
* On change callback.
*/
onChange: ( value: string ) => void;
/**
* The placeholder of the editable button.
*/
placeholder?: string;
/**
* The current value of the editable button.
*/
value: string;
}
const EditableButton = ( {
onChange,
placeholder,
value,
...props
}: EditableButtonProps ) => {
return (
<Button { ...props }>
<RichText
multiline={ false }
allowedFormats={ [] }
value={ value }
placeholder={ placeholder }
onChange={ onChange }
/>
</Button>
);
};
export default EditableButton;

View File

@@ -0,0 +1,18 @@
.wc-block-error-message {
margin-bottom: 16px;
margin-top: 8px;
}
.wc-block-api-error {
.components-placeholder__fieldset {
display: block;
}
.wc-block-error-message {
margin-top: 0;
}
.components-spinner {
float: none;
}
}

View File

@@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { escapeHTML } from '@wordpress/escape-html';
/**
* Internal dependencies
*/
import { ErrorObject } from '.';
export interface ErrorMessageProps {
/**
* The error object.
*/
error: ErrorObject;
}
const getErrorMessage = ( { message, type }: ErrorObject ) => {
if ( ! message ) {
return __(
'An error has prevented the block from being updated.',
'woo-gutenberg-products-block'
);
}
if ( type === 'general' ) {
return (
<span>
{ __(
'The following error was returned',
'woo-gutenberg-products-block'
) }
<br />
<code>{ escapeHTML( message ) }</code>
</span>
);
}
if ( type === 'api' ) {
return (
<span>
{ __(
'The following error was returned from the API',
'woo-gutenberg-products-block'
) }
<br />
<code>{ escapeHTML( message ) }</code>
</span>
);
}
return message;
};
const ErrorMessage = ( { error }: ErrorMessageProps ): JSX.Element => (
<div className="wc-block-error-message">{ getErrorMessage( error ) }</div>
);
export default ErrorMessage;

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, warning } from '@wordpress/icons';
import classNames from 'classnames';
import { Button, Placeholder, Spinner } from '@wordpress/components';
/**
* Internal dependencies
*/
import ErrorMessage from './error-message';
import './editor.scss';
export interface ErrorObject {
/**
* Human-readable error message to display.
*/
message: string;
/**
* Context in which the error was triggered. That will determine how the error is displayed to the user.
*/
type: 'api' | 'general' | string;
}
export interface ErrorPlaceholderProps {
/**
* Classname to add to placeholder in addition to the defaults.
*/
className?: string;
/**
* The error object.
*/
error: ErrorObject;
/**
* Whether there is a request running, so the 'Retry' button is hidden and
* a spinner is shown instead.
*/
isLoading: boolean;
/**
* Callback to retry an action.
*/
onRetry?: ( () => void ) | undefined;
}
const ErrorPlaceholder = ( {
className = '',
error,
isLoading = false,
onRetry,
}: ErrorPlaceholderProps ): JSX.Element => (
<Placeholder
icon={ <Icon icon={ warning } /> }
label={ __(
'Sorry, an error occurred',
'woo-gutenberg-products-block'
) }
className={ classNames( 'wc-block-api-error', className ) }
>
<ErrorMessage error={ error } />
{ onRetry && (
<>
{ isLoading ? (
<Spinner />
) : (
<Button isSecondary onClick={ onRetry }>
{ __( 'Retry', 'woo-gutenberg-products-block' ) }
</Button>
) }
</>
) }
</Placeholder>
);
export default ErrorPlaceholder;

View File

@@ -0,0 +1,27 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import ErrorMessage, { ErrorMessageProps } from '../error-message';
export default {
title: 'Editor Components/Errors/Base Error Atom',
component: ErrorMessage,
} as Meta< ErrorMessageProps >;
const Template: Story< ErrorMessageProps > = ( args ) => (
<ErrorMessage { ...args } />
);
export const BaseErrorAtom = Template.bind( {} );
BaseErrorAtom.args = {
error: {
message:
'A very generic and unhelpful error. Please try again later. Or contact support. Or not.',
type: 'general',
},
};

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
import { useArgs } from '@storybook/client-api';
import { INTERACTION_TIMEOUT } from '@woocommerce/storybook-controls';
/**
* Internal dependencies
*/
import ErrorPlaceholder, { ErrorPlaceholderProps } from '..';
export default {
title: 'Editor Components/Errors/Error Placeholder',
component: ErrorPlaceholder,
} as Meta< ErrorPlaceholderProps >;
const Template: StoryFn< ErrorPlaceholderProps > = ( args ) => {
const [ { isLoading }, setArgs ] = useArgs();
const onRetry = args.onRetry
? () => {
setArgs( { isLoading: true } );
setTimeout(
() => setArgs( { isLoading: false } ),
INTERACTION_TIMEOUT
);
}
: undefined;
return (
<ErrorPlaceholder
{ ...args }
onRetry={ onRetry }
isLoading={ isLoading }
/>
);
};
export const Default = Template.bind( {} );
Default.args = {
error: {
message:
'A very generic and unhelpful error. Please try again later. Or contact support. Or not.',
type: 'general',
},
};
export const APIError = Template.bind( {} );
APIError.args = {
error: {
message: 'Server refuses to comply. It is a teapot.',
type: 'api',
},
};
export const UnknownError = Template.bind( {} );
UnknownError.args = {
error: {
message: '',
type: 'general',
},
};
export const NoRetry: StoryFn< ErrorPlaceholderProps > = ( args ) => {
return <ErrorPlaceholder { ...args } onRetry={ undefined } />;
};
NoRetry.args = {
error: {
message: '',
type: 'general',
},
};

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { Spinner } from '@wordpress/components';
import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
import { renderItemArgs } from '@woocommerce/editor-components/search-list-control/types';
import classNames from 'classnames';
interface ExpandableSearchListItemProps extends renderItemArgs {
isLoading: boolean;
}
const ExpandableSearchListItem = ( {
className,
item,
isSelected,
isLoading,
onSelect,
disabled,
...rest
}: ExpandableSearchListItemProps ): JSX.Element => {
return (
<>
<SearchListItem
{ ...rest }
key={ item.id }
className={ className }
isSelected={ isSelected }
item={ item }
onSelect={ onSelect }
disabled={ disabled }
/>
{ isSelected && isLoading && (
<div
key="loading"
className={ classNames(
'woocommerce-search-list__item',
'woocommerce-product-attributes__item',
'depth-1',
'is-loading',
'is-not-active'
) }
>
<Spinner />
</div>
) }
</>
);
};
export default ExpandableSearchListItem;

View File

@@ -0,0 +1,50 @@
.wc-block-editor-components-external-link-card {
display: flex;
flex-direction: row;
text-decoration: none;
margin: $gap-large 0;
color: inherit;
align-items: flex-start;
+ .wc-block-editor-components-external-link-card {
margin-top: -($gap-large - $gap);
}
&:last-child {
margin-bottom: 0;
}
.wc-block-editor-components-external-link-card__content {
flex: 1 1 0;
padding-right: $gap;
}
.wc-block-editor-components-external-link-card__title {
font-weight: 500;
display: block;
}
.wc-block-editor-components-external-link-card__description {
color: $gray-700;
display: block;
@include font-size(small);
margin-top: 0.5em;
}
.wc-block-editor-components-external-link-card__icon {
flex: 0 0 24px;
margin: 0;
text-align: right;
color: inherit;
vertical-align: top;
}
.wc-block-editor-components-external-link-card__warning {
color: #cc1818;
display: flex;
align-items: flex-start;
font-size: 0.875em;
column-gap: 4px;
margin-top: 0.5em;
svg {
width: 18px;
height: 18px;
min-width: 18px;
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, external } from '@wordpress/icons';
import { VisuallyHidden } from '@wordpress/components';
import { sanitizeHTML } from '@woocommerce/utils';
import { Alert } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import './editor.scss';
export interface ExternalLinkCardProps {
href: string;
title: string;
description?: string;
warning?: string;
}
/**
* Show a link that displays a title, description, and an icon showing that the link is external.
* Links are opened in a new tab.
*/
const ExternalLinkCard = ( {
href,
title,
description,
warning,
}: ExternalLinkCardProps ): JSX.Element => {
return (
<a
href={ href }
className="wc-block-editor-components-external-link-card"
target="_blank"
rel="noreferrer"
>
<span className="wc-block-editor-components-external-link-card__content">
<strong className="wc-block-editor-components-external-link-card__title">
{ title }
</strong>
{ description && (
<span
className="wc-block-editor-components-external-link-card__description"
dangerouslySetInnerHTML={ {
__html: sanitizeHTML( description ),
} }
></span>
) }
{ warning ? (
<span className="wc-block-editor-components-external-link-card__warning">
<Icon icon={ <Alert status="error" /> } />
<span>{ warning }</span>
</span>
) : null }
</span>
<VisuallyHidden as="span">
{
/* translators: accessibility text */
__( '(opens in a new tab)', 'woo-gutenberg-products-block' )
}
</VisuallyHidden>
<Icon
icon={ external }
className="wc-block-editor-components-external-link-card__icon"
/>
</a>
);
};
export default ExternalLinkCard;

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import ExternalLinkCard, { ExternalLinkCardProps } from '..';
export default {
title: 'Editor Components/ExternalLinkCard',
component: ExternalLinkCard,
} as Meta< ExternalLinkCardProps >;
const Template: Story< ExternalLinkCardProps > = ( args ) => (
<ExternalLinkCard { ...args } />
);
export const Default = Template.bind( {} );
Default.args = {
description:
'This is the description of the link, perhaps a bit of a longer paragraph or a summary of a blog post, or whatever could give more context',
href: 'https://woocommerce.com/posts/seven-tips-to-extend-holiday-sales-momentum/',
title: 'Seven tips to extend holiday sales momentum',
};

View File

@@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, commentContent, external } from '@wordpress/icons';
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import './style.scss';
interface FeedbackPromptProps {
text: string;
title?: string;
url: string;
}
/**
* Component to render a Feedback prompt in the sidebar.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.text
* @param {string} props.title
* @param {string} props.url
*/
const FeedbackPrompt = ( {
text,
title = __( 'Feedback?', 'woo-gutenberg-products-block' ),
url,
}: FeedbackPromptProps ) => {
// By returning false we ensure that this component is not entered into the InspectorControls
// (which is a slot fill), children array on first render, on the second render when the state
// gets updated this component does get put into the InspectorControls children array but as the
// last item, ensuring it shows last in the sidebar.
const [ isVisible, setIsVisible ] = useState( false );
useEffect( () => {
setIsVisible( true );
}, [] );
return (
<>
{ isVisible && (
<div className="wc-block-feedback-prompt">
<Icon icon={ commentContent } />
<h2 className="wc-block-feedback-prompt__title">
{ title }
</h2>
<p className="wc-block-feedback-prompt__text">{ text }</p>
<a
href={ url }
className="wc-block-feedback-prompt__link"
rel="noreferrer noopener"
target="_blank"
>
{ __(
'Give us your feedback.',
'woo-gutenberg-products-block'
) }
<Icon icon={ external } size={ 16 } />
</a>
</div>
) }
</>
);
};
export default FeedbackPrompt;
export const CartCheckoutFeedbackPrompt = () => (
<FeedbackPrompt
text={ __(
'We are currently working on improving our cart and checkout blocks to provide merchants with the tools and customization options they need.',
'woo-gutenberg-products-block'
) }
url="https://github.com/woocommerce/woocommerce/discussions/new?category=checkout-flow&labels=type%3A+product%20feedback"
/>
);
export const ProductQueryFeedbackPrompt = () => (
<FeedbackPrompt
text={ __(
'Thanks for trying out the Products block! Help us make it better by sharing your feedback.',
'woo-gutenberg-products-block'
) }
title={ __( 'Share your feedback!', 'woo-gutenberg-products-block' ) }
url={ 'https://airtable.com/shrFX5FAqmCY6hVYI' }
/>
);
export const ProductCollectionFeedbackPrompt = () => (
<FeedbackPrompt
text={ __(
'Thanks for trying out the Product Collection block! Help us make it better by sharing your feedback.',
'woo-gutenberg-products-block'
) }
title={ __( 'Share your feedback!', 'woo-gutenberg-products-block' ) }
url={ 'https://airtable.com/shrqsMSDPvAKoY99u' }
/>
);

View File

@@ -0,0 +1,19 @@
.wc-block-feedback-prompt {
background-color: #f7f7f7;
border-top: 1px solid $gray-200;
margin: 0;
padding: $gap-large;
text-align: center;
.wc-block-feedback-prompt__title {
margin: 0 0 $gap-small;
}
.wc-block-feedback-prompt__link {
color: inherit;
> .gridicon {
vertical-align: text-bottom;
}
}
}

View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ToggleControl } from '@wordpress/components';
interface GridContentControlProps {
onChange: ( settings: GridContentSettings ) => void;
settings: GridContentSettings;
}
interface GridContentSettings {
image: boolean;
button: boolean;
price: boolean;
rating: boolean;
title: boolean;
}
/**
* A combination of toggle controls for content visibility in product grids.
*
* @param {Object} props Incoming props for the component.
* @param {function(any):any} props.onChange
* @param {Object} props.settings
*/
const GridContentControl = ( {
onChange,
settings,
}: GridContentControlProps ) => {
const { image, button, price, rating, title } = settings;
// If `image` is undefined, that might be because it's a block that was
// created before the `image` attribute existed, so we default to true.
const imageIsVisible = image !== false;
return (
<>
<ToggleControl
label={ __( 'Product image', 'woo-gutenberg-products-block' ) }
checked={ imageIsVisible }
onChange={ () =>
onChange( { ...settings, image: ! imageIsVisible } )
}
/>
<ToggleControl
label={ __( 'Product title', 'woo-gutenberg-products-block' ) }
checked={ title }
onChange={ () => onChange( { ...settings, title: ! title } ) }
/>
<ToggleControl
label={ __( 'Product price', 'woo-gutenberg-products-block' ) }
checked={ price }
onChange={ () => onChange( { ...settings, price: ! price } ) }
/>
<ToggleControl
label={ __( 'Product rating', 'woo-gutenberg-products-block' ) }
checked={ rating }
onChange={ () => onChange( { ...settings, rating: ! rating } ) }
/>
<ToggleControl
label={ __(
'Add to Cart button',
'woo-gutenberg-products-block'
) }
checked={ button }
onChange={ () => onChange( { ...settings, button: ! button } ) }
/>
</>
);
};
export default GridContentControl;

View File

@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { RangeControl, ToggleControl } from '@wordpress/components';
interface ClampProps {
( number: number, boundOne: number, boundTwo?: number ): number;
}
const clamp: ClampProps = ( number, boundOne, boundTwo ) => {
if ( ! boundTwo ) {
return Math.max( number, boundOne ) === boundOne ? number : boundOne;
} else if ( Math.min( number, boundOne ) === number ) {
return boundOne;
} else if ( Math.max( number, boundTwo ) === number ) {
return boundTwo;
}
return number;
};
interface GridLayoutControlProps {
columns: number;
rows: number;
setAttributes: ( attributes: Record< string, unknown > ) => void;
alignButtons: boolean;
minColumns?: number;
maxColumns?: number;
minRows?: number;
maxRows?: number;
}
/**
* A combination of range controls for product grid layout settings.
*
* @param {Object} props Incoming props for the component.
* @param {number} props.columns
* @param {number} props.rows
* @param {function(any):any} props.setAttributes Setter for block attributes.
* @param {boolean} props.alignButtons
* @param {number} props.minColumns
* @param {number} props.maxColumns
* @param {number} props.minRows
* @param {number} props.maxRows
*/
const GridLayoutControl = ( {
columns,
rows,
setAttributes,
alignButtons,
minColumns = 1,
maxColumns = 6,
minRows = 1,
maxRows = 6,
}: GridLayoutControlProps ) => {
return (
<>
<RangeControl
label={ __( 'Columns', 'woo-gutenberg-products-block' ) }
value={ columns }
onChange={ ( value: number ) => {
const newValue = clamp( value, minColumns, maxColumns );
setAttributes( {
columns: Number.isNaN( newValue ) ? '' : newValue,
} );
} }
min={ minColumns }
max={ maxColumns }
/>
<RangeControl
label={ __( 'Rows', 'woo-gutenberg-products-block' ) }
value={ rows }
onChange={ ( value: number ) => {
const newValue = clamp( value, minRows, maxRows );
setAttributes( {
rows: Number.isNaN( newValue ) ? '' : newValue,
} );
} }
min={ minRows }
max={ maxRows }
/>
<ToggleControl
label={ __(
'Align the last block to the bottom',
'woo-gutenberg-products-block'
) }
help={
alignButtons
? __(
'Align the last block to the bottom.',
'woo-gutenberg-products-block'
)
: __(
'The last inner block will follow other content.',
'woo-gutenberg-products-block'
)
}
checked={ alignButtons }
onChange={ () =>
setAttributes( { alignButtons: ! alignButtons } )
}
/>
</>
);
};
export default GridLayoutControl;

View File

@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { Path, SVG } from '@wordpress/primitives';
export default function HeadingLevelIcon( { level } ) {
const levelToPath = {
1: 'M9 5h2v10H9v-4H5v4H3V5h2v4h4V5zm6.6 0c-.6.9-1.5 1.7-2.6 2v1h2v7h2V5h-1.4z',
2: 'M7 5h2v10H7v-4H3v4H1V5h2v4h4V5zm8 8c.5-.4.6-.6 1.1-1.1.4-.4.8-.8 1.2-1.3.3-.4.6-.8.9-1.3.2-.4.3-.8.3-1.3 0-.4-.1-.9-.3-1.3-.2-.4-.4-.7-.8-1-.3-.3-.7-.5-1.2-.6-.5-.2-1-.2-1.5-.2-.4 0-.7 0-1.1.1-.3.1-.7.2-1 .3-.3.1-.6.3-.9.5-.3.2-.6.4-.8.7l1.2 1.2c.3-.3.6-.5 1-.7.4-.2.7-.3 1.2-.3s.9.1 1.3.4c.3.3.5.7.5 1.1 0 .4-.1.8-.4 1.1-.3.5-.6.9-1 1.2-.4.4-1 .9-1.6 1.4-.6.5-1.4 1.1-2.2 1.6V15h8v-2H15z',
3: 'M12.1 12.2c.4.3.8.5 1.2.7.4.2.9.3 1.4.3.5 0 1-.1 1.4-.3.3-.1.5-.5.5-.8 0-.2 0-.4-.1-.6-.1-.2-.3-.3-.5-.4-.3-.1-.7-.2-1-.3-.5-.1-1-.1-1.5-.1V9.1c.7.1 1.5-.1 2.2-.4.4-.2.6-.5.6-.9 0-.3-.1-.6-.4-.8-.3-.2-.7-.3-1.1-.3-.4 0-.8.1-1.1.3-.4.2-.7.4-1.1.6l-1.2-1.4c.5-.4 1.1-.7 1.6-.9.5-.2 1.2-.3 1.8-.3.5 0 1 .1 1.6.2.4.1.8.3 1.2.5.3.2.6.5.8.8.2.3.3.7.3 1.1 0 .5-.2.9-.5 1.3-.4.4-.9.7-1.5.9v.1c.6.1 1.2.4 1.6.8.4.4.7.9.7 1.5 0 .4-.1.8-.3 1.2-.2.4-.5.7-.9.9-.4.3-.9.4-1.3.5-.5.1-1 .2-1.6.2-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1l1.1-1.4zM7 9H3V5H1v10h2v-4h4v4h2V5H7v4z',
4: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm10-2h-1v2h-2v-2h-5v-2l4-6h3v6h1v2zm-3-2V7l-2.8 4H16z',
5: 'M12.1 12.2c.4.3.7.5 1.1.7.4.2.9.3 1.3.3.5 0 1-.1 1.4-.4.4-.3.6-.7.6-1.1 0-.4-.2-.9-.6-1.1-.4-.3-.9-.4-1.4-.4H14c-.1 0-.3 0-.4.1l-.4.1-.5.2-1-.6.3-5h6.4v1.9h-4.3L14 8.8c.2-.1.5-.1.7-.2.2 0 .5-.1.7-.1.5 0 .9.1 1.4.2.4.1.8.3 1.1.6.3.2.6.6.8.9.2.4.3.9.3 1.4 0 .5-.1 1-.3 1.4-.2.4-.5.8-.9 1.1-.4.3-.8.5-1.3.7-.5.2-1 .3-1.5.3-.8 0-1.6-.1-2.3-.4-.6-.2-1.1-.6-1.6-1-.1-.1 1-1.5 1-1.5zM9 15H7v-4H3v4H1V5h2v4h4V5h2v10z',
6: 'M9 15H7v-4H3v4H1V5h2v4h4V5h2v10zm8.6-7.5c-.2-.2-.5-.4-.8-.5-.6-.2-1.3-.2-1.9 0-.3.1-.6.3-.8.5l-.6.9c-.2.5-.2.9-.2 1.4.4-.3.8-.6 1.2-.8.4-.2.8-.3 1.3-.3.4 0 .8 0 1.2.2.4.1.7.3 1 .6.3.3.5.6.7.9.2.4.3.8.3 1.3s-.1.9-.3 1.4c-.2.4-.5.7-.8 1-.4.3-.8.5-1.2.6-1 .3-2 .3-3 0-.5-.2-1-.5-1.4-.9-.4-.4-.8-.9-1-1.5-.2-.6-.3-1.3-.3-2.1s.1-1.6.4-2.3c.2-.6.6-1.2 1-1.6.4-.4.9-.7 1.4-.9.6-.3 1.1-.4 1.7-.4.7 0 1.4.1 2 .3.5.2 1 .5 1.4.8 0 .1-1.3 1.4-1.3 1.4zm-2.4 5.8c.2 0 .4 0 .6-.1.2 0 .4-.1.5-.2.1-.1.3-.3.4-.5.1-.2.1-.5.1-.7 0-.4-.1-.8-.4-1.1-.3-.2-.7-.3-1.1-.3-.3 0-.7.1-1 .2-.4.2-.7.4-1 .7 0 .3.1.7.3 1 .1.2.3.4.4.6.2.1.3.3.5.3.2.1.5.2.7.1z',
};
if ( ! levelToPath.hasOwnProperty( level ) ) {
return null;
}
return (
<SVG
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<Path d={ levelToPath[ level ] } />
</SVG>
);
}

View File

@@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { ToolbarGroup } from '@wordpress/components';
/**
* Internal dependencies
*/
import HeadingLevelIcon from './heading-level-icon';
/**
* HeadingToolbar component.
*
* Allows the heading level to be chosen for a title block.
*/
class HeadingToolbar extends Component {
createLevelControl( targetLevel, selectedLevel, onChange ) {
const isActive = targetLevel === selectedLevel;
return {
icon: <HeadingLevelIcon level={ targetLevel } />,
title: sprintf(
/* translators: %s: heading level e.g: "2", "3", "4" */
__( 'Heading %d', 'woocommerce' ),
targetLevel
),
isActive,
onClick: () => onChange( targetLevel ),
};
}
render() {
const {
isCollapsed = true,
minLevel,
maxLevel,
selectedLevel,
onChange,
} = this.props;
const levels = Array.from(
{ length: maxLevel - minLevel + 1 },
( _, i ) => i + minLevel
);
return (
<ToolbarGroup
isCollapsed={ isCollapsed }
icon={ <HeadingLevelIcon level={ selectedLevel } /> }
controls={ levels.map( ( index ) =>
this.createLevelControl( index, selectedLevel, onChange )
) }
/>
);
}
}
export default HeadingToolbar;

View File

@@ -0,0 +1,63 @@
.wc-blocks-incompatible-extensions-notice.is-dismissible {
margin: 0;
padding-right: 16px;
.components-notice__dismiss {
min-width: 24px;
}
.components-notice__content {
margin: 4px 0;
}
svg {
width: 16px;
height: 16px;
}
.wc-blocks-incompatible-extensions-notice__content {
display: flex;
.wc-blocks-incompatible-extensions-notice__warning-icon {
width: 24px;
height: 24px;
margin-right: 6px;
min-width: 24px; // Ensure that notice is visible in Safari. See https://github.com/woocommerce/woocommerce-blocks/issues/11734
}
}
ul {
margin: 0 0 1em 1.2em;
padding: 0;
list-style: disc outside;
}
}
.wc-blocks-legacy-page-notice {
margin: 0;
}
ul.cross-list {
list-style: none outside;
margin: 0 0 24px 1em;
padding: 0;
li {
list-style: none outside;
margin: 1em 0;
padding: 0;
}
li::before {
content: "";
margin-right: 0.5em;
}
}
.wc-blocks-incompatible-extensions-notice-modal-content {
p {
margin-top: 0;
}
}
.wc-blocks-incompatible-extensions-notice-modal-actions {
text-align: right;
button {
margin-left: 4px;
display: inline-block;
}
}

View File

@@ -0,0 +1,280 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
Notice,
ExternalLink,
Button,
TabbableContainer,
Modal,
} from '@wordpress/components';
import {
createInterpolateElement,
useEffect,
useState,
} from '@wordpress/element';
import { Alert } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
import { useDispatch, useSelect } from '@wordpress/data';
import { createBlock, BlockInstance } from '@wordpress/blocks';
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { recordEvent } from '@woocommerce/tracks';
import { findBlock } from '@woocommerce/utils';
/**
* Internal dependencies
*/
import { useCombinedIncompatibilityNotice } from './use-combined-incompatibility-notice';
import { ModalContent } from './modal';
import './editor.scss';
interface ExtensionNoticeProps {
toggleDismissedStatus: ( status: boolean ) => void;
block: 'woocommerce/cart' | 'woocommerce/checkout';
clientId: string;
}
/**
* Shows a notice when there are incompatible extensions.
*
* Tracks events:
* - switch_to_classic_shortcode_click
* - switch_to_classic_shortcode_confirm
* - switch_to_classic_shortcode_cancel
* - switch_to_classic_shortcode_undo
*/
export function IncompatibleExtensionsNotice( {
toggleDismissedStatus,
block,
clientId,
}: ExtensionNoticeProps ) {
const [
isVisible,
dismissNotice,
incompatiblePaymentMethods,
numberOfIncompatiblePaymentMethods,
] = useCombinedIncompatibilityNotice( block );
const [ isOpen, setOpen ] = useState( false );
const openModal = () => setOpen( true );
const closeModal = () => setOpen( false );
const { createInfoNotice } = useDispatch( noticesStore );
const { replaceBlock, selectBlock } = useDispatch( blockEditorStore );
const { undo } = useDispatch( coreStore );
const { getBlocks } = useSelect( ( select ) => {
return {
getBlocks: select( blockEditorStore ).getBlocks,
};
}, [] );
useEffect( () => {
toggleDismissedStatus( ! isVisible );
}, [ isVisible, toggleDismissedStatus ] );
if ( ! isVisible ) {
return null;
}
const switchButtonLabel =
block === 'woocommerce/cart'
? __( 'Switch to classic cart', 'woo-gutenberg-products-block' )
: __(
'Switch to classic checkout',
'woo-gutenberg-products-block'
);
const snackbarLabel =
block === 'woocommerce/cart'
? __( 'Switched to classic cart.', 'woo-gutenberg-products-block' )
: __(
'Switched to classic checkout.',
'woo-gutenberg-products-block'
);
const noticeContent = (
<>
{ numberOfIncompatiblePaymentMethods > 1
? createInterpolateElement(
__(
'Some active extensions do not yet support this block. This may impact the shopper experience. <a>Learn more</a>',
'woo-gutenberg-products-block'
),
{
a: (
<ExternalLink href="https://woocommerce.com/document/cart-checkout-blocks-support-status/" />
),
}
)
: createInterpolateElement(
sprintf(
// translators: %s is the name of the extension.
__(
'<strong>%s</strong> does not yet support this block. This may impact the shopper experience. <a>Learn more</a>',
'woo-gutenberg-products-block'
),
Object.values( incompatiblePaymentMethods )[ 0 ]
),
{
strong: <strong />,
a: (
<ExternalLink href="https://woocommerce.com/document/cart-checkout-blocks-support-status/" />
),
}
) }
</>
);
const selectClassicShortcodeBlock = () => {
const classicShortcodeBlock = findBlock( {
blocks: getBlocks(),
findCondition: ( foundBlock: BlockInstance ) =>
foundBlock.name === 'woocommerce/classic-shortcode',
} );
if ( classicShortcodeBlock ) {
selectBlock( classicShortcodeBlock.clientId );
}
};
return (
<Notice
className="wc-blocks-incompatible-extensions-notice"
status={ 'warning' }
onRemove={ dismissNotice }
spokenMessage={ noticeContent }
>
<div className="wc-blocks-incompatible-extensions-notice__content">
<Icon
className="wc-blocks-incompatible-extensions-notice__warning-icon"
icon={ <Alert /> }
/>
<div>
<p>{ noticeContent }</p>
{ numberOfIncompatiblePaymentMethods > 1 && (
<ul>
{ Object.entries( incompatiblePaymentMethods ).map(
( [ id, title ] ) => (
<li
key={ id }
className="wc-blocks-incompatible-extensions-notice__element"
>
{ title }
</li>
)
) }
</ul>
) }
<Button
variant={ 'secondary' }
onClick={ () => {
recordEvent( 'switch_to_classic_shortcode_click', {
shortcode:
block === 'woocommerce/checkout'
? 'checkout'
: 'cart',
} );
openModal();
} }
>
{ switchButtonLabel }
</Button>
{ isOpen && (
<Modal
size="medium"
title={ switchButtonLabel }
onRequestClose={ closeModal }
className="wc-blocks-incompatible-extensions-notice-modal-content"
>
<ModalContent blockType={ block } />
<TabbableContainer className="wc-blocks-incompatible-extensions-notice-modal-actions">
<Button
variant="primary"
isDestructive={ true }
onClick={ () => {
replaceBlock(
clientId,
createBlock(
'woocommerce/classic-shortcode',
{
shortcode:
block ===
'woocommerce/checkout'
? 'checkout'
: 'cart',
}
)
);
recordEvent(
'switch_to_classic_shortcode_confirm',
{
shortcode:
block ===
'woocommerce/checkout'
? 'checkout'
: 'cart',
}
);
selectClassicShortcodeBlock();
createInfoNotice( snackbarLabel, {
actions: [
{
label: __(
'Undo',
'woo-gutenberg-products-block'
),
onClick: () => {
undo();
recordEvent(
'switch_to_classic_shortcode_undo',
{
shortcode:
block ===
'woocommerce/checkout'
? 'checkout'
: 'cart',
}
);
},
},
],
type: 'snackbar',
} );
closeModal();
} }
>
{ __(
'Switch',
'woo-gutenberg-products-block'
) }
</Button>{ ' ' }
<Button
variant="secondary"
onClick={ () => {
recordEvent(
'switch_to_classic_shortcode_cancel',
{
shortcode:
block ===
'woocommerce/checkout'
? 'checkout'
: 'cart',
}
);
closeModal();
} }
>
{ __(
'Cancel',
'woo-gutenberg-products-block'
) }
</Button>
</TabbableContainer>
</Modal>
) }
</div>
</div>
</Notice>
);
}

View File

@@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const ModalContent = ( {
blockType = 'woocommerce/cart',
}: {
blockType: 'woocommerce/cart' | 'woocommerce/checkout';
} ): JSX.Element => {
if ( blockType === 'woocommerce/cart' ) {
return (
<p>
{ __(
'If you continue, the cart block will be replaced with the classic experience powered by shortcodes. This means that you may lose customizations that you made to the cart block.',
'woo-gutenberg-products-block'
) }
</p>
);
}
return (
<>
<p>
{ __(
'If you continue, the checkout block will be replaced with the classic experience powered by shortcodes. This means that you may lose:',
'woo-gutenberg-products-block'
) }
</p>
<ul className="cross-list">
<li>
{ __(
'Customizations and updates to the block',
'woo-gutenberg-products-block'
) }
</li>
<li>
{ __(
'Additional local pickup options created for the new checkout',
'woo-gutenberg-products-block'
) }
</li>
</ul>
</>
);
};

View File

@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import { useLocalStorageState } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { useIncompatiblePaymentGatewaysNotice } from './use-incompatible-payment-gateways-notice';
import { useIncompatibleExtensionNotice } from './use-incompatible-extensions-notice';
type StoredIncompatibleExtension = { [ k: string ]: string[] };
const initialDismissedNotices: React.SetStateAction<
StoredIncompatibleExtension[]
> = [];
const areEqual = ( array1: string[], array2: string[] ) => {
if ( array1.length !== array2.length ) {
return false;
}
const uniqueCollectionValues = new Set( [ ...array1, ...array2 ] );
return uniqueCollectionValues.size === array1.length;
};
const sortAlphabetically = ( obj: {
[ key: string ]: string;
} ): { [ key: string ]: string } =>
Object.fromEntries(
Object.entries( obj ).sort( ( [ , a ], [ , b ] ) =>
a.localeCompare( b )
)
);
export const useCombinedIncompatibilityNotice = (
blockName: string
): [ boolean, () => void, { [ k: string ]: string }, number ] => {
const [
incompatibleExtensions,
incompatibleExtensionSlugs,
incompatibleExtensionCount,
] = useIncompatibleExtensionNotice();
const [
incompatiblePaymentMethods,
incompatiblePaymentMethodSlugs,
incompatiblePaymentMethodCount,
] = useIncompatiblePaymentGatewaysNotice();
const allIncompatibleItems = {
...incompatibleExtensions,
...incompatiblePaymentMethods,
};
const allIncompatibleItemSlugs = [
...incompatibleExtensionSlugs,
...incompatiblePaymentMethodSlugs,
];
const allIncompatibleItemCount =
incompatibleExtensionCount + incompatiblePaymentMethodCount;
const [ dismissedNotices, setDismissedNotices ] = useLocalStorageState<
StoredIncompatibleExtension[]
>(
`wc-blocks_dismissed_incompatible_extensions_notices`,
initialDismissedNotices
);
const [ isVisible, setIsVisible ] = useState( false );
const isDismissedNoticeUpToDate = dismissedNotices.some(
( notice ) =>
Object.keys( notice ).includes( blockName ) &&
areEqual(
notice[ blockName as keyof object ],
allIncompatibleItemSlugs
)
);
const shouldBeDismissed =
allIncompatibleItemCount === 0 || isDismissedNoticeUpToDate;
const dismissNotice = () => {
const dismissedNoticesSet = new Set( dismissedNotices );
dismissedNoticesSet.add( {
[ blockName ]: allIncompatibleItemSlugs,
} );
setDismissedNotices( [ ...dismissedNoticesSet ] );
};
// This ensures the modal is not loaded on first render. This is required so
// Gutenberg doesn't steal the focus from the Guide and focuses the block.
useEffect( () => {
setIsVisible( ! shouldBeDismissed );
if ( ! shouldBeDismissed && ! isDismissedNoticeUpToDate ) {
setDismissedNotices( ( previousDismissedNotices ) =>
previousDismissedNotices.reduce(
( acc: StoredIncompatibleExtension[], curr ) => {
if ( Object.keys( curr ).includes( blockName ) ) {
return acc;
}
acc.push( curr );
return acc;
},
[]
)
);
}
}, [
shouldBeDismissed,
isDismissedNoticeUpToDate,
setDismissedNotices,
blockName,
] );
return [
isVisible,
dismissNotice,
sortAlphabetically( allIncompatibleItems ),
allIncompatibleItemCount,
];
};

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
export const useIncompatibleExtensionNotice = (): [
{ [ k: string ]: string } | null,
string[],
number
] => {
interface GlobalIncompatibleExtensions {
id: string;
title: string;
}
const incompatibleExtensions: Record< string, string > = {};
if ( getSetting( 'incompatibleExtensions' ) ) {
getSetting< GlobalIncompatibleExtensions[] >(
'incompatibleExtensions'
).forEach( ( extension ) => {
incompatibleExtensions[ extension.id ] = extension.title;
} );
}
const incompatibleExtensionSlugs = Object.keys( incompatibleExtensions );
const incompatibleExtensionCount = incompatibleExtensionSlugs.length;
return [
incompatibleExtensions,
incompatibleExtensionSlugs,
incompatibleExtensionCount,
];
};

View File

@@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY as PAYMENT_STORE_KEY } from '../../data/payment/constants';
export const useIncompatiblePaymentGatewaysNotice = (): [
{ [ k: string ]: string },
string[],
number
] => {
const { incompatiblePaymentMethods } = useSelect( ( select ) => {
const { getIncompatiblePaymentMethods } = select( PAYMENT_STORE_KEY );
return {
incompatiblePaymentMethods: getIncompatiblePaymentMethods(),
};
}, [] );
const incompatiblePaymentMethodSlugs = Object.keys(
incompatiblePaymentMethods
);
const incompatiblePaymentMethodCount =
incompatiblePaymentMethodSlugs.length;
return [
incompatiblePaymentMethods,
incompatiblePaymentMethodSlugs,
incompatiblePaymentMethodCount,
];
};

View File

@@ -0,0 +1,7 @@
.wc-blocks-no-payment-methods-notice {
margin: 0;
.components-notice__content {
margin: 4px 0;
}
}

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Notice, ExternalLink } from '@wordpress/components';
import { ADMIN_URL } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import './editor.scss';
export function NoPaymentMethodsNotice() {
const noticeContent = __(
'Your store does not have any payment methods that support the Checkout block. Once you have configured a compatible payment method it will be displayed here.',
'woo-gutenberg-products-block'
);
return (
<Notice
className="wc-blocks-no-payment-methods-notice"
status={ 'warning' }
spokenMessage={ noticeContent }
isDismissible={ false }
>
<div className="wc-blocks-no-payment-methods-notice__content">
{ noticeContent }{ ' ' }
<ExternalLink
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=checkout` }
>
{ __(
'Configure Payment Methods',
'woo-gutenberg-products-block'
) }
</ExternalLink>
</div>
</Notice>
);
}

View File

@@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { PanelBody, SelectControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { formatTitle } from '../utils';
const PageSelector = ( { setPageId, pageId, labels } ) => {
const pages =
useSelect( ( select ) => {
return select( 'core' ).getEntityRecords( 'postType', 'page', {
status: 'publish',
orderby: 'title',
order: 'asc',
per_page: 100,
} );
}, [] ) || null;
if ( pages ) {
return (
<PanelBody title={ labels.title }>
<SelectControl
label={ __( 'Link to', 'woocommerce' ) }
value={ pageId }
options={ [
{
label: labels.default,
value: 0,
},
...pages.map( ( page ) => {
return {
label: formatTitle( page, pages ),
value: parseInt( page.id, 10 ),
};
} ),
] }
onChange={ ( value ) => setPageId( parseInt( value, 10 ) ) }
/>
</PanelBody>
);
}
return null;
};
export default PageSelector;

View File

@@ -0,0 +1,238 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { __, _n, sprintf } from '@wordpress/i18n';
import {
SearchListControl,
SearchListItem,
} from '@woocommerce/editor-components/search-list-control';
import { SelectControl } from '@wordpress/components';
import { withInstanceId } from '@wordpress/compose';
import useProductAttributes from '@woocommerce/base-context/hooks/use-product-attributes';
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item';
import {
renderItemArgs,
SearchListControlProps,
SearchListItem as SearchListItemProps,
} from '@woocommerce/editor-components/search-list-control/types';
import { convertAttributeObjectToSearchItem } from '@woocommerce/utils';
/**
* Internal dependencies
*/
import './style.scss';
interface Props
extends Omit< SearchListControlProps, 'isSingle' | 'list' | 'selected' > {
instanceId?: string;
/**
* Callback to update the category operator. If not passed in, setting is not used.
*/
onOperatorChange?: () => void;
/**
* Setting for whether products should match all or any selected categories.
*/
operator: 'all' | 'any';
/**
* The list of currently selected attribute ids.
*/
selected: { id: number }[];
}
const ProductAttributeTermControl = ( {
onChange,
onOperatorChange,
instanceId,
isCompact = false,
messages = {},
operator = 'any',
selected,
type = 'text',
}: Props ) => {
const { errorLoadingAttributes, isLoadingAttributes, productsAttributes } =
useProductAttributes( true );
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',
{
'is-searching': search.length > 0,
'is-skip-level': depth === 0 && item.parent !== 0,
},
];
if ( ! item.breadcrumbs.length ) {
return (
<ExpandableSearchListItem
{ ...args }
className={ classNames( classes ) }
item={ item }
isLoading={ isLoadingAttributes }
disabled={ item.count === 0 }
name={ `attributes-${ instanceId }` }
countLabel={ sprintf(
/* translators: %d is the count of terms. */
_n(
'%d term',
'%d terms',
count,
'woo-gutenberg-products-block'
),
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',
count,
'woo-gutenberg-products-block'
),
item.name,
count
) }
/>
);
}
const itemName = `${ item.breadcrumbs[ 0 ] }: ${ item.name }`;
return (
<SearchListItem
{ ...args }
name={ `terms-${ instanceId }` }
className={ classNames( ...classes, 'has-count' ) }
countLabel={ sprintf(
/* translators: %d is the count of products. */
_n(
'%d product',
'%d products',
count,
'woo-gutenberg-products-block'
),
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',
count,
'woo-gutenberg-products-block'
),
itemName,
count
) }
/>
);
};
const list = productsAttributes.reduce( ( acc, curr ) => {
const { terms, ...props } = curr;
return [
...acc,
convertAttributeObjectToSearchItem( props ),
...terms.map( convertAttributeObjectToSearchItem ),
];
}, [] as SearchListItemProps[] );
messages = {
clear: __(
'Clear all product attributes',
'woo-gutenberg-products-block'
),
noItems: __(
"Your store doesn't have any product attributes.",
'woo-gutenberg-products-block'
),
search: __(
'Search for product attributes',
'woo-gutenberg-products-block'
),
selected: ( n: number ) =>
sprintf(
/* translators: %d is the count of attributes selected. */
_n(
'%d attribute selected',
'%d attributes selected',
n,
'woo-gutenberg-products-block'
),
n
),
updated: __(
'Product attribute search results updated.',
'woo-gutenberg-products-block'
),
...messages,
};
if ( errorLoadingAttributes ) {
return <ErrorMessage error={ errorLoadingAttributes } />;
}
return (
<>
<SearchListControl
className="woocommerce-product-attributes"
isCompact={ isCompact }
isHierarchical
isLoading={ isLoadingAttributes }
isSingle={ false }
list={ list }
messages={ messages }
onChange={ onChange }
renderItem={ renderItem }
selected={
selected
.map( ( { id } ) =>
list.find( ( term ) => term.id === id )
)
.filter( Boolean ) as SearchListItemProps[]
}
type={ type }
/>
{ !! onOperatorChange && (
<div hidden={ selected.length < 2 }>
<SelectControl
className="woocommerce-product-attributes__operator"
label={ __(
'Display products matching',
'woo-gutenberg-products-block'
) }
help={ __(
'Pick at least two attributes to use this setting.',
'woo-gutenberg-products-block'
) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __(
'Any selected attributes',
'woo-gutenberg-products-block'
),
value: 'any',
},
{
label: __(
'All selected attributes',
'woo-gutenberg-products-block'
),
value: 'all',
},
] }
/>
</div>
) }
</>
);
};
export default withInstanceId( ProductAttributeTermControl );

View File

@@ -0,0 +1,35 @@
.woocommerce-product-attributes__operator {
.components-base-control__help {
@include visually-hidden;
}
.components-base-control__label {
margin-bottom: 0;
margin-right: 0.5em;
}
}
.woocommerce-search-list__item.woocommerce-product-attributes__item {
&.is-searching,
&.is-skip-level {
.woocommerce-search-list__item-prefix::after {
content: ":";
}
}
&.is-not-active {
&:hover,
&:active,
&:focus {
background: $white;
}
}
&.is-loading {
justify-content: center;
.components-spinner {
margin-bottom: $gap-small;
}
}
}

View File

@@ -0,0 +1,234 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import {
SearchListControl,
SearchListItem,
} from '@woocommerce/editor-components/search-list-control';
import { SelectControl } from '@wordpress/components';
import { withCategories } from '@woocommerce/block-hocs';
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
/**
* @param {Object} props
* @param {string=} props.categories
* @param {boolean=} props.isLoading
* @param {string=} props.error
* @param {Function} props.onChange
* @param {Function=} props.onOperatorChange
* @param {string=} props.operator
* @param {number[]} props.selected
* @param {boolean=} props.isCompact
* @param {boolean=} props.isSingle
* @param {boolean=} props.showReviewCount
*/
const ProductCategoryControl = ( {
categories,
error,
isLoading,
onChange,
onOperatorChange,
operator,
selected,
isCompact,
isSingle,
showReviewCount,
} ) => {
const renderItem = ( args ) => {
const { item, search, depth = 0 } = args;
const accessibleName = ! item.breadcrumbs.length
? item.name
: `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`;
const listItemAriaLabel = showReviewCount
? sprintf(
/* translators: %1$s is the item name, %2$d is the count of reviews for the item. */
_n(
'%1$s, has %2$d review',
'%1$s, has %2$d reviews',
item.review_count,
'woocommerce'
),
accessibleName,
item.review_count
)
: sprintf(
/* translators: %1$s is the item name, %2$d is the count of products for the item. */
_n(
'%1$s, has %2$d product',
'%1$s, has %2$d products',
item.count,
'woocommerce'
),
accessibleName,
item.count
);
const listItemCountLabel = showReviewCount
? sprintf(
/* translators: %d is the count of reviews. */
_n(
'%d review',
'%d reviews',
item.review_count,
'woocommerce'
),
item.review_count
)
: sprintf(
/* translators: %d is the count of products. */
_n(
'%d product',
'%d products',
item.count,
'woocommerce'
),
item.count
);
return (
<SearchListItem
className={ classNames(
'woocommerce-product-categories__item',
'has-count',
{
'is-searching': search.length > 0,
'is-skip-level': depth === 0 && item.parent !== 0,
}
) }
{ ...args }
countLabel={ listItemCountLabel }
aria-label={ listItemAriaLabel }
/>
);
};
const messages = {
clear: __(
'Clear all product categories',
'woocommerce'
),
list: __( 'Product Categories', 'woocommerce' ),
noItems: __(
"Your store doesn't have any product categories.",
'woocommerce'
),
search: __(
'Search for product categories',
'woocommerce'
),
selected: ( n ) =>
sprintf(
/* translators: %d is the count of selected categories. */
_n(
'%d category selected',
'%d categories selected',
n,
'woocommerce'
),
n
),
updated: __(
'Category search results updated.',
'woocommerce'
),
};
if ( error ) {
return <ErrorMessage error={ error } />;
}
return (
<>
<SearchListControl
className="woocommerce-product-categories"
list={ categories }
isLoading={ isLoading }
selected={ selected
.map( ( id ) =>
categories.find( ( category ) => category.id === id )
)
.filter( Boolean ) }
onChange={ onChange }
renderItem={ renderItem }
messages={ messages }
isCompact={ isCompact }
isHierarchical
isSingle={ isSingle }
/>
{ !! onOperatorChange && (
<div hidden={ selected.length < 2 }>
<SelectControl
className="woocommerce-product-categories__operator"
label={ __(
'Display products matching',
'woocommerce'
) }
help={ __(
'Pick at least two categories to use this setting.',
'woocommerce'
) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __(
'Any selected categories',
'woocommerce'
),
value: 'any',
},
{
label: __(
'All selected categories',
'woocommerce'
),
value: 'all',
},
] }
/>
</div>
) }
</>
);
};
ProductCategoryControl.propTypes = {
/**
* Callback to update the selected product categories.
*/
onChange: PropTypes.func.isRequired,
/**
* Callback to update the category operator. If not passed in, setting is not used.
*/
onOperatorChange: PropTypes.func,
/**
* Setting for whether products should match all or any selected categories.
*/
operator: PropTypes.oneOf( [ 'all', 'any' ] ),
/**
* The list of currently selected category IDs.
*/
selected: PropTypes.array.isRequired,
isCompact: PropTypes.bool,
/**
* Allow only a single selection. Defaults to false.
*/
isSingle: PropTypes.bool,
};
ProductCategoryControl.defaultProps = {
operator: 'any',
isCompact: false,
isSingle: false,
};
export default withCategories( ProductCategoryControl );

View File

@@ -0,0 +1,10 @@
.woocommerce-product-categories__operator {
.components-base-control__help {
@include visually-hidden;
}
.components-base-control__label {
margin-bottom: 0;
margin-right: 0.5em;
}
}

View File

@@ -0,0 +1,238 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { isEmpty } from '@woocommerce/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';
/**
* 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: __(
"Your store doesn't have any products.",
'woo-gutenberg-products-block'
),
search: __(
'Search for a product to display',
'woo-gutenberg-products-block'
),
updated: __(
'Product search results updated.',
'woo-gutenberg-products-block'
),
};
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.details?.variations && Array.isArray( item.details.variations )
? item.details.variations.length
: 0;
const classes = classNames(
'woocommerce-search-product__item',
'woocommerce-search-list__item',
`depth-${ depth }`,
'has-count',
{
'is-searching': search.length > 0,
'is-skip-level': depth === 0 && item.parent !== 0,
'is-variable': variationsCount > 0,
}
);
// Top level items custom rendering based on SearchListItem.
if ( ! item.breadcrumbs.length ) {
const hasVariations =
item.details?.variations && item.details.variations.length > 0;
return (
<ExpandableSearchListItem
{ ...args }
className={ classNames( classes, {
'is-selected': isSelected,
} ) }
isSelected={ isSelected }
item={ item }
onSelect={ () => {
return () => {
onSelect( item )();
};
} }
isLoading={ isLoading || variationsLoading }
countLabel={
hasVariations
? sprintf(
/* translators: %1$d is the number of variations of a product product. */
__(
'%1$d variations',
'woo-gutenberg-products-block'
),
item.details?.variations.length
)
: null
}
name={ `products-${ instanceId }` }
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.details?.variation )
? args
: {
...args,
item: {
...args.item,
name: item.details?.variation as string,
},
'aria-label': `${ item.breadcrumbs[ 0 ] }: ${ item.details?.variation }`,
};
return (
<SearchListItem
{ ...itemArgs }
className={ classes }
name={ `variations-${ instanceId }` }
/>
);
};
const getRenderItemFunc = () => {
if ( renderItem ) {
return renderItem;
} else if ( showVariations ) {
return renderItemWithVariations;
}
return () => null;
};
if ( error ) {
return <ErrorMessage error={ error } />;
}
const currentVariations =
variations && expandedProduct && variations[ expandedProduct ]
? variations[ expandedProduct ]
: [];
const currentList = [ ...products, ...currentVariations ].map(
convertProductResponseItemToSearchItem
);
return (
<SearchListControl
className="woocommerce-products"
list={ currentList }
isCompact={ isCompact }
isLoading={ isLoading }
isSingle
selected={ currentList.filter( ( { id } ) =>
selected.includes( Number( id ) )
) }
onChange={ onChange }
renderItem={ getRenderItemFunc() }
onSearch={ onSearch }
messages={ messages }
isHierarchical
/>
);
};
export default withTransformSingleSelectToMultipleSelect(
withSearchedProducts(
withProductVariations( withInstanceId( ProductControl ) )
)
);

View File

@@ -0,0 +1,45 @@
.woocommerce-search-product__item {
.woocommerce-search-list__item-name {
.description {
display: block;
}
}
&.is-searching,
&.is-skip-level {
.woocommerce-search-list__item-prefix::after {
content: ":";
}
}
&.is-not-active {
&:hover,
&:active,
&:focus {
background: $white;
}
}
&.is-loading {
justify-content: center;
.components-spinner {
margin-bottom: $gap-small;
}
}
&.depth-0.is-variable::after {
margin-left: $gap-smaller;
content: "";
height: $gap-large;
width: $gap-large;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="#{encode-color($gray-700)}" /></svg>');
background-repeat: no-repeat;
background-position: center right;
background-size: contain;
}
&.depth-0.is-variable.is-selected::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="#{encode-color($gray-700)}" /></svg>');
}
}

View File

@@ -0,0 +1,80 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { SelectControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import type { ProductOrderbyControlProps } from './types';
/**
* A pre-configured SelectControl for product orderby settings.
*
* @param {Object} props Incoming props for the component.
* @param {string} props.value
* @param {function(any):any} props.setAttributes Setter for block attributes.
*/
const ProductOrderbyControl = ( {
value,
setAttributes,
}: ProductOrderbyControlProps ) => {
return (
<SelectControl
label={ __( 'Order products by', 'woo-gutenberg-products-block' ) }
value={ value }
options={ [
{
label: __(
'Newness - newest first',
'woo-gutenberg-products-block'
),
value: 'date',
},
{
label: __(
'Price - low to high',
'woo-gutenberg-products-block'
),
value: 'price_asc',
},
{
label: __(
'Price - high to low',
'woo-gutenberg-products-block'
),
value: 'price_desc',
},
{
label: __(
'Rating - highest first',
'woo-gutenberg-products-block'
),
value: 'rating',
},
{
label: __(
'Sales - most first',
'woo-gutenberg-products-block'
),
value: 'popularity',
},
{
label: __(
'Title - alphabetical',
'woo-gutenberg-products-block'
),
value: 'title',
},
{
label: __( 'Menu Order', 'woo-gutenberg-products-block' ),
value: 'menu_order',
},
] }
onChange={ ( orderby ) => setAttributes( { orderby } ) }
/>
);
};
export default ProductOrderbyControl;

View File

@@ -0,0 +1,4 @@
export interface ProductOrderbyControlProps {
value: string;
setAttributes: ( attributes: Record< string, unknown > ) => void;
}

View File

@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
import { useCallback, useState, useEffect } from '@wordpress/element';
import { ToggleControl } from '@wordpress/components';
export interface ProductStockControlProps {
value: Array< string >;
setAttributes: ( attributes: Record< string, unknown > ) => void;
}
// Look up whether or not out of stock items should be hidden globally.
const hideOutOfStockItems = getSetting( 'hideOutOfStockItems', false );
// Get the stock status options.
const allStockStatusOptions = getSetting( 'stockStatusOptions', {} );
/**
* A pre-configured SelectControl for product stock settings.
*/
const ProductStockControl = ( {
value,
setAttributes,
}: ProductStockControlProps ): JSX.Element => {
// Determine whether or not to use the out of stock status.
const { outofstock, ...otherStockStatusOptions } = allStockStatusOptions;
const stockStatusOptions = hideOutOfStockItems
? otherStockStatusOptions
: allStockStatusOptions;
/**
* Valid options must be in an array of [ 'value' : 'mystatus', 'label' : 'My label' ] format.
* stockStatusOptions are returned as [ 'mystatus' : 'My label' ].
* Formatting is corrected here.
*/
const displayOptions = Object.entries( stockStatusOptions )
.map( ( [ slug, name ] ) => ( { value: slug, label: name } ) )
.filter( ( status ) => !! status.label );
const defaultCheckedOptions = Object.keys( stockStatusOptions ).filter(
( key: string ) => !! key
);
// Set the initial state to the default or saved value.
const [ checkedOptions, setChecked ] = useState(
value || defaultCheckedOptions
);
/**
* Set attributes when checked items change.
* Note: The blank stock status prevents all results returning when all options are unchecked.
*/
useEffect( () => {
setAttributes( {
stockStatus: [ '', ...checkedOptions ],
} );
}, [ checkedOptions, setAttributes ] );
/**
* When a checkbox in the list changes, update state.
*/
const onChange = useCallback(
( checkedValue: string ) => {
const previouslyChecked = checkedOptions.includes( checkedValue );
const newChecked = checkedOptions.filter(
( filteredValue ) => filteredValue !== checkedValue
);
if ( ! previouslyChecked ) {
newChecked.push( checkedValue );
newChecked.sort();
}
setChecked( newChecked );
},
[ checkedOptions ]
);
return (
<>
{ displayOptions.map( ( option ) => {
const helpText = checkedOptions.includes( option.value )
? /* translators: %s stock status. */ __(
'Stock status "%s" visible.',
'woo-gutenberg-products-block'
)
: /* translators: %s stock status. */ __(
'Stock status "%s" hidden.',
'woo-gutenberg-products-block'
);
return (
<ToggleControl
label={ option.label }
key={ option.value }
help={ sprintf( helpText, option.label ) }
checked={ checkedOptions.includes( option.value ) }
onChange={ () => onChange( option.value ) }
/>
);
} ) }
</>
);
};
export default ProductStockControl;

View File

@@ -0,0 +1,142 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { useState, useEffect, useCallback, useMemo } from '@wordpress/element';
import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
import { SelectControl } from '@wordpress/components';
import { getSetting } from '@woocommerce/settings';
import { useDebouncedCallback } from 'use-debounce';
/**
* Internal dependencies
*/
import type { SearchListItem as SearchListItemProps } from '../search-list-control/types';
import ProductTagItem from './product-tag-item';
import type { ProductTagControlProps } from './types';
import { getProductTags } from '../utils';
import './style.scss';
/**
* Component to handle searching and selecting product tags.
*/
const ProductTagControl = ( {
isCompact = false,
onChange,
onOperatorChange,
operator = 'any',
selected,
}: ProductTagControlProps ): JSX.Element => {
const [ list, setList ] = useState< SearchListItemProps[] >( [] );
const [ loading, setLoading ] = useState( true );
const [ isMounted, setIsMounted ] = useState( false );
const limitTags = getSetting( 'limitTags', false );
const selectedTags = useMemo< SearchListItemProps[] >( () => {
return list.filter( ( item ) => selected.includes( item.id ) );
}, [ list, selected ] );
const onSearch = useCallback(
( search: string ) => {
setLoading( true );
getProductTags( { selected, search } )
.then( ( newList ) => {
setList( newList );
setLoading( false );
} )
.catch( () => {
setLoading( false );
} );
},
[ selected ]
);
// Load on mount.
useEffect( () => {
if ( isMounted ) {
return;
}
onSearch( '' );
setIsMounted( true );
}, [ onSearch, isMounted ] );
const debouncedOnSearch = useDebouncedCallback( onSearch, 400 );
const messages = {
clear: __( 'Clear all product tags', 'woo-gutenberg-products-block' ),
list: __( 'Product Tags', 'woo-gutenberg-products-block' ),
noItems: __(
'You have not set up any product tags on your store.',
'woo-gutenberg-products-block'
),
search: __( 'Search for product tags', 'woo-gutenberg-products-block' ),
selected: ( n: number ) =>
sprintf(
/* translators: %d is the count of selected tags. */
_n(
'%d tag selected',
'%d tags selected',
n,
'woo-gutenberg-products-block'
),
n
),
updated: __(
'Tag search results updated.',
'woo-gutenberg-products-block'
),
};
return (
<>
<SearchListControl
className="woocommerce-product-tags"
list={ list }
isLoading={ loading }
selected={ selectedTags }
onChange={ onChange }
onSearch={ limitTags ? debouncedOnSearch : undefined }
renderItem={ ProductTagItem }
messages={ messages }
isCompact={ isCompact }
isHierarchical
isSingle={ false }
/>
{ !! onOperatorChange && (
<div hidden={ selected.length < 2 }>
<SelectControl
className="woocommerce-product-tags__operator"
label={ __(
'Display products matching',
'woo-gutenberg-products-block'
) }
help={ __(
'Pick at least two tags to use this setting.',
'woo-gutenberg-products-block'
) }
value={ operator }
onChange={ onOperatorChange }
options={ [
{
label: __(
'Any selected tags',
'woo-gutenberg-products-block'
),
value: 'any',
},
{
label: __(
'All selected tags',
'woo-gutenberg-products-block'
),
value: 'all',
},
] }
/>
</div>
) }
</>
);
};
export default ProductTagControl;

View File

@@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { _n, sprintf } from '@wordpress/i18n';
import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import type { renderItemArgs } from '../search-list-control/types';
export const ProductTagItem = ( {
item,
search,
depth = 0,
...rest
}: renderItemArgs ): JSX.Element => {
const accessibleName = ! item.breadcrumbs.length
? item.name
: `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`;
return (
<SearchListItem
className={ classNames(
'woocommerce-product-tags__item',
'has-count',
{
'is-searching': search.length > 0,
'is-skip-level': depth === 0 && item.parent !== 0,
}
) }
item={ item }
search={ search }
depth={ depth }
{ ...rest }
ariaLabel={ sprintf(
/* translators: %1$d is the count of products, %2$s is the name of the tag. */
_n(
'%1$d product tagged as %2$s',
'%1$d products tagged as %2$s',
item.count,
'woo-gutenberg-products-block'
),
item.count,
accessibleName
) }
/>
);
};
export default ProductTagItem;

View File

@@ -0,0 +1,10 @@
.woocommerce-product-tags__operator {
.components-base-control__help {
@include visually-hidden;
}
.components-base-control__label {
margin-bottom: 0;
margin-right: 0.5em;
}
}

View File

@@ -0,0 +1,13 @@
/**
* Internal dependencies
*/
import type { SearchListItem as SearchListItemProps } from '../search-list-control/types';
export type ProductTagControlProps = {
isCompact?: boolean;
onChange: ( selected: SearchListItemProps[] ) => void;
onOperatorChange?: ( operator: string ) => void;
operator?: string;
// Selected tag ids.
selected: ( number | string )[];
};

View File

@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
import PropTypes from 'prop-types';
import { withSearchedProducts } from '@woocommerce/block-hocs';
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
import { decodeEntities } from '@wordpress/html-entities';
/**
* The products control exposes a custom selector for searching and selecting
* products.
*
* @param {Object} props Component props.
* @param {string} props.error
* @param {Function} props.onChange Callback fired when the selected item changes
* @param {Function} props.onSearch Callback fired when a search is triggered
* @param {Array} props.selected An array of selected products.
* @param {Array} props.products An array of products to select from.
* @param {boolean} props.isLoading Whether or not the products are being loaded.
* @param {boolean} props.isCompact Whether or not the control should have compact styles.
*
* @return {Function} A functional component.
*/
const ProductsControl = ( {
error,
onChange,
onSearch,
selected,
products,
isLoading,
isCompact,
} ) => {
const messages = {
clear: __( 'Clear all products', 'woocommerce' ),
list: __( 'Products', 'woocommerce' ),
noItems: __(
"Your store doesn't have any products.",
'woocommerce'
),
search: __(
'Search for products to display',
'woocommerce'
),
selected: ( n ) =>
sprintf(
/* translators: %d is the number of selected products. */
_n(
'%d product selected',
'%d products selected',
n,
'woocommerce'
),
n
),
updated: __(
'Product search results updated.',
'woocommerce'
),
};
if ( error ) {
return <ErrorMessage error={ error } />;
}
return (
<SearchListControl
className="woocommerce-products"
list={ products.map( ( product ) => {
const formattedSku = product.sku
? ' (' + product.sku + ')'
: '';
return {
...product,
name: `${ decodeEntities(
product.name
) }${ formattedSku }`,
};
} ) }
isCompact={ isCompact }
isLoading={ isLoading }
selected={ products.filter( ( { id } ) =>
selected.includes( id )
) }
onSearch={ onSearch }
onChange={ onChange }
messages={ messages }
/>
);
};
ProductsControl.propTypes = {
onChange: PropTypes.func.isRequired,
onSearch: PropTypes.func,
selected: PropTypes.array,
products: PropTypes.array,
isCompact: PropTypes.bool,
isLoading: PropTypes.bool,
};
ProductsControl.defaultProps = {
selected: [],
products: [],
isCompact: false,
isLoading: true,
};
export default withSearchedProducts( ProductsControl );

View File

@@ -0,0 +1,2 @@
export * from './search-list-control';
export * from './item';

View File

@@ -0,0 +1,201 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { CheckboxControl } from '@wordpress/components';
import { useCallback } from '@wordpress/element';
import { arrayDifferenceBy, arrayUnionBy } from '@woocommerce/utils';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import type {
renderItemArgs,
SearchListItem as SearchListItemProps,
} from './types';
import { getHighlightedName, getBreadcrumbsForDisplay } from './utils';
const Count = ( { label }: { label: string | React.ReactNode | number } ) => {
return (
<span className="woocommerce-search-list__item-count">{ label }</span>
);
};
const ItemLabel = ( props: { item: SearchListItemProps; search: string } ) => {
const { item, search } = props;
const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length;
return (
<span className="woocommerce-search-list__item-label">
{ hasBreadcrumbs ? (
<span className="woocommerce-search-list__item-prefix">
{ getBreadcrumbsForDisplay( item.breadcrumbs ) }
</span>
) : null }
<span className="woocommerce-search-list__item-name">
{ getHighlightedName( decodeEntities( item.name ), search ) }
</span>
</span>
);
};
export const SearchListItem = < T extends object = object >( {
countLabel,
className,
depth = 0,
controlId = '',
item,
isSelected,
isSingle,
onSelect,
search = '',
selected,
useExpandedPanelId,
...props
}: renderItemArgs< T > ): JSX.Element => {
const [ expandedPanelId, setExpandedPanelId ] = useExpandedPanelId;
const showCount =
countLabel !== undefined &&
countLabel !== null &&
item.count !== undefined &&
item.count !== null;
const hasBreadcrumbs = !! item.breadcrumbs?.length;
const hasChildren = !! item.children?.length;
const isExpanded = expandedPanelId === item.id;
const classes = classNames(
[ 'woocommerce-search-list__item', `depth-${ depth }`, className ],
{
'has-breadcrumbs': hasBreadcrumbs,
'has-children': hasChildren,
'has-count': showCount,
'is-expanded': isExpanded,
'is-radio-button': isSingle,
}
);
const name = props.name || `search-list-item-${ controlId }`;
const id = `${ name }-${ item.id }`;
const togglePanel = useCallback( () => {
setExpandedPanelId( isExpanded ? -1 : Number( item.id ) );
}, [ isExpanded, item.id, setExpandedPanelId ] );
return hasChildren ? (
<div
className={ classes }
onClick={ togglePanel }
onKeyDown={ ( e ) =>
e.key === 'Enter' || e.key === ' ' ? togglePanel() : null
}
role="treeitem"
tabIndex={ 0 }
>
{ isSingle ? (
<>
<input
type="radio"
id={ id }
name={ name }
value={ item.value }
onChange={ onSelect( item ) }
onClick={ ( e ) => e.stopPropagation() }
checked={ isSelected }
className="woocommerce-search-list__item-input"
{ ...props }
/>
<ItemLabel item={ item } search={ search } />
{ showCount ? (
<Count label={ countLabel || item.count } />
) : null }
</>
) : (
<>
<CheckboxControl
className="woocommerce-search-list__item-input"
checked={ isSelected }
{ ...( ! isSelected &&
// We know that `item.children` is not `undefined` because
// we are here only if `hasChildren` is `true`.
( item.children as SearchListItemProps[] ).some(
( child ) =>
selected.find(
( selectedItem ) =>
selectedItem.id === child.id
)
)
? { indeterminate: true }
: {} ) }
label={ getHighlightedName(
decodeEntities( item.name ),
search
) }
onChange={ () => {
if ( isSelected ) {
onSelect(
arrayDifferenceBy(
selected,
item.children as SearchListItemProps[],
'id'
)
)();
} else {
onSelect(
arrayUnionBy(
selected,
item.children as SearchListItemProps[],
'id'
)
)();
}
} }
onClick={ ( e ) => e.stopPropagation() }
/>
{ showCount ? (
<Count label={ countLabel || item.count } />
) : null }
</>
) }
</div>
) : (
<label htmlFor={ id } className={ classes }>
{ isSingle ? (
<>
<input
{ ...props }
type="radio"
id={ id }
name={ name }
value={ item.value }
onChange={ onSelect( item ) }
checked={ isSelected }
className="woocommerce-search-list__item-input"
></input>
<ItemLabel item={ item } search={ search } />
</>
) : (
<CheckboxControl
{ ...props }
id={ id }
name={ name }
className="woocommerce-search-list__item-input"
value={ decodeEntities( item.value ) }
label={ getHighlightedName(
decodeEntities( item.name ),
search
) }
onChange={ onSelect( item ) }
checked={ isSelected }
/>
) }
{ showCount ? <Count label={ countLabel || item.count } /> : null }
</label>
);
};
export default SearchListItem;

View File

@@ -0,0 +1,357 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
Button,
FormTokenField,
Spinner,
TextControl,
withSpokenMessages,
} from '@wordpress/components';
import {
useState,
useMemo,
useEffect,
useCallback,
Fragment,
} from '@wordpress/element';
import { Icon, info } from '@wordpress/icons';
import classnames from 'classnames';
import { useInstanceId } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { getFilteredList, defaultMessages } from './utils';
import SearchListItem from './item';
import Tag from '../tag';
import type {
SearchListItem as SearchListItemProps,
SearchListControlProps,
SearchListMessages,
renderItemArgs,
ListItemsProps,
SearchListItemsContainerProps,
} from './types';
import './style.scss';
const defaultRenderListItem = ( args: renderItemArgs ): JSX.Element => {
return <SearchListItem { ...args } />;
};
const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
const {
list,
selected,
renderItem,
depth = 0,
onSelect,
instanceId,
isSingle,
search,
useExpandedPanelId,
} = props;
const [ expandedPanelId ] = useExpandedPanelId;
if ( ! list ) {
return null;
}
return (
<>
{ list.map( ( item ) => {
const isSelected =
item.children?.length && ! isSingle
? item.children.every( ( { id } ) =>
selected.find(
( selectedItem ) => selectedItem.id === id
)
)
: !! selected.find( ( { id } ) => id === item.id );
const isExpanded =
item.children?.length && expandedPanelId === item.id;
return (
<Fragment key={ item.id }>
<li>
{ renderItem( {
item,
isSelected,
onSelect,
isSingle,
selected,
search,
depth,
useExpandedPanelId,
controlId: instanceId,
} ) }
</li>
{ isExpanded ? (
<ListItems
{ ...props }
list={ item.children as SearchListItemProps[] }
depth={ depth + 1 }
/>
) : null }
</Fragment>
);
} ) }
</>
);
};
const SelectedListItems = < T extends object = object >( {
isLoading,
isSingle,
selected,
messages,
onChange,
onRemove,
}: SearchListControlProps< T > & {
messages: SearchListMessages;
onRemove: ( itemId: string | number ) => () => void;
} ) => {
if ( isLoading || isSingle || ! selected ) {
return null;
}
const selectedCount = selected.length;
return (
<div className="woocommerce-search-list__selected">
<div className="woocommerce-search-list__selected-header">
<strong>{ messages.selected( selectedCount ) }</strong>
{ selectedCount > 0 ? (
<Button
isLink
isDestructive
onClick={ () => onChange( [] ) }
aria-label={ messages.clear }
>
{ __( 'Clear all', 'woo-gutenberg-products-block' ) }
</Button>
) : null }
</div>
{ selectedCount > 0 ? (
<ul>
{ selected.map( ( item, i ) => (
<li key={ i }>
<Tag
label={ item.name }
id={ item.id }
remove={ onRemove }
/>
</li>
) ) }
</ul>
) : null }
</div>
);
};
const ListItemsContainer = < T extends object = object >( {
filteredList,
search,
onSelect,
instanceId,
useExpandedPanelId,
...props
}: SearchListItemsContainerProps< T > ) => {
const { messages, renderItem, selected, isSingle } = props;
const renderItemCallback = renderItem || defaultRenderListItem;
if ( filteredList.length === 0 ) {
return (
<div className="woocommerce-search-list__list is-not-found">
<span className="woocommerce-search-list__not-found-icon">
<Icon icon={ info } role="img" />
</span>
<span className="woocommerce-search-list__not-found-text">
{ search
? // eslint-disable-next-line @wordpress/valid-sprintf
sprintf( messages.noResults, search )
: messages.noItems }
</span>
</div>
);
}
return (
<ul className="woocommerce-search-list__list">
<ListItems
useExpandedPanelId={ useExpandedPanelId }
list={ filteredList }
selected={ selected }
renderItem={ renderItemCallback }
onSelect={ onSelect }
instanceId={ instanceId }
isSingle={ isSingle }
search={ search }
/>
</ul>
);
};
/**
* Component to display a searchable, selectable list of items.
*/
export const SearchListControl = < T extends object = object >(
props: SearchListControlProps< T >
) => {
const {
className = '',
isCompact,
isHierarchical,
isLoading,
isSingle,
list,
messages: customMessages = defaultMessages,
onChange,
onSearch,
selected,
type = 'text',
debouncedSpeak,
} = props;
const [ search, setSearch ] = useState( '' );
const useExpandedPanelId = useState< number >( -1 );
const instanceId = useInstanceId( SearchListControl );
const messages = useMemo(
() => ( { ...defaultMessages, ...customMessages } ),
[ customMessages ]
);
const filteredList = useMemo( () => {
return getFilteredList( list, search, isHierarchical );
}, [ list, search, isHierarchical ] );
useEffect( () => {
if ( debouncedSpeak ) {
debouncedSpeak( messages.updated );
}
}, [ debouncedSpeak, messages ] );
useEffect( () => {
if ( typeof onSearch === 'function' ) {
onSearch( search );
}
}, [ search, onSearch ] );
const onRemove = useCallback(
( itemId: string | number ) => () => {
if ( isSingle ) {
onChange( [] );
}
const i = selected.findIndex(
( { id: selectedId } ) => selectedId === itemId
);
onChange( [
...selected.slice( 0, i ),
...selected.slice( i + 1 ),
] );
},
[ isSingle, selected, onChange ]
);
const onSelect = useCallback(
( 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 ] );
}
},
[ isSingle, onRemove, onChange, selected ]
);
const onRemoveToken = useCallback(
( tokens: Array< SearchListItemProps & { value: string } > ) => {
const [ removedItem ] = selected.filter(
( item ) => ! tokens.find( ( token ) => item.id === token.id )
);
onRemove( removedItem.id )();
},
[ onRemove, selected ]
);
return (
<div
className={ classnames( 'woocommerce-search-list', className, {
'is-compact': isCompact,
'is-loading': isLoading,
'is-token': type === 'token',
} ) }
>
{ type === 'text' && (
<SelectedListItems
{ ...props }
onRemove={ onRemove }
messages={ messages }
/>
) }
<div className="woocommerce-search-list__search">
{ type === 'text' ? (
<TextControl
label={ messages.search }
type="search"
value={ search }
onChange={ ( value ) => setSearch( value ) }
/>
) : (
<FormTokenField
disabled={ isLoading }
label={ messages.search }
onChange={ onRemoveToken }
onInputChange={ ( value ) => setSearch( value ) }
suggestions={ [] }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because `__experimentalValidateInput` is not yet in the type definitions.
__experimentalValidateInput={ () => false }
value={
isLoading
? [
__(
'Loading…',
'woo-gutenberg-products-block'
),
]
: selected.map( ( token ) => ( {
...token,
value: token.name,
} ) )
}
__experimentalShowHowTo={ false }
/>
) }
</div>
{ isLoading ? (
<div className="woocommerce-search-list__list">
<Spinner />
</div>
) : (
<ListItemsContainer
{ ...props }
search={ search }
filteredList={ filteredList }
messages={ messages }
onSelect={ onSelect }
instanceId={ instanceId }
useExpandedPanelId={ useExpandedPanelId }
/>
) }
</div>
);
};
export default withSpokenMessages( SearchListControl );

View File

@@ -0,0 +1,302 @@
.woocommerce-search-list {
width: 100%;
padding: 0 0 $gap;
text-align: left;
&.is-compact {
.woocommerce-search-list__selected {
margin: 0 0 $gap;
padding: 0;
border-top: none;
// 54px is the height of 1 row of tags in the sidebar.
min-height: 54px;
}
.woocommerce-search-list__search {
margin: 0 0 $gap;
padding: 0;
border-top: none;
&.is-token {
margin-bottom: 0;
}
}
}
&.is-loading {
.woocommerce-search-list__list {
padding: $gap-small 0;
text-align: center;
border: none;
}
.components-form-token-field__remove-token {
// We use a placeholder “Loading…” text when loading passed
// as a value to the `FormTokenField`, so we hide the “X”.
display: none;
}
}
&.is-token {
.woocommerce-search-list__list {
border-top: 0;
}
.woocommerce-search-list__search {
margin-bottom: 0;
.components-form-token-field__input-container {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
}
}
.woocommerce-search-list__list {
// Here to increase specificity compared to default editor styles
// wrapping `ul`s and `ol`s.
padding-left: 0;
}
}
.woocommerce-search-list__selected {
margin: $gap 0;
padding: $gap 0 0;
// 76px is the height of 1 row of tags.
min-height: 76px;
border-top: 1px solid $gray-100;
.woocommerce-search-list__selected-header {
margin-bottom: $gap-smaller;
button {
margin-left: $gap-small;
}
}
.woocommerce-tag__text {
max-width: 13em;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
float: left;
}
}
}
.woocommerce-search-list__search {
margin: $gap 0;
padding: $gap 0 0;
border-top: 1px solid $gray-100;
.components-base-control__field {
margin-bottom: $gap;
}
}
.woocommerce-search-list__list {
border: 1px solid $gray-200;
margin: 0;
padding: 0;
list-style: none;
max-height: 17em;
overflow-x: hidden;
overflow-y: auto;
li {
margin-bottom: 0;
}
&.is-not-found {
padding: $gap-small 0;
text-align: center;
border: none;
.woocommerce-search-list__not-found-icon,
.woocommerce-search-list__not-found-text {
display: inline-block;
}
.woocommerce-search-list__not-found-icon {
margin-right: $gap;
.gridicon {
vertical-align: top;
margin-top: -1px;
}
}
}
.components-spinner {
float: none;
margin: 0 auto;
}
.components-menu-group__label {
@include visually-hidden;
}
> [role="menu"] {
border: 1px solid $gray-100;
border-bottom: none;
}
.woocommerce-search-list__item {
display: flex;
align-items: center;
margin-bottom: 0;
padding: $gap-small $gap;
background: $studio-white;
// !important to keep the border around on hover
border-bottom: 1px solid $gray-100;
color: $gray-700;
&:hover,
&:active,
&:focus {
background: $gray-100;
}
&:active,
&:focus {
box-shadow: none;
}
&.has-children {
cursor: pointer;
&::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="#{encode-color($gray-700)}" /></svg>');
background-position: center right;
background-repeat: no-repeat;
background-size: contain;
content: "";
height: $gap-large;
margin-left: $gap-smaller;
width: $gap-large;
}
&[disabled]::after {
background: none;
margin-left: 0;
width: auto;
}
&.is-expanded::after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="#{encode-color($gray-700)}" /></svg>');
}
}
.woocommerce-search-list__item-input {
margin: 0;
}
.woocommerce-search-list__item-input[type="radio"] {
margin-right: $gap-smaller;
}
.components-base-control__field {
margin: 0;
}
.woocommerce-search-list__item-label {
display: flex;
flex: 1;
}
&.depth-0 + .depth-1 {
// Hide the border on the preceding list item
margin-top: -1px;
}
&:not(.depth-0) {
border-bottom: 0 !important;
}
&:not(.depth-0) + .depth-0 {
border-top: 1px solid $gray-100;
}
// Anything deeper than 5 levels will use this fallback depth
&[class*="depth-"] .woocommerce-search-list__item-label::before {
margin-right: $gap-smallest;
content: str-repeat("", 5);
}
&.depth-0 .woocommerce-search-list__item-label::before {
margin-right: 0;
content: "";
}
@for $i from 1 to 5 {
&.depth-#{$i} {
padding-left: $gap * ( $i + 1 );
}
&.depth-#{$i} .woocommerce-search-list__item-label::before {
content: str-repeat("", $i);
}
}
.woocommerce-search-list__item-name {
display: inline-block;
}
.woocommerce-search-list__item-prefix {
display: none;
color: $gray-700;
}
&.is-searching,
&.is-skip-level {
.woocommerce-search-list__item-label {
// Un-flex the label, so the prefix (breadcrumbs) and name are aligned.
display: inline-block;
}
.woocommerce-search-list__item-prefix {
display: inline;
&::after {
margin-right: $gap-smallest;
content: " ";
}
}
}
&.is-searching {
.woocommerce-search-list__item-name {
color: $gray-900;
}
}
&.has-count {
> .components-menu-item__item {
width: 100%;
}
}
.woocommerce-search-list__item-count {
flex: 0 1 auto;
padding: math.div($gap-smallest, 2) $gap-smaller;
border: 1px solid $gray-100;
border-radius: 12px;
font-size: 0.8em;
line-height: 1.4;
margin-left: auto;
color: $gray-700;
background: $studio-white;
white-space: nowrap;
}
}
li:last-child .woocommerce-search-list__item {
border-bottom: none;
}
}

View File

@@ -0,0 +1,204 @@
/**
* Internal dependencies
*/
import { buildTermsTree } from '../utils';
const list = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 0 },
{ id: 3, name: 'Elderberry', parent: 2 },
{ id: 4, name: 'Guava', parent: 2 },
{ id: 5, name: 'Lychee', parent: 3 },
{ id: 6, name: 'Mulberry', parent: 0 },
{ id: 7, name: 'Tamarind', parent: 5 },
];
describe( 'buildTermsTree', () => {
test( 'should return an empty array on empty input', () => {
const tree = buildTermsTree( [] );
expect( tree ).toEqual( [] );
} );
test( 'should return a flat array when there are no parent relationships', () => {
const tree = buildTermsTree( [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 0 },
] );
expect( tree ).toEqual( [
{
id: 1,
name: 'Apricots',
parent: 0,
breadcrumbs: [],
children: [],
},
{
id: 2,
name: 'Clementine',
parent: 0,
breadcrumbs: [],
children: [],
},
] );
} );
test( 'should return a tree of items', () => {
const tree = buildTermsTree( list );
expect( tree ).toEqual( [
{
id: 1,
name: 'Apricots',
parent: 0,
breadcrumbs: [],
children: [],
},
{
id: 2,
name: 'Clementine',
parent: 0,
breadcrumbs: [],
children: [
{
id: 3,
name: 'Elderberry',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [
{
id: 5,
name: 'Lychee',
parent: 3,
breadcrumbs: [ 'Clementine', 'Elderberry' ],
children: [
{
id: 7,
name: 'Tamarind',
parent: 5,
breadcrumbs: [
'Clementine',
'Elderberry',
'Lychee',
],
children: [],
},
],
},
],
},
{
id: 4,
name: 'Guava',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [],
},
],
},
{
id: 6,
name: 'Mulberry',
parent: 0,
breadcrumbs: [],
children: [],
},
] );
} );
test( 'should return a tree of items, with orphan categories appended to the end', () => {
const filteredList = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 0 },
{ id: 4, name: 'Guava', parent: 2 },
{ id: 5, name: 'Lychee', parent: 3 },
{ id: 6, name: 'Mulberry', parent: 0 },
];
const tree = buildTermsTree( filteredList, list );
expect( tree ).toEqual( [
{
id: 1,
name: 'Apricots',
parent: 0,
breadcrumbs: [],
children: [],
},
{
id: 2,
name: 'Clementine',
parent: 0,
breadcrumbs: [],
children: [
{
id: 4,
name: 'Guava',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [],
},
],
},
{
id: 6,
name: 'Mulberry',
parent: 0,
breadcrumbs: [],
children: [],
},
{
id: 5,
name: 'Lychee',
parent: 3,
breadcrumbs: [ 'Clementine', 'Elderberry' ],
children: [],
},
] );
} );
test( 'should return a tree of items, with orphan categories appended to the end, with children of thier own', () => {
const filteredList = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 3, name: 'Elderberry', parent: 2 },
{ id: 4, name: 'Guava', parent: 2 },
{ id: 5, name: 'Lychee', parent: 3 },
{ id: 6, name: 'Mulberry', parent: 0 },
];
const tree = buildTermsTree( filteredList, list );
expect( tree ).toEqual( [
{
id: 1,
name: 'Apricots',
parent: 0,
breadcrumbs: [],
children: [],
},
{
id: 6,
name: 'Mulberry',
parent: 0,
breadcrumbs: [],
children: [],
},
{
id: 3,
name: 'Elderberry',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [
{
id: 5,
name: 'Lychee',
parent: 3,
breadcrumbs: [ 'Clementine', 'Elderberry' ],
children: [],
},
],
},
{
id: 4,
name: 'Guava',
parent: 2,
breadcrumbs: [ 'Clementine' ],
children: [],
},
] );
} );
} );

View File

@@ -0,0 +1,222 @@
/**
* External dependencies
*/
import { fireEvent, render } from '@testing-library/react';
/**
* Internal dependencies
*/
import { SearchListControl } from '../';
const noop = () => {};
const SELECTORS = {
listItems: '.woocommerce-search-list__list > li',
searchInput: '.components-text-control__input[type="search"]',
};
const list = [
{ id: 1, name: 'Apricots' },
{ id: 2, name: 'Clementine' },
{ id: 3, name: 'Elderberry' },
{ id: 4, name: 'Guava' },
{ id: 5, name: 'Lychee' },
{ id: 6, name: 'Mulberry' },
];
const hierarchicalList = [
{ id: 1, name: 'Apricots', parent: 0 },
{ id: 2, name: 'Clementine', parent: 1 },
{ id: 3, name: 'Elderberry', parent: 1 },
{ id: 4, name: 'Guava', parent: 3 },
{ id: 5, name: 'Lychee', parent: 0 },
{ id: 6, name: 'Mulberry', parent: 0 },
];
describe( 'SearchListControl', () => {
test( 'should render a search box and list of options', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box and list of options with a custom className', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
className="test-search"
list={ list }
selected={ [] }
onChange={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box, a list of options, and 1 selected item', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [ list[ 1 ] ] }
onChange={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box, a list of options, and 2 selected item', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [ list[ 1 ], list[ 3 ] ] }
onChange={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box and no options', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ [] }
selected={ [] }
onChange={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box with a search term, and only matching options', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
search="berry"
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box with a search term, and only matching options, regardless of case sensitivity', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
fireEvent.change(
component.container.querySelector( SELECTORS.searchInput ),
{ target: { value: 'BeRrY' } }
);
expect( component ).toMatchSnapshot();
const $listItems = component.container.querySelectorAll(
SELECTORS.listItems
);
expect( $listItems ).toHaveLength( 2 );
} );
// @see https://github.com/woocommerce/woocommerce-blocks/issues/6524
test( "should render search results in their original case regardless of user's input case", () => {
const EXPECTED = [ 'Elderberry', 'Mulberry' ];
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
fireEvent.change(
component.container.querySelector( SELECTORS.searchInput ),
{ target: { value: 'BeRrY' } }
);
const listItems = Array.from(
component.container.querySelectorAll( SELECTORS.listItems )
).map( ( $el ) => $el.textContent );
expect( listItems ).toEqual( expect.arrayContaining( EXPECTED ) );
} );
test( 'should render a search box with a search term, and no matching options', () => {
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
search="no matches"
selected={ [] }
onChange={ noop }
debouncedSpeak={ noop }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box and list of options, with a custom search input message', () => {
const messages = { search: 'Testing search label' };
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
messages={ messages }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box and list of options, with a custom render callback for each item', () => {
const renderItem = ( { item } ) => (
<div key={ item.id }>{ item.name }!</div>
); // eslint-disable-line
const component = render(
<SearchListControl
instanceId={ 1 }
list={ list }
selected={ [] }
onChange={ noop }
renderItem={ renderItem }
/>
);
expect( component ).toMatchSnapshot();
} );
test( 'should render a search box and list of hierarchical options', () => {
const component = render(
<SearchListControl
isCompact
isHierarchical
instanceId={ 1 }
isSingle={ false }
list={ hierarchicalList }
onChange={ noop }
selected={ [] }
type={ 'text' }
/>
);
expect( component ).toMatchSnapshot();
} );
} );

View File

@@ -0,0 +1,137 @@
/**
* External dependencies
*/
import type { InputHTMLAttributes, ReactNode } from 'react';
import { Require } from '@woocommerce/types';
interface ItemProps< T extends object = object > {
// Depth, non-zero if the list is hierarchical.
depth?: number;
// Callback for selecting the item.
onSelect: (
item: SearchListItem< T > | SearchListItem< T >[]
) => () => void;
// Search string, used to highlight the substring in the item name.
search: string;
useExpandedPanelId: [
number,
React.Dispatch< React.SetStateAction< number > >
];
}
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< T >[];
// Callback to render each item in the selection list, allows any custom object-type rendering.
renderItem?: ( args: renderItemArgs< T > ) => JSX.Element;
// The list of currently selected items.
selected: SearchListItem< T >[];
}
export interface ListItemsProps
extends Require< SearchListProps, 'renderItem' >,
ItemProps {
instanceId: string | number;
}
export type SearchListItem< T extends object = object > = {
breadcrumbs: string[];
children?: SearchListItem< T >[];
count?: number;
details?: T;
id: string | number;
name: string;
parent: number;
value: string;
};
export interface SearchListItemsContainerProps< T extends object = object >
extends SearchListControlProps,
ItemProps {
instanceId: string | number;
filteredList: SearchListItem< T >[];
messages: SearchListMessages;
}
export interface SearchListMessages {
// A more detailed label for the "Clear all" button, read to screen reader users.
clear: string;
// Message to display when the list is empty (implies nothing loaded from the server or parent component).
noItems: string;
// Message to display when no matching results are found. %s is the search term.
noResults: string;
// Label for the search input
search: string;
// Label for the selected items. This is actually a function, so that we can pass through the count of currently selected items.
selected: ( n: number ) => string;
// Label indicating that search results have changed, read to screen reader users.
updated: string;
}
export interface renderItemArgs< T extends object = object >
extends ItemProps,
Partial<
Omit<
InputHTMLAttributes< HTMLInputElement >,
'onChange' | 'onSelect'
>
> {
// Additional CSS classes.
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< 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< 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
* If not provided, a default name will be generated using the controlId.
*/
name?: string;
// Aria label for the input. If not provided, a default label will be generated using the item name.
ariaLabel?: string;
}
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.
isCompact: boolean;
// Whether the list of items is hierarchical or not. If true, each list item is expected to have a parent property.
isHierarchical?: boolean;
// Whether the list of items is still loading.
isLoading?: boolean;
//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< 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< 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< T > ) => JSX.Element | null )
| undefined;
// The list of currently selected items.
selected: SearchListItem< T >[];
// Whether to show a text field or a token field as search
// Defaults to `'text'`
type?: 'text' | 'token';
// from withSpokenMessages
debouncedSpeak?: ( message: string ) => void;
}

View File

@@ -0,0 +1,153 @@
/**
* External dependencies
*/
import { Fragment } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
import { keyBy } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import type { SearchListItem } from './types';
export const defaultMessages = {
clear: __( 'Clear all selected items', 'woo-gutenberg-products-block' ),
noItems: __( 'No items found.', 'woo-gutenberg-products-block' ),
/* Translators: %s search term */
noResults: __( 'No results for %s', 'woo-gutenberg-products-block' ),
search: __( 'Search for items', 'woo-gutenberg-products-block' ),
selected: ( n: number ): string =>
sprintf(
/* translators: Number of items selected from list. */
_n(
'%d item selected',
'%d items selected',
n,
'woo-gutenberg-products-block'
),
n
),
updated: __( 'Search results updated.', 'woo-gutenberg-products-block' ),
};
/**
* Returns terms in a tree form.
*
* @param {Array} filteredList Array of terms, possibly a subset of all terms, in flat format.
* @param {Array} list Array of the full list of terms, defaults to the filteredList.
*
* @return {Array} Array of terms in tree format.
*/
export const buildTermsTree = (
filteredList: SearchListItem[],
list = filteredList
): SearchListItem[] | [] => {
const termsByParent = filteredList.reduce( ( acc, currentValue ) => {
const key = currentValue.parent || 0;
if ( ! acc[ key ] ) {
acc[ key ] = [];
}
acc[ key ].push( currentValue );
return acc;
}, {} as Record< string, SearchListItem[] > );
const listById = keyBy( list, 'id' );
const builtParents = [ '0' ];
const getParentsName = ( term = {} as SearchListItem ): string[] => {
if ( ! term.parent ) {
return term.name ? [ term.name ] : [];
}
const parentName = getParentsName( listById[ term.parent ] );
return [ ...parentName, term.name ];
};
const fillWithChildren = ( terms: SearchListItem[] ): SearchListItem[] => {
return terms.map( ( term ) => {
const children = termsByParent[ term.id ];
builtParents.push( '' + term.id );
return {
...term,
breadcrumbs: getParentsName( listById[ term.parent ] ),
children:
children && children.length
? fillWithChildren( children )
: [],
};
} );
};
const tree = fillWithChildren( termsByParent[ '0' ] || [] );
// Handle remaining items in termsByParent that have not been built (orphaned).
Object.entries( termsByParent ).forEach( ( [ termId, terms ] ) => {
if ( ! builtParents.includes( termId ) ) {
tree.push( ...fillWithChildren( terms || [] ) );
}
} );
return tree;
};
export const getFilteredList = (
list: SearchListItem[],
search: string,
isHierarchical?: boolean | undefined
) => {
if ( ! search ) {
return isHierarchical ? buildTermsTree( list ) : list;
}
const re = new RegExp(
search.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ),
'i'
);
const filteredList = list
.map( ( item ) => ( re.test( item.name ) ? item : false ) )
.filter( Boolean ) as SearchListItem[];
return isHierarchical ? buildTermsTree( filteredList, list ) : filteredList;
};
export const getHighlightedName = (
name: string,
search: string
): ( JSX.Element | string )[] | string => {
if ( ! search ) {
return name;
}
const re = new RegExp(
// Escaping.
`(${ search.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ) })`,
'ig'
);
const nameParts = name.split( re );
return nameParts.map( ( part, i ) => {
return re.test( part ) ? (
<strong key={ i }>{ part }</strong>
) : (
<Fragment key={ i }>{ part }</Fragment>
);
} );
};
export const getBreadcrumbsForDisplay = ( breadcrumbs: string[] ): string => {
if ( breadcrumbs.length === 1 ) {
return breadcrumbs.slice( 0, 1 ).toString();
}
if ( breadcrumbs.length === 2 ) {
return (
breadcrumbs.slice( 0, 1 ).toString() +
' ' +
breadcrumbs.slice( -1 ).toString()
);
}
return (
breadcrumbs.slice( 0, 1 ).toString() +
' … ' +
breadcrumbs.slice( -1 ).toString()
);
};

View File

@@ -0,0 +1,17 @@
.wc-blocks-sidebar-compatibility-notice.is-dismissible {
margin: 0;
padding-right: 16px;
.components-notice__dismiss {
min-width: 24px;
}
.components-notice__content {
margin: 4px 0;
}
svg {
width: 16px;
height: 16px;
}
&.is-hidden {
display: none;
}
}

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { Notice, ExternalLink } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import './editor.scss';
import { useCompatibilityNotice } from './use-compatibility-notice';
export const CartCheckoutSidebarCompatibilityNotice = ( {
block,
}: {
block: 'cart' | 'checkout';
} ) => {
const [ isVisible, dismissNotice ] = useCompatibilityNotice( block );
const noticeText = createInterpolateElement(
__(
'The Cart & Checkout Blocks are built to optimize for faster checkout. To make sure this feature is right for your store, <a>review the list of compatible extensions</a>.',
'woo-gutenberg-products-block'
),
{
a: (
// Suppress the warning as this <a> will be interpolated into the string with content.
// eslint-disable-next-line jsx-a11y/anchor-has-content
<ExternalLink href="https://woocommerce.com/document/cart-checkout-blocks-support-status/#section-3" />
),
}
);
return (
<Notice
onRemove={ dismissNotice }
className={ classnames( [
'wc-blocks-sidebar-compatibility-notice',
{ 'is-hidden': ! isVisible },
] ) }
>
{ noticeText }
</Notice>
);
};

View File

@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import { useLocalStorageState } from '@woocommerce/base-hooks';
const initialDismissedNotices: string[] = [];
export const useCompatibilityNotice = (
blockName: string
): [ boolean, () => void ] => {
const [ dismissedNotices, setDismissedNotices ] = useLocalStorageState(
`wc-blocks_dismissed_sidebar_compatibility_notices`,
initialDismissedNotices
);
const [ isVisible, setIsVisible ] = useState( false );
const isDismissed = dismissedNotices.includes( blockName );
const dismissNotice = () => {
const dismissedNoticesSet = new Set( dismissedNotices );
dismissedNoticesSet.add( blockName );
setDismissedNotices( [ ...dismissedNoticesSet ] );
};
// This ensures the modal is not loaded on first render. This is required so
// Gutenberg doesn't steal the focus from the Guide and focuses the block.
useEffect( () => {
setIsVisible( ! isDismissed );
}, [ isDismissed ] );
return [ isVisible, dismissNotice ];
};

View File

@@ -0,0 +1,43 @@
.woocommerce-tag {
display: inline-flex;
margin: 1px 4px 1px 0;
overflow: hidden;
vertical-align: middle;
.woocommerce-tag__text,
.woocommerce-tag__remove {
display: inline-block;
line-height: 24px;
background: $gray-100;
transition: all 0.2s cubic-bezier(0.4, 1, 0.4, 1);
}
.woocommerce-tag__text {
align-self: center;
padding: 0 $gap-smaller;
border-radius: 12px;
color: $gray-700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.has-remove .woocommerce-tag__text {
padding: 0 $gap-smallest 0 $gap-smaller;
border-radius: 12px 0 0 12px;
}
.woocommerce-tag__remove {
cursor: pointer;
padding: 0 2px;
border-radius: 0 12px 12px 0;
color: $gray-700;
line-height: 10px;
text-indent: 0;
height: 24px;
&:hover {
color: $gray-900;
}
}
}

View File

@@ -0,0 +1,104 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { Button, Popover } from '@wordpress/components';
import { Icon, cancelCircleFilled } from '@wordpress/icons';
import { decodeEntities } from '@wordpress/html-entities';
import { useInstanceId } from '@wordpress/compose';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import './editor.scss';
/**
* This component can be used to show an item styled as a "tag", optionally with an `X` + "remove"
* or with a popover that is shown on click.
*/
const Tag = ( {
id,
label,
popoverContents,
remove,
screenReaderLabel,
className = '',
}: {
// Additional CSS classes.
className?: string;
// The ID for this item, used in the remove function.
id: string | number;
// The name for this item, displayed as the tag's text.
label: string;
// Contents to display on click in a popover
popoverContents?: JSX.Element;
// A function called when the remove X is clicked. If not used, no X icon will display.
remove?: ( id: string | number ) => () => void;
// A more descriptive label for screen reader users. Defaults to the `name` prop.
screenReaderLabel?: string;
} ): JSX.Element | null => {
const [ isVisible, setIsVisible ] = useState( false );
const instanceId = useInstanceId( Tag );
screenReaderLabel = screenReaderLabel || label;
if ( ! label ) {
// A null label probably means something went wrong
return null;
}
label = decodeEntities( label );
const classes = classnames( 'woocommerce-tag', className, {
'has-remove': !! remove,
} );
const labelId = `woocommerce-tag__label-${ instanceId }`;
const labelTextNode = (
<>
<span className="screen-reader-text">{ screenReaderLabel }</span>
<span aria-hidden="true">{ label }</span>
</>
);
return (
<span className={ classes }>
{ popoverContents ? (
<Button
className="woocommerce-tag__text"
id={ labelId }
onClick={ () => setIsVisible( true ) }
>
{ labelTextNode }
</Button>
) : (
<span className="woocommerce-tag__text" id={ labelId }>
{ labelTextNode }
</span>
) }
{ popoverContents && isVisible && (
<Popover onClose={ () => setIsVisible( false ) }>
{ popoverContents }
</Popover>
) }
{ remove && (
<Button
className="woocommerce-tag__remove"
onClick={ remove( id ) }
label={ sprintf(
// Translators: %s label.
__( 'Remove %s', 'woo-gutenberg-products-block' ),
label
) }
aria-describedby={ labelId }
>
<Icon
icon={ cancelCircleFilled }
size={ 20 }
className="clear-icon"
role="img"
/>
</Button>
) }
</span>
);
};
export default Tag;

View File

@@ -0,0 +1,545 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Tag <Tag label="foo" /> should render a tag with the label foo 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<p
class="a11y-speak-intro-text"
hidden="hidden"
id="a11y-speak-intro-text"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
>
Notifications
</p>
<div
aria-atomic="true"
aria-live="assertive"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-assertive"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div
aria-atomic="true"
aria-live="polite"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-polite"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div>
<span
class="woocommerce-tag"
>
<span
class="woocommerce-tag__text"
id="woocommerce-tag__label-0"
>
<span
class="screen-reader-text"
>
foo
</span>
<span
aria-hidden="true"
>
foo
</span>
</span>
</span>
</div>
</body>,
"container": <div>
<span
class="woocommerce-tag"
>
<span
class="woocommerce-tag__text"
id="woocommerce-tag__label-0"
>
<span
class="screen-reader-text"
>
foo
</span>
<span
aria-hidden="true"
>
foo
</span>
</span>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`Tag <Tag label="foo" popoverContents={ <p>This is a popover</p> } /> should render a tag with a popover 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<p
class="a11y-speak-intro-text"
hidden="hidden"
id="a11y-speak-intro-text"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
>
Notifications
</p>
<div
aria-atomic="true"
aria-live="assertive"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-assertive"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div
aria-atomic="true"
aria-live="polite"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-polite"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div>
<span
class="woocommerce-tag"
>
<button
class="components-button woocommerce-tag__text"
id="woocommerce-tag__label-2"
type="button"
>
<span
class="screen-reader-text"
>
foo
</span>
<span
aria-hidden="true"
>
foo
</span>
</button>
</span>
</div>
</body>,
"container": <div>
<span
class="woocommerce-tag"
>
<button
class="components-button woocommerce-tag__text"
id="woocommerce-tag__label-2"
type="button"
>
<span
class="screen-reader-text"
>
foo
</span>
<span
aria-hidden="true"
>
foo
</span>
</button>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`Tag <Tag label="foo" remove={ noop } /> should render a tag with a close button 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<p
class="a11y-speak-intro-text"
hidden="hidden"
id="a11y-speak-intro-text"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
>
Notifications
</p>
<div
aria-atomic="true"
aria-live="assertive"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-assertive"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div
aria-atomic="true"
aria-live="polite"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-polite"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div>
<span
class="woocommerce-tag has-remove"
>
<span
class="woocommerce-tag__text"
id="woocommerce-tag__label-1"
>
<span
class="screen-reader-text"
>
foo
</span>
<span
aria-hidden="true"
>
foo
</span>
</span>
<button
aria-describedby="woocommerce-tag__label-1"
aria-label="Remove foo"
class="components-button woocommerce-tag__remove"
type="button"
>
<svg
aria-hidden="true"
class="clear-icon"
focusable="false"
height="20"
role="img"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z"
/>
</svg>
</button>
</span>
</div>
</body>,
"container": <div>
<span
class="woocommerce-tag has-remove"
>
<span
class="woocommerce-tag__text"
id="woocommerce-tag__label-1"
>
<span
class="screen-reader-text"
>
foo
</span>
<span
aria-hidden="true"
>
foo
</span>
</span>
<button
aria-describedby="woocommerce-tag__label-1"
aria-label="Remove foo"
class="components-button woocommerce-tag__remove"
type="button"
>
<svg
aria-hidden="true"
class="clear-icon"
focusable="false"
height="20"
role="img"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z"
/>
</svg>
</button>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
exports[`Tag <Tag label="foo" screenReaderLabel="FooBar" /> should render a tag with a screen reader label 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<p
class="a11y-speak-intro-text"
hidden="hidden"
id="a11y-speak-intro-text"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
>
Notifications
</p>
<div
aria-atomic="true"
aria-live="assertive"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-assertive"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div
aria-atomic="true"
aria-live="polite"
aria-relevant="additions text"
class="a11y-speak-region"
id="a11y-speak-polite"
style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;"
/>
<div>
<span
class="woocommerce-tag"
>
<span
class="woocommerce-tag__text"
id="woocommerce-tag__label-3"
>
<span
class="screen-reader-text"
>
FooBar
</span>
<span
aria-hidden="true"
>
foo
</span>
</span>
</span>
</div>
</body>,
"container": <div>
<span
class="woocommerce-tag"
>
<span
class="woocommerce-tag__text"
id="woocommerce-tag__label-3"
>
<span
class="screen-reader-text"
>
FooBar
</span>
<span
aria-hidden="true"
>
foo
</span>
</span>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import Tag from '../';
const noop = () => {};
describe( 'Tag', () => {
test( '<Tag label="foo" /> should render a tag with the label foo', () => {
const component = render( <Tag label="foo" /> );
expect( component ).toMatchSnapshot();
} );
test( '<Tag label="foo" remove={ noop } /> should render a tag with a close button', () => {
const component = render( <Tag label="foo" remove={ noop } /> );
expect( component ).toMatchSnapshot();
} );
test( '<Tag label="foo" popoverContents={ <p>This is a popover</p> } /> should render a tag with a popover', () => {
const component = render(
<Tag label="foo" popoverContents={ <p>This is a popover</p> } />
);
expect( component ).toMatchSnapshot();
} );
test( '<Tag label="foo" screenReaderLabel="FooBar" /> should render a tag with a screen reader label', () => {
const component = render(
<Tag label="foo" screenReaderLabel="FooBar" />
);
expect( component ).toMatchSnapshot();
} );
} );

View File

@@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import './style.scss';
function TextToolbarButton( { className = '', ...props } ) {
const classes = classnames( 'wc-block-text-toolbar-button', className );
return <Button className={ classes } { ...props } />;
}
export default TextToolbarButton;

View File

@@ -0,0 +1,13 @@
.wc-block-text-toolbar-button {
align-items: center;
&.is-toggled,
&.is-toggled:focus {
background: $gray-700;
color: $white;
}
}
.block-editor-block-toolbar__slot {
// prevents text toolbar items shrinking to avoid other buttons overlapping.
flex-shrink: 0;
}

View File

@@ -0,0 +1,231 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { getSetting } from '@woocommerce/settings';
import { blocksConfig } from '@woocommerce/block-settings';
/**
* Get product query requests for the Store API.
*
* @param {Object} request A query object with the list of selected products and search term.
* @param {number[]} request.selected Currently selected products.
* @param {string=} request.search Search string.
* @param {(Record<string, unknown>)=} request.queryArgs Query args to pass in.
*/
const getProductsRequests = ( {
selected = [],
search = '',
queryArgs = {},
} ) => {
const isLargeCatalog = blocksConfig.productCount > 100;
const defaultArgs = {
per_page: isLargeCatalog ? 100 : 0,
catalog_visibility: 'any',
search,
orderby: 'title',
order: 'asc',
};
const requests = [
addQueryArgs( '/wc/store/v1/products', {
...defaultArgs,
...queryArgs,
} ),
];
// If we have a large catalog, we might not get all selected products in the first page.
if ( isLargeCatalog && selected.length ) {
requests.push(
addQueryArgs( '/wc/store/v1/products', {
catalog_visibility: 'any',
include: selected,
per_page: 0,
} )
);
}
return requests;
};
const uniqBy = ( array, iteratee ) => {
const seen = new Map();
return array.filter( ( item ) => {
const key = iteratee( item );
if ( ! seen.has( key ) ) {
seen.set( key, item );
return true;
}
return false;
} );
};
/**
* Get a promise that resolves to a list of products from the Store API.
*
* @param {Object} request A query object with the list of selected products and search term.
* @param {number[]} request.selected Currently selected products.
* @param {string=} request.search Search string.
* @param {(Record<string, unknown>)=} request.queryArgs Query args to pass in.
* @return {Promise<unknown>} Promise resolving to a Product list.
* @throws Exception if there is an error.
*/
export const getProducts = ( {
selected = [],
search = '',
queryArgs = {},
} ) => {
const requests = getProductsRequests( { selected, search, queryArgs } );
return Promise.all( requests.map( ( path ) => apiFetch( { path } ) ) )
.then( ( data ) => {
const flatData = data.flat();
const products = uniqBy( flatData, ( item ) => item.id );
const list = products.map( ( product ) => ( {
...product,
parent: 0,
} ) );
return list;
} )
.catch( ( e ) => {
throw e;
} );
};
/**
* Get a promise that resolves to a product object from the Store API.
*
* @param {number} productId Id of the product to retrieve.
*/
export const getProduct = ( productId ) => {
return apiFetch( {
path: `/wc/store/v1/products/${ productId }`,
} );
};
/**
* Get a promise that resolves to a list of attribute objects from the Store API.
*/
export const getAttributes = () => {
return apiFetch( {
path: `wc/store/v1/products/attributes`,
} );
};
/**
* Get a promise that resolves to a list of attribute term objects from the Store API.
*
* @param {number} attribute Id of the attribute to retrieve terms for.
*/
export const getTerms = ( attribute ) => {
return apiFetch( {
path: `wc/store/v1/products/attributes/${ attribute }/terms`,
} );
};
/**
* Get product tag query requests for the Store API.
*
* @param {Object} request A query object with the list of selected products and search term.
* @param {Array} request.selected Currently selected tags.
* @param {string} request.search Search string.
*/
const getProductTagsRequests = ( { selected = [], search } ) => {
const limitTags = getSetting( 'limitTags', false );
const requests = [
addQueryArgs( `wc/store/v1/products/tags`, {
per_page: limitTags ? 100 : 0,
orderby: limitTags ? 'count' : 'name',
order: limitTags ? 'desc' : 'asc',
search,
} ),
];
// If we have a large catalog, we might not get all selected products in the first page.
if ( limitTags && selected.length ) {
requests.push(
addQueryArgs( `wc/store/v1/products/tags`, {
include: selected,
} )
);
}
return requests;
};
/**
* Get a promise that resolves to a list of tags from the Store API.
*
* @param {Object} props A query object with the list of selected products and search term.
* @param {Array} props.selected
* @param {string} props.search
*/
export const getProductTags = ( { selected = [], search } ) => {
const requests = getProductTagsRequests( { selected, search } );
return Promise.all( requests.map( ( path ) => apiFetch( { path } ) ) ).then(
( data ) => {
const flatData = data.flat();
return uniqBy( flatData, ( item ) => item.id );
}
);
};
/**
* Get a promise that resolves to a list of category objects from the Store API.
*
* @param {Object} queryArgs Query args to pass in.
*/
export const getCategories = ( queryArgs ) => {
return apiFetch( {
path: addQueryArgs( `wc/store/v1/products/categories`, {
per_page: 0,
...queryArgs,
} ),
} );
};
/**
* Get a promise that resolves to a category object from the API.
*
* @param {number} categoryId Id of the product to retrieve.
*/
export const getCategory = ( categoryId ) => {
return apiFetch( {
path: `wc/store/v1/products/categories/${ categoryId }`,
} );
};
/**
* Get a promise that resolves to a list of variation objects from the Store API.
*
* @param {number} product Product ID.
*/
export const getProductVariations = ( product ) => {
return apiFetch( {
path: addQueryArgs( `wc/store/v1/products`, {
per_page: 0,
type: 'variation',
parent: product,
} ),
} );
};
/**
* Given a page object and an array of page, format the title.
*
* @param {Object} page Page object.
* @param {Object} page.title Page title object.
* @param {string} page.title.raw Page title.
* @param {string} page.slug Page slug.
* @param {Array} pages Array of all pages.
* @return {string} Formatted page title to display.
*/
export const formatTitle = ( page, pages ) => {
if ( ! page.title.raw ) {
return page.slug;
}
const isUnique =
pages.filter( ( p ) => p.title.raw === page.title.raw ).length === 1;
return page.title.raw + ( ! isUnique ? ` - ${ page.slug }` : '' );
};