initial commit
This commit is contained in:
@ -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;
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { PlainText } from '@wordpress/block-editor';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './editor.scss';
|
||||
|
||||
const BlockTitle = ( {
|
||||
className,
|
||||
headingLevel,
|
||||
onChange,
|
||||
heading,
|
||||
instanceId,
|
||||
} ) => {
|
||||
const TagName = `h${ headingLevel }`;
|
||||
return (
|
||||
<TagName className={ className }>
|
||||
<label
|
||||
className="screen-reader-text"
|
||||
htmlFor={ `block-title-${ instanceId }` }
|
||||
>
|
||||
{ __( 'Block title', 'woocommerce' ) }
|
||||
</label>
|
||||
<PlainText
|
||||
id={ `block-title-${ instanceId }` }
|
||||
className="wc-block-editor-components-title"
|
||||
value={ heading }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
</TagName>
|
||||
);
|
||||
};
|
||||
|
||||
BlockTitle.propTypes = {
|
||||
/**
|
||||
* Classname to add to title in addition to the defaults.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* The value of the heading.
|
||||
*/
|
||||
value: PropTypes.string,
|
||||
/**
|
||||
* Callback to update the attribute when text is changed.
|
||||
*/
|
||||
onChange: PropTypes.func,
|
||||
/**
|
||||
* Level of the heading tag (1, 2, 3... will render <h1>, <h2>, <h3>... elements).
|
||||
*/
|
||||
headingLevel: PropTypes.number,
|
||||
};
|
||||
|
||||
export default withInstanceId( BlockTitle );
|
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Guide } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { createInterpolateElement } from '@wordpress/element';
|
||||
import { isWpVersion } from '@woocommerce/settings';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useCompatibilityNotice } from './use-compatibility-notice';
|
||||
import WooImage from './woo-image';
|
||||
|
||||
interface CartCheckoutCompatibilityNoticeProps {
|
||||
blockName: 'cart' | 'checkout';
|
||||
}
|
||||
|
||||
export function CartCheckoutCompatibilityNotice( {
|
||||
blockName,
|
||||
}: CartCheckoutCompatibilityNoticeProps ): ReactElement | null {
|
||||
const [ isVisible, dismissNotice ] = useCompatibilityNotice( blockName );
|
||||
|
||||
if ( isWpVersion( '5.4', '<=' ) || ! isVisible ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Guide
|
||||
className="edit-post-welcome-guide"
|
||||
contentLabel={ __(
|
||||
'Compatibility notice',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
onFinish={ () => dismissNotice() }
|
||||
finishButtonText={ __( 'Got it!', 'woo-gutenberg-products-block' ) }
|
||||
pages={ [
|
||||
{
|
||||
image: <WooImage />,
|
||||
content: (
|
||||
<>
|
||||
<h1 className="edit-post-welcome-guide__heading">
|
||||
{ __(
|
||||
'Compatibility notice',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</h1>
|
||||
<p className="edit-post-welcome-guide__text">
|
||||
{ createInterpolateElement(
|
||||
__(
|
||||
'This block may not be compatible with <em>all</em> checkout extensions and integrations.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
{
|
||||
em: <em />,
|
||||
}
|
||||
) }
|
||||
</p>
|
||||
<p className="edit-post-welcome-guide__text">
|
||||
{ createInterpolateElement(
|
||||
__(
|
||||
'We recommend reviewing our <a>expanding list</a> of compatible extensions prior to using this block on a live store.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
{
|
||||
a: (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content
|
||||
<a
|
||||
href="https://docs.woocommerce.com/document/cart-checkout-blocks-support-status/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}
|
||||
) }
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
] }
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './cart-checkout-compatibility-notice';
|
@ -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_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 ];
|
||||
};
|
@ -0,0 +1,132 @@
|
||||
const WooImage = ( props ) => (
|
||||
<div
|
||||
className="edit-post-welcome-guide__image edit-post-welcome-guide__image__prm-np"
|
||||
style={ {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
} }
|
||||
{ ...props }
|
||||
>
|
||||
<svg
|
||||
height="120"
|
||||
viewBox="0 0 170 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_ddd)">
|
||||
<rect
|
||||
x="5.5"
|
||||
y="18"
|
||||
width="159"
|
||||
height="96"
|
||||
rx="3"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="24.5"
|
||||
y="4"
|
||||
width="51"
|
||||
height="22"
|
||||
rx="3"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="94.5"
|
||||
y="4"
|
||||
width="51"
|
||||
height="22"
|
||||
rx="3"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M48.8908 42H125.078C129.899 42 133.802 45.9231 133.802 50.7692V80C133.802 84.8462 129.899 88.7692 125.078 88.7692H97.756L101.506 98L85.0135 88.7692H48.929C44.1076 88.7692 40.2045 84.8462 40.2045 80V50.7692C40.1662 45.9615 44.0693 42 48.8908 42Z"
|
||||
fill="#7F54B3"
|
||||
/>
|
||||
<path
|
||||
d="M44.8709 50.723C45.4039 50.0175 46.2033 49.6462 47.2693 49.5719C49.2108 49.4234 50.3149 50.3145 50.5814 52.2453C51.7615 60.0056 53.0559 66.5778 54.4264 71.9617L62.7637 56.4782C63.5251 55.0673 64.4768 54.3246 65.6189 54.2504C67.294 54.139 68.3219 55.1786 68.7406 57.3694C69.6924 62.3077 70.9106 66.5035 72.3573 70.0681C73.3471 60.6369 75.0222 53.8419 77.3825 49.6462C77.9535 48.6065 78.7911 48.0867 79.8951 48.0124C80.7707 47.9382 81.5702 48.1981 82.2935 48.755C83.0168 49.312 83.3975 50.0175 83.4736 50.8715C83.5117 51.5398 83.3975 52.0968 83.0929 52.6538C81.6082 55.3272 80.39 59.82 79.4002 66.0579C78.4484 72.1102 78.1058 76.8258 78.3342 80.2047C78.4104 81.133 78.2581 81.9499 77.8774 82.6553C77.4205 83.4722 76.7353 83.9178 75.8597 83.9921C74.8699 84.0663 73.842 83.6207 72.8522 82.6182C69.3117 79.0908 66.4945 73.8183 64.4388 66.8006C61.9642 71.5533 60.1369 75.1178 58.9567 77.4942C56.7106 81.69 54.8071 83.8435 53.2082 83.9549C52.1803 84.0292 51.3047 83.1752 50.5433 81.3929C48.6017 76.5288 46.5079 67.1347 44.2618 53.2107C44.1476 52.2453 44.3379 51.3913 44.8709 50.723Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M126.922 56.5426C125.536 54.1968 123.495 52.7819 120.761 52.2234C120.029 52.0745 119.336 52 118.681 52C114.985 52 111.981 53.8617 109.632 57.5851C107.63 60.75 106.629 64.25 106.629 68.0851C106.629 70.9521 107.245 73.4096 108.477 75.4574C109.863 77.8032 111.904 79.2181 114.638 79.7766C115.37 79.9255 116.063 80 116.717 80C120.453 80 123.456 78.1383 125.767 74.4149C127.769 71.2128 128.77 67.7128 128.77 63.8777C128.77 60.9734 128.154 58.5532 126.922 56.5426ZM122.07 66.8564C121.531 69.3138 120.568 71.1383 119.143 72.367C118.027 73.3351 116.987 73.7447 116.024 73.5585C115.1 73.3723 114.33 72.5904 113.752 71.1383C113.29 69.984 113.059 68.8298 113.059 67.75C113.059 66.8192 113.136 65.8883 113.329 65.0319C113.675 63.5053 114.33 62.016 115.37 60.6011C116.64 58.7766 117.988 58.0319 119.374 58.2925C120.299 58.4787 121.069 59.2606 121.646 60.7128C122.108 61.867 122.339 63.0213 122.339 64.1011C122.339 65.0691 122.224 66 122.07 66.8564Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M102.767 56.5426C101.381 54.1968 99.3018 52.7819 96.6063 52.2234C95.8747 52.0745 95.1816 52 94.5269 52C90.8303 52 87.8267 53.8617 85.4778 57.5851C83.4755 60.75 82.4743 64.25 82.4743 68.0851C82.4743 70.9521 83.0904 73.4096 84.3226 75.4574C85.7089 77.8032 87.7497 79.2181 90.4837 79.7766C91.2153 79.9255 91.9085 80 92.5631 80C96.2983 80 99.3018 78.1383 101.612 74.4149C103.615 71.2128 104.616 67.7128 104.616 63.8777C104.616 60.9734 104 58.5532 102.767 56.5426ZM97.9155 66.8564C97.3765 69.3138 96.4138 71.1383 94.989 72.367C93.8723 73.3351 92.8326 73.7447 91.87 73.5585C90.9458 73.3723 90.1757 72.5904 89.5981 71.1383C89.136 69.984 88.9049 68.8298 88.9049 67.75C88.9049 66.8192 88.9819 65.8883 89.1745 65.0319C89.521 63.5053 90.1757 62.016 91.2153 60.6011C92.4861 58.7766 93.8338 58.0319 95.2201 58.2925C96.1442 58.4787 96.9144 59.2606 97.492 60.7128C97.9541 61.867 98.1851 63.0213 98.1851 64.1011C98.1851 65.0691 98.1081 66 97.9155 66.8564Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_ddd"
|
||||
x="0.5"
|
||||
y="0"
|
||||
width="169"
|
||||
height="120"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
/>
|
||||
<feOffset dy="1" />
|
||||
<feGaussianBlur stdDeviation="1.5" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
/>
|
||||
<feOffset dy="1" />
|
||||
<feGaussianBlur stdDeviation="2.5" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect1_dropShadow"
|
||||
result="effect2_dropShadow"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
/>
|
||||
<feOffset dy="2" />
|
||||
<feGaussianBlur stdDeviation="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect2_dropShadow"
|
||||
result="effect3_dropShadow"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect3_dropShadow"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default WooImage;
|
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Icon, external } from '@woocommerce/icons';
|
||||
import { ADMIN_URL } from '@woocommerce/settings';
|
||||
import { InspectorControls } from '@wordpress/block-editor';
|
||||
import { useProductDataContext } from '@woocommerce/shared-context';
|
||||
|
||||
/**
|
||||
* Component to render an edit product link in the sidebar.
|
||||
*
|
||||
* @param {Object} props Component props.
|
||||
*/
|
||||
const EditProductLink = ( props ) => {
|
||||
const productDataContext = useProductDataContext();
|
||||
const product = productDataContext.product || {};
|
||||
const productId = product.id || props.productId || 0;
|
||||
|
||||
if ( ! productId ) {
|
||||
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",
|
||||
'woocommerce'
|
||||
) }
|
||||
<Icon srcElement={ external } size={ 16 } />
|
||||
</a>
|
||||
</div>
|
||||
<div className="wc-block-single-product__edit-card-description">
|
||||
{ __(
|
||||
'Edit details such as title, price, description and more.',
|
||||
'woocommerce'
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
</InspectorControls>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProductLink;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { escapeHTML } from '@wordpress/escape-html';
|
||||
|
||||
const getErrorMessage = ( { message, type } ) => {
|
||||
if ( ! message ) {
|
||||
return __(
|
||||
'An unknown error occurred which prevented the block from being updated.',
|
||||
'woocommerce'
|
||||
);
|
||||
}
|
||||
|
||||
if ( type === 'general' ) {
|
||||
return (
|
||||
<span>
|
||||
{ __(
|
||||
'The following error was returned',
|
||||
'woocommerce'
|
||||
) }
|
||||
<br />
|
||||
<code>{ escapeHTML( message ) }</code>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if ( type === 'api' ) {
|
||||
return (
|
||||
<span>
|
||||
{ __(
|
||||
'The following error was returned from the API',
|
||||
'woocommerce'
|
||||
) }
|
||||
<br />
|
||||
<code>{ escapeHTML( message ) }</code>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const ErrorMessage = ( { error } ) => (
|
||||
<div className="wc-block-error-message">{ getErrorMessage( error ) }</div>
|
||||
);
|
||||
|
||||
ErrorMessage.propTypes = {
|
||||
/**
|
||||
* The error object.
|
||||
*/
|
||||
error: PropTypes.shape( {
|
||||
/**
|
||||
* Human-readable error message to display.
|
||||
*/
|
||||
message: PropTypes.node,
|
||||
/**
|
||||
* Context in which the error was triggered. That will determine how the error is displayed to the user.
|
||||
*/
|
||||
type: PropTypes.oneOf( [ 'api', 'general' ] ),
|
||||
} ),
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon, notice } from '@woocommerce/icons';
|
||||
import classNames from 'classnames';
|
||||
import { Button, Placeholder, Spinner } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ErrorMessage from './error-message.js';
|
||||
import './editor.scss';
|
||||
|
||||
const ErrorPlaceholder = ( { className, error, isLoading, onRetry } ) => (
|
||||
<Placeholder
|
||||
icon={ <Icon srcElement={ notice } /> }
|
||||
label={ __(
|
||||
'Sorry, an error occurred',
|
||||
'woocommerce'
|
||||
) }
|
||||
className={ classNames( 'wc-block-api-error', className ) }
|
||||
>
|
||||
<ErrorMessage error={ error } />
|
||||
{ onRetry && (
|
||||
<>
|
||||
{ isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Button isSecondary onClick={ onRetry }>
|
||||
{ __( 'Retry', 'woocommerce' ) }
|
||||
</Button>
|
||||
) }
|
||||
</>
|
||||
) }
|
||||
</Placeholder>
|
||||
);
|
||||
|
||||
ErrorPlaceholder.propTypes = {
|
||||
/**
|
||||
* Classname to add to placeholder in addition to the defaults.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* The error object.
|
||||
*/
|
||||
error: PropTypes.shape( {
|
||||
/**
|
||||
* Human-readable error message to display.
|
||||
*/
|
||||
message: PropTypes.node,
|
||||
/**
|
||||
* Context in which the error was triggered. That will determine how the error is displayed to the user.
|
||||
*/
|
||||
type: PropTypes.oneOf( [ 'api', 'general' ] ),
|
||||
} ),
|
||||
/**
|
||||
* Whether there is a request running, so the 'Retry' button is hidden and
|
||||
* a spinner is shown instead.
|
||||
*/
|
||||
isLoading: PropTypes.bool,
|
||||
/**
|
||||
* Callback to retry an action.
|
||||
*/
|
||||
onRetry: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ErrorPlaceholder;
|
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ErrorPlaceholder from '../';
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Blocks/editor-components/ErrorPlaceholder',
|
||||
component: ErrorPlaceholder,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<ErrorPlaceholder
|
||||
error={ {
|
||||
message:
|
||||
'Unfortunately, we seem to have encountered a slight problem',
|
||||
type: 'general',
|
||||
} }
|
||||
/>
|
||||
);
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { SearchListItem } from '@woocommerce/components';
|
||||
import { Spinner } from '@wordpress/components';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface SearchListItem {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ExpandableSearchListItemProps {
|
||||
className?: string;
|
||||
item: SearchListItem;
|
||||
isSelected: boolean;
|
||||
isLoading: boolean;
|
||||
onSelect: () => void;
|
||||
disabled: 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 }
|
||||
isSingle
|
||||
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;
|
@ -0,0 +1,33 @@
|
||||
.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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Icon, external } from '@wordpress/icons';
|
||||
import { VisuallyHidden } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './editor.scss';
|
||||
|
||||
/**
|
||||
* Show a link that displays a title, description, and optional icon. Links are opened in a new tab.
|
||||
*/
|
||||
const ExternalLinkCard = ( {
|
||||
href,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
} ): 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">
|
||||
{ description }
|
||||
</span>
|
||||
) }
|
||||
</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;
|
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ExternalLinkCard from '../';
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Blocks/editor-components/ExternalLinkCard',
|
||||
component: ExternalLinkCard,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<ExternalLinkCard
|
||||
href="#link"
|
||||
title="Card Title"
|
||||
description="This is a description of the link."
|
||||
/>
|
||||
);
|
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon, comment, external } from '@woocommerce/icons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* Component to render a Feedback prompt in the sidebar.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {string} props.text
|
||||
* @param {string} props.url
|
||||
*/
|
||||
const FeedbackPrompt = ( {
|
||||
text,
|
||||
url = 'https://ideas.woocommerce.com/forums/133476-woocommerce?category_id=384565',
|
||||
} ) => {
|
||||
return (
|
||||
<div className="wc-block-feedback-prompt">
|
||||
<Icon srcElement={ comment } />
|
||||
<h2 className="wc-block-feedback-prompt__title">
|
||||
{ __( 'Feedback?', 'woocommerce' ) }
|
||||
</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.',
|
||||
'woocommerce'
|
||||
) }
|
||||
<Icon srcElement={ external } size={ 16 } />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FeedbackPrompt.propTypes = {
|
||||
text: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
};
|
||||
|
||||
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.',
|
||||
'woocommerce'
|
||||
) }
|
||||
url="https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/new?template=--cart-checkout-feedback.md"
|
||||
/>
|
||||
);
|
@ -0,0 +1,19 @@
|
||||
.wc-block-feedback-prompt {
|
||||
background-color: #f7f7f7;
|
||||
border-top: 1px solid $gray-200;
|
||||
margin: 0 -16px 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ToggleControl } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* 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 } ) => {
|
||||
const { button, price, rating, title } = settings;
|
||||
return (
|
||||
<>
|
||||
<ToggleControl
|
||||
label={ __( 'Product title', 'woocommerce' ) }
|
||||
help={
|
||||
title
|
||||
? __(
|
||||
'Product title is visible.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Product title is hidden.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ title }
|
||||
onChange={ () => onChange( { ...settings, title: ! title } ) }
|
||||
/>
|
||||
<ToggleControl
|
||||
label={ __( 'Product price', 'woocommerce' ) }
|
||||
help={
|
||||
price
|
||||
? __(
|
||||
'Product price is visible.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Product price is hidden.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ price }
|
||||
onChange={ () => onChange( { ...settings, price: ! price } ) }
|
||||
/>
|
||||
<ToggleControl
|
||||
label={ __( 'Product rating', 'woocommerce' ) }
|
||||
help={
|
||||
rating
|
||||
? __(
|
||||
'Product rating is visible.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Product rating is hidden.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ rating }
|
||||
onChange={ () => onChange( { ...settings, rating: ! rating } ) }
|
||||
/>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
'Add to Cart button',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={
|
||||
button
|
||||
? __(
|
||||
'Add to Cart button is visible.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'Add to Cart button is hidden.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ button }
|
||||
onChange={ () => onChange( { ...settings, button: ! button } ) }
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
GridContentControl.propTypes = {
|
||||
/**
|
||||
* The current title visibility.
|
||||
*/
|
||||
settings: PropTypes.shape( {
|
||||
button: PropTypes.bool.isRequired,
|
||||
price: PropTypes.bool.isRequired,
|
||||
rating: PropTypes.bool.isRequired,
|
||||
title: PropTypes.bool.isRequired,
|
||||
} ).isRequired,
|
||||
/**
|
||||
* Callback to update the layout settings.
|
||||
*/
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default GridContentControl;
|
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { clamp } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { RangeControl, ToggleControl } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* 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 {string} 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,
|
||||
} ) => {
|
||||
return (
|
||||
<>
|
||||
<RangeControl
|
||||
label={ __( 'Columns', 'woocommerce' ) }
|
||||
value={ columns }
|
||||
onChange={ ( value ) => {
|
||||
const newValue = clamp( value, minColumns, maxColumns );
|
||||
setAttributes( {
|
||||
columns: Number.isNaN( newValue ) ? '' : newValue,
|
||||
} );
|
||||
} }
|
||||
min={ minColumns }
|
||||
max={ maxColumns }
|
||||
/>
|
||||
<RangeControl
|
||||
label={ __( 'Rows', 'woocommerce' ) }
|
||||
value={ rows }
|
||||
onChange={ ( value ) => {
|
||||
const newValue = clamp( value, minRows, maxRows );
|
||||
setAttributes( {
|
||||
rows: Number.isNaN( newValue ) ? '' : newValue,
|
||||
} );
|
||||
} }
|
||||
min={ minRows }
|
||||
max={ maxRows }
|
||||
/>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
'Align Last Block',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={
|
||||
alignButtons
|
||||
? __(
|
||||
'The last inner block will be aligned vertically.',
|
||||
'woocommerce'
|
||||
)
|
||||
: __(
|
||||
'The last inner block will follow other content.',
|
||||
'woocommerce'
|
||||
)
|
||||
}
|
||||
checked={ alignButtons }
|
||||
onChange={ () =>
|
||||
setAttributes( { alignButtons: ! alignButtons } )
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
GridLayoutControl.propTypes = {
|
||||
// The current columns count.
|
||||
columns: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] )
|
||||
.isRequired,
|
||||
// The current rows count.
|
||||
rows: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] )
|
||||
.isRequired,
|
||||
// Whether or not buttons are aligned horizontally across items.
|
||||
alignButtons: PropTypes.bool.isRequired,
|
||||
// Callback to update the layout settings.
|
||||
setAttributes: PropTypes.func.isRequired,
|
||||
// Min and max constraints.
|
||||
minColumns: PropTypes.number,
|
||||
maxColumns: PropTypes.number,
|
||||
minRows: PropTypes.number,
|
||||
maxRows: PropTypes.number,
|
||||
};
|
||||
|
||||
export default GridLayoutControl;
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Path, SVG } from '@wordpress/components';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { range } from 'lodash';
|
||||
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 } />,
|
||||
/* translators: %s: heading level e.g: "2", "3", "4" */
|
||||
title: sprintf( __( 'Heading %d' ), targetLevel ),
|
||||
isActive,
|
||||
onClick: () => onChange( targetLevel ),
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isCollapsed = true,
|
||||
minLevel,
|
||||
maxLevel,
|
||||
selectedLevel,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ToolbarGroup
|
||||
isCollapsed={ isCollapsed }
|
||||
icon={ <HeadingLevelIcon level={ selectedLevel } /> }
|
||||
controls={ range( minLevel, maxLevel ).map( ( index ) =>
|
||||
this.createLevelControl( index, selectedLevel, onChange )
|
||||
) }
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default HeadingToolbar;
|
@ -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;
|
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchListControl, SearchListItem } from '@woocommerce/components';
|
||||
import { SelectControl } from '@wordpress/components';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { withAttributes } from '@woocommerce/block-hocs';
|
||||
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message.js';
|
||||
import classNames from 'classnames';
|
||||
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item.tsx';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const ProductAttributeTermControl = ( {
|
||||
attributes,
|
||||
error,
|
||||
expandedAttribute,
|
||||
onChange,
|
||||
onExpandAttribute,
|
||||
onOperatorChange,
|
||||
instanceId,
|
||||
isCompact,
|
||||
isLoading,
|
||||
operator,
|
||||
selected,
|
||||
termsAreLoading,
|
||||
termsList,
|
||||
} ) => {
|
||||
const renderItem = ( args ) => {
|
||||
const { item, search, depth = 0 } = args;
|
||||
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 ) {
|
||||
const isSelected = expandedAttribute === item.id;
|
||||
return (
|
||||
<ExpandableSearchListItem
|
||||
{ ...args }
|
||||
className={ classNames( ...classes, {
|
||||
'is-selected': isSelected,
|
||||
} ) }
|
||||
isSelected={ isSelected }
|
||||
item={ item }
|
||||
isLoading={ termsAreLoading }
|
||||
disabled={ item.count === '0' }
|
||||
onSelect={ ( { id } ) => {
|
||||
return () => {
|
||||
onChange( [] );
|
||||
onExpandAttribute( id );
|
||||
};
|
||||
} }
|
||||
name={ `attributes-${ instanceId }` }
|
||||
countLabel={ sprintf(
|
||||
/* translators: %d is the count of terms. */
|
||||
_n(
|
||||
'%d term',
|
||||
'%d terms',
|
||||
item.count,
|
||||
'woocommerce'
|
||||
),
|
||||
item.count
|
||||
) }
|
||||
aria-label={ sprintf(
|
||||
/* translators: %1$s is the item name, %2$d is the count of terms for the item. */
|
||||
_n(
|
||||
'%1$s, has %2$d term',
|
||||
'%1$s, has %2$d terms',
|
||||
item.count,
|
||||
'woocommerce'
|
||||
),
|
||||
item.name,
|
||||
item.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',
|
||||
item.count,
|
||||
'woocommerce'
|
||||
),
|
||||
item.count
|
||||
) }
|
||||
aria-label={ sprintf(
|
||||
/* translators: %1$s is the attribute name, %2$d is the count of products for that attribute. */
|
||||
_n(
|
||||
'%1$s, has %2$d product',
|
||||
'%1$s, has %2$d products',
|
||||
item.count,
|
||||
'woocommerce'
|
||||
),
|
||||
itemName,
|
||||
item.count
|
||||
) }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const currentTerms = termsList[ expandedAttribute ] || [];
|
||||
const currentList = [ ...attributes, ...currentTerms ];
|
||||
|
||||
const messages = {
|
||||
clear: __(
|
||||
'Clear all product attributes',
|
||||
'woocommerce'
|
||||
),
|
||||
list: __( 'Product Attributes', 'woocommerce' ),
|
||||
noItems: __(
|
||||
"Your store doesn't have any product attributes.",
|
||||
'woocommerce'
|
||||
),
|
||||
search: __(
|
||||
'Search for product attributes',
|
||||
'woocommerce'
|
||||
),
|
||||
selected: ( n ) =>
|
||||
sprintf(
|
||||
/* translators: %d is the count of attributes selected. */
|
||||
_n(
|
||||
'%d attribute selected',
|
||||
'%d attributes selected',
|
||||
n,
|
||||
'woocommerce'
|
||||
),
|
||||
n
|
||||
),
|
||||
updated: __(
|
||||
'Product attribute search results updated.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
|
||||
if ( error ) {
|
||||
return <ErrorMessage error={ error } />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchListControl
|
||||
className="woocommerce-product-attributes"
|
||||
list={ currentList }
|
||||
isLoading={ isLoading }
|
||||
selected={ selected
|
||||
.map( ( { id } ) =>
|
||||
currentList.find(
|
||||
( currentListItem ) => currentListItem.id === id
|
||||
)
|
||||
)
|
||||
.filter( Boolean ) }
|
||||
onChange={ onChange }
|
||||
renderItem={ renderItem }
|
||||
messages={ messages }
|
||||
isCompact={ isCompact }
|
||||
isHierarchical
|
||||
/>
|
||||
{ !! onOperatorChange && (
|
||||
<div hidden={ selected.length < 2 }>
|
||||
<SelectControl
|
||||
className="woocommerce-product-attributes__operator"
|
||||
label={ __(
|
||||
'Display products matching',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={ __(
|
||||
'Pick at least two attributes to use this setting.',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={ operator }
|
||||
onChange={ onOperatorChange }
|
||||
options={ [
|
||||
{
|
||||
label: __(
|
||||
'Any selected attributes',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'All selected attributes',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'all',
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</div>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProductAttributeTermControl.propTypes = {
|
||||
/**
|
||||
* Callback to update the selected product attributes.
|
||||
*/
|
||||
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 attribute slug/ID pairs.
|
||||
*/
|
||||
selected: PropTypes.array.isRequired,
|
||||
// from withAttributes
|
||||
attributes: PropTypes.array,
|
||||
error: PropTypes.object,
|
||||
expandedAttribute: PropTypes.number,
|
||||
onExpandAttribute: PropTypes.func,
|
||||
isCompact: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
termsAreLoading: PropTypes.bool,
|
||||
termsList: PropTypes.object,
|
||||
};
|
||||
|
||||
ProductAttributeTermControl.defaultProps = {
|
||||
isCompact: false,
|
||||
operator: 'any',
|
||||
};
|
||||
|
||||
export default withAttributes( withInstanceId( ProductAttributeTermControl ) );
|
@ -0,0 +1,56 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
&.depth-0::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 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z" fill="#{encode-color($gray-700)}" /></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center right;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
&.depth-0.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 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" fill="#{encode-color($gray-700)}" /></svg>');
|
||||
}
|
||||
|
||||
&[disabled].depth-0::after {
|
||||
margin-left: 0;
|
||||
width: auto;
|
||||
background: none;
|
||||
}
|
||||
}
|
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchListControl, SearchListItem } from '@woocommerce/components';
|
||||
import { SelectControl } from '@wordpress/components';
|
||||
import { withCategories } from '@woocommerce/block-hocs';
|
||||
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message.js';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
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 );
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import { isEmpty } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchListControl, SearchListItem } from '@woocommerce/components';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import {
|
||||
withProductVariations,
|
||||
withSearchedProducts,
|
||||
withTransformSingleSelectToMultipleSelect,
|
||||
} from '@woocommerce/block-hocs';
|
||||
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message.js';
|
||||
import classNames from 'classnames';
|
||||
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item.tsx';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const messages = {
|
||||
list: __( 'Products', 'woocommerce' ),
|
||||
noItems: __(
|
||||
"Your store doesn't have any products.",
|
||||
'woocommerce'
|
||||
),
|
||||
search: __(
|
||||
'Search for a product to display',
|
||||
'woocommerce'
|
||||
),
|
||||
updated: __(
|
||||
'Product search results updated.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
|
||||
const ProductControl = ( {
|
||||
expandedProduct,
|
||||
error,
|
||||
instanceId,
|
||||
isCompact,
|
||||
isLoading,
|
||||
onChange,
|
||||
onSearch,
|
||||
products,
|
||||
renderItem,
|
||||
selected,
|
||||
showVariations,
|
||||
variations,
|
||||
variationsLoading,
|
||||
} ) => {
|
||||
const renderItemWithVariations = ( args ) => {
|
||||
const { item, search, depth = 0, isSelected, onSelect } = args;
|
||||
const variationsCount =
|
||||
item.variations && Array.isArray( item.variations )
|
||||
? item.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 ) {
|
||||
return (
|
||||
<ExpandableSearchListItem
|
||||
{ ...args }
|
||||
className={ classNames( classes, {
|
||||
'is-selected': isSelected,
|
||||
} ) }
|
||||
isSelected={ isSelected }
|
||||
item={ item }
|
||||
onSelect={ () => {
|
||||
return () => {
|
||||
onSelect( item )();
|
||||
};
|
||||
} }
|
||||
isLoading={ isLoading || variationsLoading }
|
||||
countLabel={
|
||||
item.variations.length > 0
|
||||
? sprintf(
|
||||
/* translators: %1$d is the number of variations of a product product. */
|
||||
__(
|
||||
'%1$d variations',
|
||||
'woocommerce'
|
||||
),
|
||||
item.variations.length
|
||||
)
|
||||
: null
|
||||
}
|
||||
name={ `products-${ instanceId }` }
|
||||
aria-label={ sprintf(
|
||||
/* translators: %1$s is the product name, %2$d is the number of variations of that product. */
|
||||
_n(
|
||||
'%1$s, has %2$d variation',
|
||||
'%1$s, has %2$d variations',
|
||||
item.variations.length,
|
||||
'woocommerce'
|
||||
),
|
||||
item.name,
|
||||
item.variations.length
|
||||
) }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemArgs = isEmpty( item.variation )
|
||||
? args
|
||||
: {
|
||||
...args,
|
||||
item: {
|
||||
...args.item,
|
||||
name: item.variation,
|
||||
},
|
||||
'aria-label': `${ item.breadcrumbs[ 0 ] }: ${ item.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 && variations[ expandedProduct ]
|
||||
? variations[ expandedProduct ]
|
||||
: [];
|
||||
const currentList = [ ...products, ...currentVariations ];
|
||||
|
||||
return (
|
||||
<SearchListControl
|
||||
className="woocommerce-products"
|
||||
list={ currentList }
|
||||
isCompact={ isCompact }
|
||||
isLoading={ isLoading }
|
||||
isSingle
|
||||
selected={ currentList.filter( ( { id } ) =>
|
||||
selected.includes( id )
|
||||
) }
|
||||
onChange={ onChange }
|
||||
renderItem={ getRenderItemFunc() }
|
||||
onSearch={ onSearch }
|
||||
messages={ messages }
|
||||
isHierarchical
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProductControl.propTypes = {
|
||||
/**
|
||||
* Callback to update the selected products.
|
||||
*/
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isCompact: PropTypes.bool,
|
||||
/**
|
||||
* The ID of the currently expanded product.
|
||||
*/
|
||||
expandedProduct: PropTypes.number,
|
||||
/**
|
||||
* Callback to search products by their name.
|
||||
*/
|
||||
onSearch: PropTypes.func,
|
||||
/**
|
||||
* Query args to pass to getProducts.
|
||||
*/
|
||||
queryArgs: PropTypes.object,
|
||||
/**
|
||||
* Callback to render each item in the selection list, allows any custom object-type rendering.
|
||||
*/
|
||||
renderItem: PropTypes.func,
|
||||
/**
|
||||
* The ID of the currently selected item (product or variation).
|
||||
*/
|
||||
selected: PropTypes.arrayOf( PropTypes.number ),
|
||||
/**
|
||||
* Whether to show variations in the list of items available.
|
||||
*/
|
||||
showVariations: PropTypes.bool,
|
||||
};
|
||||
|
||||
ProductControl.defaultProps = {
|
||||
isCompact: false,
|
||||
expandedProduct: null,
|
||||
selected: [],
|
||||
showVariations: false,
|
||||
};
|
||||
|
||||
export default withTransformSingleSelectToMultipleSelect(
|
||||
withSearchedProducts(
|
||||
withProductVariations( withInstanceId( ProductControl ) )
|
||||
)
|
||||
);
|
@ -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>');
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { SelectControl } from '@wordpress/components';
|
||||
import PropTypes from 'prop-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 } ) => {
|
||||
return (
|
||||
<SelectControl
|
||||
label={ __( 'Order products by', 'woocommerce' ) }
|
||||
value={ value }
|
||||
options={ [
|
||||
{
|
||||
label: __(
|
||||
'Newness - newest first',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'date',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Price - low to high',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'price_asc',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Price - high to low',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'price_desc',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Rating - highest first',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'rating',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Sales - most first',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'popularity',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'Title - alphabetical',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'title',
|
||||
},
|
||||
{
|
||||
label: __( 'Menu Order', 'woocommerce' ),
|
||||
value: 'menu_order',
|
||||
},
|
||||
] }
|
||||
onChange={ ( orderby ) => setAttributes( { orderby } ) }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProductOrderbyControl.propTypes = {
|
||||
/**
|
||||
* Callback to update the order setting.
|
||||
*/
|
||||
setAttributes: PropTypes.func.isRequired,
|
||||
/**
|
||||
* The selected order setting.
|
||||
*/
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ProductOrderbyControl;
|
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchListControl, SearchListItem } from '@woocommerce/components';
|
||||
import { SelectControl } from '@wordpress/components';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getProductTags } from '../utils';
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* Component to handle searching and selecting product tags.
|
||||
*/
|
||||
class ProductTagControl extends Component {
|
||||
constructor() {
|
||||
super( ...arguments );
|
||||
this.state = {
|
||||
list: [],
|
||||
loading: true,
|
||||
};
|
||||
this.renderItem = this.renderItem.bind( this );
|
||||
this.debouncedOnSearch = debounce( this.onSearch.bind( this ), 400 );
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { selected } = this.props;
|
||||
|
||||
getProductTags( { selected } )
|
||||
.then( ( list ) => {
|
||||
this.setState( { list, loading: false } );
|
||||
} )
|
||||
.catch( () => {
|
||||
this.setState( { list: [], loading: false } );
|
||||
} );
|
||||
}
|
||||
|
||||
onSearch( search ) {
|
||||
const { selected } = this.props;
|
||||
this.setState( { loading: true } );
|
||||
|
||||
getProductTags( { selected, search } )
|
||||
.then( ( list ) => {
|
||||
this.setState( { list, loading: false } );
|
||||
} )
|
||||
.catch( () => {
|
||||
this.setState( { list: [], loading: false } );
|
||||
} );
|
||||
}
|
||||
|
||||
renderItem( args ) {
|
||||
const { item, search, depth = 0 } = args;
|
||||
|
||||
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,
|
||||
}
|
||||
) }
|
||||
{ ...args }
|
||||
aria-label={ 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,
|
||||
'woocommerce'
|
||||
),
|
||||
item.count,
|
||||
accessibleName
|
||||
) }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { list, loading } = this.state;
|
||||
const {
|
||||
isCompact,
|
||||
onChange,
|
||||
onOperatorChange,
|
||||
operator,
|
||||
selected,
|
||||
} = this.props;
|
||||
|
||||
const messages = {
|
||||
clear: __(
|
||||
'Clear all product tags',
|
||||
'woocommerce'
|
||||
),
|
||||
list: __( 'Product Tags', 'woocommerce' ),
|
||||
noItems: __(
|
||||
"Your store doesn't have any product tags.",
|
||||
'woocommerce'
|
||||
),
|
||||
search: __(
|
||||
'Search for product tags',
|
||||
'woocommerce'
|
||||
),
|
||||
selected: ( n ) =>
|
||||
sprintf(
|
||||
/* translators: %d is the count of selected tags. */
|
||||
_n(
|
||||
'%d tag selected',
|
||||
'%d tags selected',
|
||||
n,
|
||||
'woocommerce'
|
||||
),
|
||||
n
|
||||
),
|
||||
updated: __(
|
||||
'Tag search results updated.',
|
||||
'woocommerce'
|
||||
),
|
||||
};
|
||||
|
||||
const limitTags = getSetting( 'limitTags', false );
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchListControl
|
||||
className="woocommerce-product-tags"
|
||||
list={ list }
|
||||
isLoading={ loading }
|
||||
selected={ selected
|
||||
.map( ( id ) =>
|
||||
list.find( ( listItem ) => listItem.id === id )
|
||||
)
|
||||
.filter( Boolean ) }
|
||||
onChange={ onChange }
|
||||
onSearch={ limitTags ? this.debouncedOnSearch : null }
|
||||
renderItem={ this.renderItem }
|
||||
messages={ messages }
|
||||
isCompact={ isCompact }
|
||||
isHierarchical
|
||||
/>
|
||||
{ !! onOperatorChange && (
|
||||
<div hidden={ selected.length < 2 }>
|
||||
<SelectControl
|
||||
className="woocommerce-product-tags__operator"
|
||||
label={ __(
|
||||
'Display products matching',
|
||||
'woocommerce'
|
||||
) }
|
||||
help={ __(
|
||||
'Pick at least two tags to use this setting.',
|
||||
'woocommerce'
|
||||
) }
|
||||
value={ operator }
|
||||
onChange={ onOperatorChange }
|
||||
options={ [
|
||||
{
|
||||
label: __(
|
||||
'Any selected tags',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'any',
|
||||
},
|
||||
{
|
||||
label: __(
|
||||
'All selected tags',
|
||||
'woocommerce'
|
||||
),
|
||||
value: 'all',
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</div>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ProductTagControl.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 tags.
|
||||
*/
|
||||
selected: PropTypes.array.isRequired,
|
||||
isCompact: PropTypes.bool,
|
||||
};
|
||||
|
||||
ProductTagControl.defaultProps = {
|
||||
isCompact: false,
|
||||
operator: 'any',
|
||||
};
|
||||
|
||||
export default ProductTagControl;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import { SearchListControl } from '@woocommerce/components';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withSearchedProducts } from '@woocommerce/block-hocs';
|
||||
import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message.js';
|
||||
|
||||
/**
|
||||
* 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: `${ 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 );
|
@ -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;
|
@ -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;
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isFunction } from 'lodash';
|
||||
import classnames from 'classnames';
|
||||
import { BaseControl, ButtonGroup, Button } from '@wordpress/components';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
class ToggleButtonControl extends Component {
|
||||
constructor() {
|
||||
super( ...arguments );
|
||||
|
||||
this.onClick = this.onClick.bind( this );
|
||||
}
|
||||
|
||||
onClick( event ) {
|
||||
if ( this.props.onChange ) {
|
||||
this.props.onChange( event.target.value );
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
label,
|
||||
checked,
|
||||
instanceId,
|
||||
className,
|
||||
help,
|
||||
options,
|
||||
value,
|
||||
} = this.props;
|
||||
const id = `inspector-toggle-button-control-${ instanceId }`;
|
||||
|
||||
let helpLabel;
|
||||
|
||||
if ( help ) {
|
||||
helpLabel = isFunction( help ) ? help( checked ) : help;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseControl
|
||||
id={ id }
|
||||
help={ helpLabel }
|
||||
className={ classnames(
|
||||
'components-toggle-button-control',
|
||||
className
|
||||
) }
|
||||
>
|
||||
<label
|
||||
id={ id + '__label' }
|
||||
htmlFor={ id }
|
||||
className="components-toggle-button-control__label"
|
||||
>
|
||||
{ label }
|
||||
</label>
|
||||
<ButtonGroup aria-labelledby={ id + '__label' }>
|
||||
{ options.map( ( option, index ) => {
|
||||
const buttonArgs = {};
|
||||
|
||||
// Change button depending on pressed state.
|
||||
if ( value === option.value ) {
|
||||
buttonArgs.isPrimary = true;
|
||||
buttonArgs[ 'aria-pressed' ] = true;
|
||||
} else {
|
||||
buttonArgs.isSecondary = true;
|
||||
buttonArgs[ 'aria-pressed' ] = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={ `${ option.label }-${ option.value }-${ index }` }
|
||||
value={ option.value }
|
||||
onClick={ this.onClick }
|
||||
aria-label={ label + ': ' + option.label }
|
||||
{ ...buttonArgs }
|
||||
>
|
||||
{ option.label }
|
||||
</Button>
|
||||
);
|
||||
} ) }
|
||||
</ButtonGroup>
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withInstanceId( ToggleButtonControl );
|
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
@ -0,0 +1,13 @@
|
||||
.components-toggle-button-control {
|
||||
.components-base-control__field {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.components-toggle-button-control__label {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.components-base-control__help {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,216 @@
|
||||
/* eslint-disable you-dont-need-lodash-underscore/flatten -- until we have an alternative to uniqBy we'll keep flatten to avoid potential introduced bugs with alternatives */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { flatten, uniqBy } from 'lodash';
|
||||
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/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/products', {
|
||||
catalog_visibility: 'any',
|
||||
include: selected,
|
||||
per_page: 0,
|
||||
} )
|
||||
);
|
||||
}
|
||||
|
||||
return requests;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 products = uniqBy( flatten( data ), '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/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/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/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/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/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 ) => {
|
||||
return uniqBy( flatten( data ), '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/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/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/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 }` : '' );
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
.wc-block-view-switch-control {
|
||||
text-align: left;
|
||||
background: #f0f2f3;
|
||||
box-shadow: 0 0 0 13px #f0f2f3;
|
||||
margin: 0 0 27px;
|
||||
visibility: hidden;
|
||||
color: $gray-700;
|
||||
}
|
||||
.has-child-selected,
|
||||
.is-selected {
|
||||
.wc-block-view-switch-control {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { ButtonGroup, Button } from '@wordpress/components';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './editor.scss';
|
||||
|
||||
const ViewSwitcher = ( {
|
||||
className,
|
||||
label = __( 'View', 'woocommerce' ),
|
||||
views,
|
||||
defaultView,
|
||||
instanceId,
|
||||
render,
|
||||
} ) => {
|
||||
const [ currentView, setCurrentView ] = useState( defaultView );
|
||||
const classes = classnames( className, 'wc-block-view-switch-control' );
|
||||
const htmlId = 'wc-block-view-switch-control-' + instanceId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={ classes }>
|
||||
<label
|
||||
htmlFor={ htmlId }
|
||||
className="wc-block-view-switch-control__label"
|
||||
>
|
||||
{ label + ': ' }
|
||||
</label>
|
||||
<ButtonGroup id={ htmlId }>
|
||||
{ views.map( ( view ) => (
|
||||
<Button
|
||||
key={ view.value }
|
||||
isPrimary={ currentView === view.value }
|
||||
aria-pressed={ currentView === view.value }
|
||||
onMouseDown={ () => {
|
||||
if ( currentView !== view.value ) {
|
||||
setCurrentView( view.value );
|
||||
}
|
||||
} }
|
||||
onClick={ () => {
|
||||
if ( currentView !== view.value ) {
|
||||
setCurrentView( view.value );
|
||||
}
|
||||
} }
|
||||
>
|
||||
{ view.name }
|
||||
</Button>
|
||||
) ) }
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{ render( currentView ) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ViewSwitcher.propTypes = {
|
||||
/**
|
||||
* Custom class name to add to component.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* List of views.
|
||||
*/
|
||||
views: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
} )
|
||||
).isRequired,
|
||||
/**
|
||||
* The default selected view.
|
||||
*/
|
||||
defaultView: PropTypes.string.isRequired,
|
||||
/**
|
||||
* Render prop for selected views.
|
||||
*/
|
||||
render: PropTypes.func.isRequired,
|
||||
// from withInstanceId
|
||||
instanceId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default withInstanceId( ViewSwitcher );
|
Reference in New Issue
Block a user