Merge branch 'trunk' into add/49335-related-products-collection

This commit is contained in:
Christopher Allford 2024-09-16 12:45:15 -07:00
commit 6ca720708b
No known key found for this signature in database
GPG Key ID: 80E44C778F08A88E
280 changed files with 10915 additions and 10868 deletions

View File

@ -4,23 +4,25 @@ on:
- cron: '0 0 * * *' # Run at 12 AM UTC.
workflow_dispatch:
permissions: {}
env:
SOURCE_REF: trunk
TARGET_REF: nightly
RELEASE_ID: 25945111
permissions: { }
jobs:
build:
if: github.repository_owner == 'woocommerce'
name: Nightly builds
strategy:
fail-fast: false
matrix:
build: [trunk]
runs-on: ubuntu-20.04
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
ref: ${{ matrix.build }}
ref: ${{ env.SOURCE_REF }}
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
@ -31,26 +33,31 @@ jobs:
working-directory: plugins/woocommerce
run: bash bin/build-zip.sh
- name: Deploy nightly build
uses: WebFreak001/deploy-nightly@v1.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload nightly build
uses: WebFreak001/deploy-nightly@46ecbabd7fad70d3e7d2c97fe8cd54e7a52e215b #v3.2.0
with:
upload_url: https://uploads.github.com/repos/${{ github.repository }}/releases/25945111/assets{?name,label}
release_id: 25945111
token: ${{ secrets.GITHUB_TOKEN }}
upload_url: https://uploads.github.com/repos/${{ github.repository }}/releases/${{ env.RELEASE_ID }}/assets{?name,label}
release_id: ${{ env.RELEASE_ID }}
asset_path: plugins/woocommerce/woocommerce.zip
asset_name: woocommerce-${{ matrix.build }}-nightly.zip
asset_name: woocommerce-${{ env.SOURCE_REF }}-nightly.zip
asset_content_type: application/zip
max_releases: 1
update:
name: Update nightly tag commit ref
runs-on: ubuntu-20.04
permissions:
contents: write
steps:
- name: Update nightly tag
uses: richardsimko/github-tag-action@v1.0.5
- name: Update nightly tag commit ref
uses: actions/github-script@v7
with:
tag_name: nightly
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const sourceRef = process.env.SOURCE_REF;
const targetRef = process.env.TARGET_REF;
const branchData = await github.rest.repos.getBranch({
...context.repo,
branch: sourceRef,
});
await github.rest.git.updateRef({
...context.repo,
ref: `tags/${ targetRef }`,
sha: branchData.data.commit.sha,
});

View File

@ -1 +1,14 @@
/.github/ @woocommerce/atlas
# Monorepo CI and package managers manifests.
/.github/ @woocommerce/flux
**/composer.json @woocommerce/flux
**/package.json @woocommerce/flux
# Monorepo tooling folders.
/bin/ @woocommerce/flux
/tools/ @woocommerce/flux
/packages/js/eslint-plugin/ @woocommerce/flux
/packages/js/dependency-extraction-webpack-plugin/ @woocommerce/flux
# Files in root of repository
/.* @woocommerce/flux
/*.* @woocommerce/flux

View File

@ -20,8 +20,8 @@ To get up and running within the WooCommerce Monorepo, you will need to make sur
Once you've installed all of the prerequisites, you can run the following commands to get everything working.
```bash
# Ensure that you're using the correct version of Node
nvm use
# Ensure that correct version of Node is installed and being used
nvm install
# Install the PHP and Composer dependencies for all of the plugins, packages, and tools
pnpm install
# Build all of the plugins, packages, and tools in the monorepo

View File

@ -1,5 +1,9 @@
== Changelog ==
= 9.3.1 2024-09-12 =
* Tweak - Disable remote logging feature by default [#51312](https://github.com/woocommerce/woocommerce/pull/51312)
= 9.3.0 2024-09-10 =
**WooCommerce**
@ -141,6 +145,7 @@
* Update - Update core profiler continue button container on extension screen [#50582](https://github.com/woocommerce/woocommerce/pull/50582)
* Update - Update Store Alert actions to have unique keys. [#50424](https://github.com/woocommerce/woocommerce/pull/50424)
* Update - Update WooCommercePayments task is_supported to use default suggestions [#50585](https://github.com/woocommerce/woocommerce/pull/50585)
* Update - Enhance CSV path and upload handling in product import [#51344](https://github.com/woocommerce/woocommerce/pull/51344)
* Dev - Execute test env setup on host instead of wp-env container [#51021](https://github.com/woocommerce/woocommerce/pull/51021)
* Dev - Added code docs with examples to the Analytics classes [#49425](https://github.com/woocommerce/woocommerce/pull/49425)
* Dev - Add lost password e2e tests [#50611](https://github.com/woocommerce/woocommerce/pull/50611)

View File

@ -727,7 +727,7 @@
"menu_title": "Implement merchant onboarding",
"tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/extension-development/handling-merchant-onboarding.md",
"hash": "c73e3c5015e6cda3be9ebd2d5fbda590ac9fa599e5fb02163c971c01060970ad",
"hash": "85fc7d70f47fdb195ad2c690d3b95565169221f9e4d7afa98e88f3824ad0e267",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/extension-development/handling-merchant-onboarding.md",
"id": "89fe15dc232379f546852822230c334d3d940b93"
},
@ -874,7 +874,7 @@
"menu_title": "Development environment setup",
"tags": "tutorial, setup",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/getting-started/development-environment.md",
"hash": "9e471d3f44a882fe61dcad9e5207d51b280a7220aae1bf6e4ae1fbdd68b7e3d4",
"hash": "bf5d77349ea64d1b8e19fe6b7472be35ed92406c5aafe677ce92363fb13f94d4",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/getting-started/development-environment.md",
"id": "9080572a3904349c44c565ca7e1bef1212c58757"
},
@ -1804,5 +1804,5 @@
"categories": []
}
],
"hash": "1f651a59399c34644d2f91a0366bbd01da2c7dc677a1c53329b184badd3b8d13"
"hash": "dfe48a2a48d383c1f4e5b5bb4258d369dd4e80ac8582462b16bbfedd879cb0bf"
}

View File

@ -448,7 +448,7 @@ class ExampleNote {
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
// Set the type of layout the note uses. Supported layout types are:
// 'banner', 'plain', 'thumbnail'
// 'plain', 'thumbnail'
$note->set_layout( 'plain' );
// Set the image for the note. This property renders as the src

View File

@ -80,22 +80,16 @@ git clone https://github.com/woocommerce/woocommerce.git
cd woocommerce
```
### Activate the required Node version
### Install and Activate the required Node version
```sh
nvm use
Found '/path/to/woocommerce/.nvmrc' with version <v12>
Now using node v12.21.0 (npm v6.14.11)
nvm install
Found '/path/to/woocommerce/.nvmrc' with version <v20>
v20.17.0 is already installed.
Now using node v20.17.0 (npm v10.8.2)
```
Note: if you don't have the required version of Node installed, NVM will alert you so you can install it:
```sh
Found '/path/to/woocommerce/.nvmrc' with version <v12>
N/A: version "v12 -> N/A" is not yet installed.
You need to run "nvm install v12" to install it before using it.
```
Note: if you don't have the required version of Node installed, NVM will install it.
### Install dependencies

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Use page query param consistently as string for `getReportTableQuery`.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Removed the leftover user meta from the help panel spotlight

View File

@ -561,7 +561,7 @@ export function getReportTableQuery(
before: noIntervals
? undefined
: appendTimestamp( datesFromQuery.primary.before, 'end' ),
page: query.paged || 1,
page: query.paged || '1',
per_page: query.per_page || QUERY_DEFAULTS.pageSize,
...filterQuery,
...tableQuery,

View File

@ -15,7 +15,6 @@ export type UserPreferences = {
dashboard_chart_type?: string;
dashboard_leaderboard_rows?: string;
dashboard_sections?: string;
help_panel_highlight_shown?: string;
homepage_layout?: string;
homepage_stats?: string;
orders_report_columns?: string;

View File

@ -300,15 +300,28 @@ const exitToWooHome = fromPromise( async () => {
window.location.href = getNewPath( {}, '/', {} );
} );
const getPluginNameParam = (
pluginsSelected: CoreProfilerStateMachineContext[ 'pluginsSelected' ]
) => {
if ( pluginsSelected.includes( 'woocommerce-payments' ) ) {
return 'woocommerce-payments';
}
return 'jetpack-ai';
};
const redirectToJetpackAuthPage = ( {
event,
context,
}: {
context: CoreProfilerStateMachineContext;
event: { output: { url: string } };
} ) => {
const url = new URL( event.output.url );
url.searchParams.set( 'installed_ext_success', '1' );
url.searchParams.set( 'plugin_name', 'jetpack-ai' );
url.searchParams.set(
'plugin_name',
getPluginNameParam( context.pluginsSelected )
);
window.location.href = url.toString();
};

View File

@ -14,6 +14,11 @@ import { mergeBaseAndUserConfigs } from '@wordpress/edit-site/build-module/compo
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
import { isEqual, noop } from 'lodash';
/**
* Internal dependencies
*/
import { trackEvent } from '~/customize-store/tracking';
const { GlobalStylesContext } = unlock( blockEditorPrivateApis );
// Removes the typography settings from the styles when the user is changing
@ -100,6 +105,24 @@ export const VariationContainer = ( { variation, children } ) => {
),
};
} );
if ( variation.settings.color?.palette ) {
trackEvent(
'customize_your_store_assembler_hub_color_palette_item_click',
{
item: variation.title,
}
);
}
if ( variation.settings.typography ) {
trackEvent(
'customize_your_store_assembler_hub_typography_item_click',
{
item: variation.title,
}
);
}
};
const selectOnEnter = ( event ) => {

View File

@ -17,6 +17,7 @@ import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
import { CustomizeStoreContext } from '../';
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ColorPalette, ColorPanel } from './global-styles';
import { trackEvent } from '~/customize-store/tracking';
import { FlowType } from '~/customize-store/types';
const { GlobalStylesContext } = unlock( blockEditorPrivateApis );
@ -27,6 +28,14 @@ const SidebarNavigationScreenColorPaletteContent = () => {
const hasCreatedOwnColors = !! (
user.settings.color && user.settings.color.palette.hasCreatedOwnColors
);
function handlePanelBodyToggle( open?: boolean ) {
trackEvent(
'customize_your_store_assembler_hub_color_palette_create_toggle',
{ open }
);
}
// Wrap in a BlockEditorProvider to ensure that the Iframe's dependencies are
// loaded. This is necessary because the Iframe component waits until
// the block editor store's `__internalIsInitialized` is true before
@ -45,6 +54,7 @@ const SidebarNavigationScreenColorPaletteContent = () => {
className="woocommerce-customize-store__color-panel-container"
title={ __( 'or create your own', 'woocommerce' ) }
initialOpen={ hasCreatedOwnColors }
onToggle={ handlePanelBodyToggle }
>
<ColorPanel />
</PanelBody>

View File

@ -297,16 +297,26 @@ const LogoEdit = ( {
);
}
function handleMediaUploadSelect( media: { id: string; url: string } ) {
onInitialSelectLogo( media );
trackEvent( 'customize_your_store_assembler_hub_logo_select' );
}
if ( ! logoUrl ) {
return (
<MediaUploadCheck>
<MediaUpload
onSelect={ onInitialSelectLogo }
onSelect={ handleMediaUploadSelect }
allowedTypes={ ALLOWED_MEDIA_TYPES }
render={ ( { open }: { open: () => void } ) => (
<Button
variant="link"
onClick={ open }
onClick={ () => {
open();
trackEvent(
'customize_your_store_assembler_hub_logo_add_click'
);
} }
className="block-library-site-logo__inspector-upload-container"
>
<span>
@ -351,10 +361,17 @@ const LogoEdit = ( {
<>
<MediaUploadCheck>
<MediaUpload
onSelect={ onInitialSelectLogo }
onSelect={ handleMediaUploadSelect }
allowedTypes={ ALLOWED_MEDIA_TYPES }
render={ ( { open }: { open: () => void } ) =>
cloneElement( logoImg, { onClick: open } )
cloneElement( logoImg, {
onClick() {
open();
trackEvent(
'customize_your_store_assembler_hub_logo_edit_click'
);
},
} )
}
/>
</MediaUploadCheck>
@ -478,6 +495,9 @@ export const SidebarNavigationScreenLogo = ( {
media
);
onClose();
trackEvent(
'customize_your_store_assembler_hub_logo_select'
);
} }
allowedTypes={
ALLOWED_MEDIA_TYPES
@ -490,6 +510,9 @@ export const SidebarNavigationScreenLogo = ( {
<MenuItem
onClick={ () => {
open();
trackEvent(
'customize_your_store_assembler_hub_logo_replace_click'
);
} }
>
{ __(
@ -508,6 +531,9 @@ export const SidebarNavigationScreenLogo = ( {
onClick={ () => {
onClose();
onRemoveLogo();
trackEvent(
'customize_your_store_assembler_hub_logo_remove_click'
);
} }
>
{ __(

View File

@ -8,8 +8,18 @@
min-height: 500px;
}
.woocommerce-marketplace__content .woocommerce-customize-store-banner .woocommerce-customize-store-banner-content {
width: 100%;
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace__content {
padding: $grid-unit-60 $grid-unit-40;
}
}
@media screen and (max-width: $breakpoint-medium) {
.woocommerce-marketplace__content .woocommerce-customize-store-banner .woocommerce-customize-store-banner-content {
margin-top: 40px;
}
}

View File

@ -73,7 +73,8 @@ export default function Content(): JSX.Element {
if (
query.tab === undefined ||
( query.tab && [ '', 'discover' ].includes( query.tab ) )
( query.tab &&
[ '', 'discover', 'my-subscriptions' ].includes( query.tab ) )
) {
return;
}
@ -196,6 +197,9 @@ export default function Content(): JSX.Element {
{ selectedTab !== 'business-services' && (
<SubscriptionsExpiredExpiringNotice type="expiring" />
) }
{ selectedTab !== 'business-services' && (
<SubscriptionsExpiredExpiringNotice type="missing" />
) }
{ renderContent() }
</div>

View File

@ -13,6 +13,7 @@ import ProductLoader from '../product-loader/product-loader';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import { ProductType } from '../product-list/types';
import './discover.scss';
import { recordMarketplaceView } from '~/marketplace/utils/tracking';
export default function Discover(): JSX.Element | null {
const [ productGroups, setProductGroups ] = useState<
@ -28,10 +29,17 @@ export default function Discover(): JSX.Element | null {
return product.id;
} );
// This is a new event specific to the Discover tab, added with Woo 8.4.
recordEvent( 'marketplace_discover_viewed', {
view: 'discover',
product_ids,
} );
// This is the new page view event added with Woo 8.3. It's improved with the marketplace_discover_viewed event
// but we'll keep it for a while to keep it compatible.
recordMarketplaceView( {
view: 'discover',
} );
}
// Get the content for this screen

View File

@ -9,6 +9,9 @@
"mktpl-tabs mktpl-tabs mktpl-tabs" auto / 1fr 320px 36px;
padding: 0 $content-spacing-large;
width: 100%;
position: sticky;
top: var(--wp-admin--admin-bar--height, 32px);
z-index: 1;
/* On narrow screens, "stack" header items and hide the bottom border */
@media (width <= $breakpoint-medium) {

View File

@ -39,6 +39,12 @@ export default function SubscriptionsExpiredExpiringNotice(
dismissed:
'woo_subscription_expiring_notice_in_marketplace_dismissed',
},
'woo-subscription-missing-notice': {
shown: 'woo_subscription_missing_notice_in_marketplace_shown',
clicked: 'woo_subscription_missing_notice_in_marketplace_clicked',
dismissed:
'woo_subscription_missing_notice_in_marketplace_dismissed',
},
};
let notice = null;
@ -51,6 +57,9 @@ export default function SubscriptionsExpiredExpiringNotice(
} else if ( type === 'expiring' ) {
notice = wccomSettings?.subscription_expiring_notice || {};
notice_id = 'woo-subscription-expiring-notice';
} else if ( type === 'missing' ) {
notice = wccomSettings?.subscription_missing_notice || {};
notice_id = 'woo-subscription-missing-notice';
} else {
return null;
}

View File

@ -51,6 +51,48 @@ export default function Install( props: InstallProps ) {
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleInstallError = ( error: any ) => {
loadSubscriptions( false ).then( () => {
let errorMessage = sprintf(
// translators: %s is the product name.
__( '%s couldnt be installed.', 'woocommerce' ),
props.subscription.product_name
);
if ( error?.success === false && error?.data.message ) {
errorMessage += ' ' + error.data.message;
}
addNotice(
props.subscription.product_key,
errorMessage,
NoticeStatus.Error,
{
actions: [
{
label: __(
'Download and install manually',
'woocommerce'
),
url: 'https://woocommerce.com/my-account/downloads/',
},
],
}
);
stopInstall();
if ( props.onError ) {
props.onError();
}
} );
recordEvent( 'marketplace_product_install_failed', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_current_version: props.subscription.version,
error_message: error?.data?.message,
} );
};
const install = () => {
recordEvent( 'marketplace_product_install_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
@ -90,71 +132,26 @@ export default function Install( props: InstallProps ) {
props.onSuccess();
}
} )
.catch( ( error ) => {
loadSubscriptions( false ).then( () => {
let errorMessage = sprintf(
// translators: %s is the product name.
__( '%s couldnt be installed.', 'woocommerce' ),
props.subscription.product_name
);
if ( error?.success === false && error?.data.message ) {
errorMessage += ' ' + error.data.message;
}
addNotice(
props.subscription.product_key,
errorMessage,
NoticeStatus.Error,
{
actions: [
{
label: __( 'Try again', 'woocommerce' ),
onClick: install,
},
],
}
);
stopInstall();
if ( props.onError ) {
props.onError();
}
} );
recordEvent( 'marketplace_product_install_failed', {
.catch( handleInstallError );
} else {
getInstallUrl( props.subscription )
.then( ( url: string ) => {
recordEvent( 'marketplace_product_install_url', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_current_version: props.subscription.version,
error_message: error?.data?.message,
product_install_url: url,
} );
} );
} else {
getInstallUrl( props.subscription ).then( ( url: string ) => {
recordEvent( 'marketplace_product_install_url', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_current_version: props.subscription.version,
product_install_url: url,
} );
stopInstall();
stopInstall();
if ( url ) {
window.open( url, '_self' );
} else {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__(
'%s couldnt be installed. Please install the product manually.',
'woocommerce'
),
props.subscription.product_name
),
NoticeStatus.Error
);
}
} );
if ( url ) {
window.open( url, '_self' );
} else {
throw new Error();
}
} )
.catch( handleInstallError );
}
};

View File

@ -7,11 +7,6 @@
margin: 0;
}
#wpbody-content {
/* Prevent double-scrollbar issue on WooCommerce > Extension pages */
overflow: hidden;
}
.woocommerce-layout__primary {
margin: 0;
}

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import domReady from '@wordpress/dom-ready';
import { recordEvent } from '@woocommerce/tracks';
domReady( () => {
const purchaseSubscriptionLink = document.querySelectorAll(
'.woocommerce-purchase-subscription'
);
if ( purchaseSubscriptionLink.length > 0 ) {
recordEvent( 'woo_purchase_subscription_in_plugins_shown' );
purchaseSubscriptionLink.forEach( ( link ) => {
link.addEventListener( 'click', function () {
recordEvent( 'woo_purchase_subscription_in_plugins_clicked' );
} );
} );
}
} );

View File

@ -83,7 +83,7 @@ class SimpleInboxNote {
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
// Set the type of layout the note uses. Supported layout types are:
// 'banner', 'plain', 'thumbnail'.
// 'plain', 'thumbnail'.
$note->set_layout( 'plain' );
// Set the image for the note. This property renders as the src

View File

@ -73,7 +73,6 @@ function get_mock_note_data() {
return array(
'content' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud.',
'info' => array(
'banner' => $plugin_url . 'images/admin-notes/banner.jpg',
'thumbnail' => $plugin_url . 'images/admin-notes/thumbnail.jpg',
'plain' => '',
),

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Deprecate unsupported Inbox note banner layout

View File

@ -1,114 +1,26 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import clsx from 'clsx';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
import {
ProductRating,
getAverageRating,
getRatingCount,
} from '@woocommerce/editor-components/product-rating';
/**
* Internal dependencies
*/
import './style.scss';
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ clsx(
'wc-block-components-product-rating-stars__norating-container',
`${ parentClassName }-product-rating-stars__norating-container`
) }
>
<div
className={
'wc-block-components-product-rating-stars__norating'
}
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woocommerce' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woocommerce' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woocommerce'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ clsx(
'wc-block-components-product-rating-stars__stars',
`${ parentClassName }__product-rating-stars__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
interface ProductRatingStarsProps {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
@ -116,42 +28,29 @@ interface ProductRatingStarsProps {
}
export const Block = ( props: ProductRatingStarsProps ): JSX.Element | null => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const {
textAlign = '',
shouldDisplayMockedReviewsWhenProductHasNoReviews,
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const reviews = getRatingCount( product );
const className = clsx(
styleProps.className,
'wc-block-components-product-rating-stars',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
const className = 'wc-block-components-product-rating-stars';
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-stars__container">
{ content }
</div>
</div>
<ProductRating
className={ className }
showMockedReviews={
shouldDisplayMockedReviewsWhenProductHasNoReviews
}
styleProps={ styleProps }
parentClassName={ parentClassName }
reviews={ reviews }
rating={ rating }
textAlign={ textAlign }
/>
);
};

View File

@ -1,129 +1,23 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import clsx from 'clsx';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
import {
ProductRating,
getAverageRating,
getRatingCount,
} from '@woocommerce/editor-components/product-rating';
/**
* Internal dependencies
*/
import './style.scss';
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ clsx(
'wc-block-components-product-rating__norating-container',
`${ parentClassName }-product-rating__norating-container`
) }
>
<div
className={ 'wc-block-components-product-rating__norating' }
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woocommerce' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woocommerce' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woocommerce'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ clsx(
'wc-block-components-product-rating__stars',
`${ parentClassName }__product-rating__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
const reviewsCount = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woocommerce'
),
reviews
);
return (
<span className="wc-block-components-product-rating__reviews_count">
{ reviewsCount }
</span>
);
};
type ProductRatingProps = {
className?: string;
textAlign?: string;
@ -136,7 +30,7 @@ type ProductRatingProps = {
export const Block = ( props: ProductRatingProps ): JSX.Element | undefined => {
const {
textAlign,
textAlign = '',
isDescendentOfSingleProductBlock,
shouldDisplayMockedReviewsWhenProductHasNoReviews,
} = props;
@ -146,38 +40,22 @@ export const Block = ( props: ProductRatingProps ): JSX.Element | undefined => {
const rating = getAverageRating( product );
const reviews = getRatingCount( product );
const className = clsx(
styleProps.className,
'wc-block-components-product-rating',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
const className = 'wc-block-components-product-rating';
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating__container">
{ content }
{ reviews && isDescendentOfSingleProductBlock ? (
<ReviewsCount reviews={ reviews } />
) : null }
</div>
</div>
<ProductRating
className={ className }
showReviewCount={ isDescendentOfSingleProductBlock }
showMockedReviews={
shouldDisplayMockedReviewsWhenProductHasNoReviews
}
styleProps={ styleProps }
parentClassName={ parentClassName }
reviews={ reviews }
rating={ rating }
textAlign={ textAlign }
/>
);
}
};

View File

@ -5,6 +5,7 @@ import { ValidatedTextInput } from '@woocommerce/blocks-components';
import { AddressFormValues, ContactFormValues } from '@woocommerce/settings';
import { useState, Fragment, useCallback } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@ariakit/react';
/**
* Internal dependencies
@ -50,7 +51,8 @@ const AddressLine2Field = < T extends AddressFormValues | ContactFormValues >( {
/>
) : (
<>
<button
<Button
render={ <span /> }
className={
'wc-block-components-address-form__address_2-toggle'
}
@ -61,7 +63,7 @@ const AddressLine2Field = < T extends AddressFormValues | ContactFormValues >( {
__( '+ Add %s', 'woocommerce' ),
field.label.toLowerCase()
) }
</button>
</Button>
<input
type="text"
tabIndex={ -1 }

View File

@ -18,7 +18,7 @@
&:focus {
background-color: $input-background-dark;
color: $input-text-dark;
box-shadow: 0 0 0 2px $input-border-gray;
box-shadow: 0 0 0 2px $input-border-dark;
}
}
@ -40,12 +40,16 @@
color: $input-text-active;
&:focus {
outline: 0;
box-shadow: 0 0 0 1px inherit;
outline: none;
box-shadow: 0 0 0 2px $input-border-gray;
}
.has-dark-controls & {
color: $input-text-dark;
&:focus {
box-shadow: 0 0 0 2px $input-border-dark;
}
}
.has-error & {

View File

@ -11,6 +11,7 @@ import {
} from '@woocommerce/types';
import { FormFieldsConfig, getSetting } from '@woocommerce/settings';
import { formatAddress } from '@woocommerce/blocks/checkout/utils';
import { Button } from '@ariakit/react';
/**
* Internal dependencies
@ -82,7 +83,8 @@ const AddressCard = ( {
) }
</address>
{ onEdit && (
<button
<Button
render={ <span /> }
className="wc-block-components-address-card__edit"
aria-controls={ target }
aria-expanded={ isExpanded }
@ -94,7 +96,7 @@ const AddressCard = ( {
type="button"
>
{ __( 'Edit', 'woocommerce' ) }
</button>
</Button>
) }
</div>
);

View File

@ -10,6 +10,7 @@ import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { isPackageRateCollectable } from '@woocommerce/base-utils';
import { getSetting } from '@woocommerce/settings';
import { Button } from '@ariakit/react';
/**
* Internal dependencies
@ -18,7 +19,6 @@ import { RatePrice, getLocalPickupPrices, getShippingPrices } from './shared';
import type { minMaxPrices } from './shared';
import { defaultLocalPickupText, defaultShippingText } from './constants';
import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils';
import Button from '../../../../base/components/button';
const SHIPPING_RATE_ERROR = {
hidden: true,
@ -44,8 +44,8 @@ const LocalPickupSelector = ( {
} ) => {
return (
<Button
render={ <div /> }
role="radio"
removeTextWrap
onClick={ onClick }
className={ clsx( 'wc-block-checkout__shipping-method-option', {
'wc-block-checkout__shipping-method-option--selected':
@ -129,9 +129,9 @@ const ShippingSelector = ( {
return (
<Button
render={ <div /> }
role="radio"
onClick={ onClick }
removeTextWrap
className={ clsx( 'wc-block-checkout__shipping-method-option', {
'wc-block-checkout__shipping-method-option--selected':
checked === 'shipping',

View File

@ -13,7 +13,7 @@ import {
useBlockProps,
RichText,
} from '@wordpress/block-editor';
import Button from '@woocommerce/base-components/button';
import { Button } from '@ariakit/react';
import { useShippingData } from '@woocommerce/base-context/hooks';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
import { useDispatch, useSelect } from '@wordpress/data';
@ -53,12 +53,12 @@ const LocalPickupSelector = ( {
} ) => {
return (
<Button
render={ <div /> }
className={ clsx( 'wc-block-checkout__shipping-method-option', {
'wc-block-checkout__shipping-method-option--selected':
checked === 'pickup',
} ) }
onClick={ onClick }
removeTextWrap
>
{ showIcon === true && (
<Icon
@ -113,12 +113,12 @@ const ShippingSelector = ( {
return (
<Button
render={ <div /> }
className={ clsx( 'wc-block-checkout__shipping-method-option', {
'wc-block-checkout__shipping-method-option--selected':
checked === 'shipping',
} ) }
onClick={ onClick }
removeTextWrap
>
{ showIcon === true && (
<Icon

View File

@ -9,22 +9,23 @@
// We have avoided nesting all the styles in case specificity changes introduce regressions elsewhere.
.edit-post-visual-editor {
.wc-block-checkout__shipping-method-container {
.wc-block-components-button.wc-block-checkout__shipping-method-option {
.wc-block-checkout__shipping-method-option {
min-height: 80px;
}
}
}
.edit-post-visual-editor
.wc-block-components-button.wc-block-checkout__shipping-method-option,
.wc-block-components-button.wc-block-checkout__shipping-method-option {
.edit-post-visual-editor .wc-block-checkout__shipping-method-option,
.wc-block-checkout__shipping-method-option {
flex-grow: 1;
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
align-items: center;
height: 100%;
min-height: 80px;
box-sizing: border-box;
flex-basis: 0;
gap: 4px;
padding: 16px 12px;
@ -35,8 +36,8 @@
outline: 1px solid $universal-border-light !important; // Overwriting Gutenberg styles
border-radius: $universal-border-radius;
cursor: pointer;
&.components-button:hover:not(:disabled),
&.components-button:focus:not(:disabled),
&:hover:not(:disabled),
&:focus:not(:disabled),
&:focus,
&:hover {
background-color: $universal-background;
@ -65,6 +66,8 @@
.wc-block-checkout__shipping-method-option-price {
@include font-size(small, 1rem);
flex-basis: 100%;
text-align: center;
em {
text-transform: uppercase;

View File

@ -1,3 +1,16 @@
.wc-block-order-confirmation-status-description {
.woocommerce-verify-email {
margin-top: $gap-larger;
#verify-email {
width: 50%;
@include breakpoint("<782px") {
width: 100%;
}
}
}
}
.wc-block-order-confirmation-status {
margin-top: $gap;
margin-bottom: $gap;

View File

@ -36,12 +36,6 @@
}
}
}
.woocommerce-verify-email {
margin-top: $gap-larger;
#verify-email {
width: 50%;
}
}
&.has-background {
padding: $gap;

View File

@ -0,0 +1,26 @@
/**
* Internal dependencies
*/
import './style.scss';
/**
* The reason for using this component instead of the core/disabled component is
* that the Disabled component disrupts the focus on inner blocks. For example,
* when a heading block is nested inside, the text cursor, which indicates the
* editable area, isn't visible when focused on the heading block.
*
* This component only uses CSS to control the selected behavior of inner
* blocks, which fixes the abovementioned issues. However, being a static
* component comes with a limitation: this component is meant to be placed
* directly inside the block wrapper element that holds block props.
*/
export const InitialDisabled = ( {
children,
}: {
children: React.ReactNode;
} ): JSX.Element => (
<div className="wc-block-product-filter-components-initial-disabled">
<div className="wc-block-product-filter-components-initial-disabled-overlay" />
{ children }
</div>
);

View File

@ -0,0 +1,17 @@
.wc-block-product-filter-components-initial-disabled {
position: relative;
.wc-block-product-filter-components-initial-disabled-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
.is-selected > &,
.has-child-selected > & {
z-index: -1;
}
}
}

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/components';
import { info } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './style.scss';
/**
* A custom notice component is designed specifically for new filter blocks. We
* are not reusing the existing components because we have a new design for the
* filter blocks notice. We want users to utilize the sidebar for attribute
* settings, so we are keeping the new notice minimal."
*/
export const Notice = ( { children }: { children: React.ReactNode } ) => (
<div className="wc-block-product-filter-components-notice">
<Icon
className="wc-block-product-filter-components-notice__icon"
icon={ info }
/>
<div className="wc-block-product-filter-components-notice__content">
{ children }
</div>
</div>
);

View File

@ -0,0 +1,17 @@
.wc-block-product-filter-components-notice {
display: flex;
padding: $gap;
gap: $gap-smaller;
border: 1px solid;
&__icon {
fill: $alert-red;
}
&__content {
> * {
padding: 0;
margin: 0;
}
}
}

View File

@ -3,3 +3,9 @@ export const BlockOverlayAttribute = {
MOBILE: 'mobile',
ALWAYS: 'always',
} as const;
export const EXCLUDED_BLOCKS = [
'woocommerce/product-filter-attribute',
'woocommerce/product-collection',
'core/query',
];

View File

@ -2,7 +2,6 @@
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { AttributeSetting } from '@woocommerce/types';
import {
InnerBlocks,
InspectorControls,
@ -37,10 +36,6 @@ import './editor.scss';
import { type BlockAttributes } from './types';
import { BlockOverlayAttribute } from './constants';
const defaultAttribute = getSetting< AttributeSetting >(
'defaultProductFilterAttribute'
);
const TEMPLATE: InnerBlockTemplate[] = [
[
'core/heading',
@ -50,42 +45,8 @@ const TEMPLATE: InnerBlockTemplate[] = [
content: __( 'Filters', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'active-filters',
heading: __( 'Active', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'price-filter',
heading: __( 'Price', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'stock-filter',
heading: __( 'Status', 'woocommerce' ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'attribute-filter',
heading: defaultAttribute.attribute_label,
attributeId: parseInt( defaultAttribute.attribute_id, 10 ),
},
],
[
'woocommerce/product-filter',
{
filterType: 'rating-filter',
heading: __( 'Rating', 'woocommerce' ),
},
],
[ 'woocommerce/product-filter-active' ],
[ 'woocommerce/product-filter-attribute' ],
[
'core/buttons',
{ layout: { type: 'flex' } },

View File

@ -1,7 +1,12 @@
/**
* External dependencies
*/
import { getContext as getContextFn, store } from '@woocommerce/interactivity';
import {
getContext as getContextFn,
store,
navigate as navigateFn,
} from '@woocommerce/interactivity';
import { getSetting } from '@woocommerce/settings';
export interface ProductFiltersContext {
isDialogOpen: boolean;
@ -11,7 +16,7 @@ export interface ProductFiltersContext {
const getContext = ( ns?: string ) =>
getContextFn< ProductFiltersContext >( ns );
const productFilters = {
store( 'woocommerce/product-filters', {
state: {
isDialogOpen: () => {
const context = getContext();
@ -36,8 +41,41 @@ const productFilters = {
},
},
callbacks: {},
};
} );
store( 'woocommerce/product-filters', productFilters );
const isBlockTheme = getSetting< boolean >( 'isBlockTheme' );
const isProductArchive = getSetting< boolean >( 'isProductArchive' );
const needsRefresh = getSetting< boolean >(
'needsRefreshForInteractivityAPI',
false
);
export type ProductFilters = typeof productFilters;
export function navigate( href: string, options = {} ) {
/**
* We may need to reset the current page when changing filters.
* This is because the current page may not exist for this set
* of filters and will 404 when the user navigates to it.
*
* There are different pagination formats to consider, as documented here:
* https://github.com/WordPress/gutenberg/blob/317eb8f14c8e1b81bf56972cca2694be250580e3/packages/block-library/src/query-pagination-numbers/index.php#L22-L85
*/
const url = new URL( href );
// When pretty permalinks are enabled, the page number may be in the path name.
url.pathname = url.pathname.replace( /\/page\/[0-9]+/i, '' );
// When plain permalinks are enabled, the page number may be in the "paged" query parameter.
url.searchParams.delete( 'paged' );
// On posts and pages the page number will be in a query parameter that
// identifies which block we are paginating.
url.searchParams.forEach( ( _, key ) => {
if ( key.match( /^query(?:-[0-9]+)?-page$/ ) ) {
url.searchParams.delete( key );
}
} );
// Make sure to update the href with the changes.
href = url.href;
if ( needsRefresh || ( ! isBlockTheme && isProductArchive ) ) {
return ( window.location.href = href );
}
return navigateFn( href, options );
}

View File

@ -2,7 +2,7 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "woocommerce/product-filter-active",
"version": "1.0.0",
"title": "Filter Options",
"title": "Active (Experimental)",
"description": "Display the currently active filters.",
"category": "woocommerce",
"keywords": [
@ -11,11 +11,10 @@
"textdomain": "woocommerce",
"apiVersion": 3,
"ancestor": [
"woocommerce/product-filter"
"woocommerce/product-filters"
],
"supports": {
"interactivity": true,
"inserter": false,
"color": {
"text": true,
"background": false
@ -27,7 +26,7 @@
"attributes": {
"displayStyle": {
"type": "string",
"default": "list"
"default": "chips"
}
}
}
}

View File

@ -6,7 +6,7 @@ import { store, getContext } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
import { navigate } from '../product-filter/frontend';
import { navigate } from '../../frontend';
type ActiveFiltersContext = {
queryId: number;

View File

@ -3,7 +3,7 @@
*/
import { registerBlockType } from '@wordpress/blocks';
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
import { productFilterOptions } from '@woocommerce/icons';
import { productFilterActive } from '@woocommerce/icons';
/**
* Internal dependencies
@ -14,7 +14,7 @@ import './style.scss';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
icon: productFilterOptions,
icon: productFilterActive,
edit: Edit,
} );
}

View File

@ -2,7 +2,7 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "woocommerce/product-filter-attribute",
"version": "1.0.0",
"title": "Filter Options",
"title": "Attribute (Experimental)",
"description": "Enable customers to filter the product grid by selecting one or more attributes, such as color.",
"category": "woocommerce",
"keywords": [
@ -11,11 +11,10 @@
"textdomain": "woocommerce",
"apiVersion": 3,
"ancestor": [
"woocommerce/product-filter"
"woocommerce/product-filters"
],
"supports": {
"interactivity": true,
"inserter": false,
"color": {
"text": true,
"background": false,
@ -78,7 +77,7 @@
},
"displayStyle": {
"type": "string",
"default": "list"
"default": "woocommerce/product-filter-checkbox-list"
},
"selectType": {
"type": "string",
@ -100,5 +99,10 @@
"type": "boolean",
"default":true
}
},
"example": {
"attributes": {
"isPreview": true
}
}
}
}

View File

@ -1,29 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Icon, chevronDown } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { PreviewDropdown } from '../../components/preview-dropdown';
type Props = {
label: string;
};
export const AttributeDropdown = ( { label }: Props ) => {
return (
<div className="wc-block-attribute-filter style-dropdown">
<PreviewDropdown
placeholder={ sprintf(
/* translators: %s attribute name. */
__( 'Select %s', 'woocommerce' ),
label
) }
/>
<Icon icon={ chevronDown } size={ 30 } />
</div>
);
};

View File

@ -1,86 +0,0 @@
/**
* External dependencies
*/
import { sort } from 'fast-sort';
import { __, sprintf, _n } from '@wordpress/i18n';
import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
import { getSetting } from '@woocommerce/settings';
import { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
import { AttributeSetting } from '@woocommerce/types';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
type AttributeSelectControlsProps = {
isCompact: boolean;
setAttributeId: ( id: number ) => void;
attributeId: number;
};
export const AttributeSelectControls = ( {
isCompact,
setAttributeId,
attributeId,
}: AttributeSelectControlsProps ) => {
const messages = {
clear: __( 'Clear selected attribute', 'woocommerce' ),
list: __( 'Product Attributes', 'woocommerce' ),
noItems: __(
"Your store doesn't have any product attributes.",
'woocommerce'
),
search: __( 'Search for a product attribute:', 'woocommerce' ),
selected: ( n: number ) =>
sprintf(
/* translators: %d is the number of attributes selected. */
_n(
'%d attribute selected',
'%d attributes selected',
n,
'woocommerce'
),
n
),
updated: __(
'Product attribute search results updated.',
'woocommerce'
),
};
const list = sort(
ATTRIBUTES.map( ( item ) => {
return {
id: parseInt( item.attribute_id, 10 ),
name: item.attribute_label,
};
} )
).asc( 'name' );
const onChange = ( selected: SearchListItem[] ) => {
if ( ! selected || ! selected.length ) {
return;
}
const selectedId = selected[ 0 ].id;
const productAttribute = ATTRIBUTES.find(
( attribute ) => attribute.attribute_id === selectedId.toString()
);
if ( ! productAttribute || attributeId === selectedId ) {
return;
}
setAttributeId( selectedId as number );
};
return (
<SearchListControl
className="woocommerce-product-attributes"
list={ list }
selected={ list.filter( ( { id } ) => id === attributeId ) }
onChange={ onChange }
messages={ messages }
isSingle
isCompact={ isCompact }
/>
);
};

View File

@ -1,68 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Ideally, this component should belong to packages/interactivity-components.
* But we haven't export it as a packages so we place it here temporary.
*/
export const Preview = ( { items }: { items: string[] } ) => {
const threshold = 15;
const isLongList = items.length > threshold;
return (
<div className="wc-block-interactivity-components-checkbox-list">
<ul className="wc-block-interactivity-components-checkbox-list__list">
{ ( isLongList ? items.slice( 0, threshold ) : items ).map(
( item, index ) => (
<li
key={ index }
className="wc-block-interactivity-components-checkbox-list__item"
>
<label
htmlFor={ `interactive-checkbox-${ index }` }
className=" wc-block-interactivity-components-checkbox-list__label"
>
<span className="wc-block-interactive-components-checkbox-list__input-wrapper">
<span className="wc-block-interactivity-components-checkbox-list__input-wrapper">
<input
name={ `interactive-checkbox-${ index }` }
type="checkbox"
className="wc-block-interactivity-components-checkbox-list__input"
// Harded coded some checked items for styling purpose.
defaultChecked={ [
1, 3, 4,
].includes( index ) }
/>
<svg
className="wc-block-interactivity-components-checkbox-list__mark"
viewBox="0 0 10 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.25 1.19922L3.75 6.69922L1 3.94922"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</span>
<span className="wc-block-interactivity-components-checkbox-list__text">
{ item }
</span>
</label>
</li>
)
) }
</ul>
{ isLongList && (
<span className="wc-block-interactivity-components-checkbox-list__show-more">
<small>{ __( 'Show more…', 'woocommerce' ) }</small>
</span>
) }
</div>
);
};

View File

@ -1,55 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, category, external } from '@wordpress/icons';
import { getAdminLink } from '@woocommerce/settings';
import { Placeholder, Button } from '@wordpress/components';
export const AttributesPlaceholder = ( {
children,
}: {
children: React.ReactNode;
} ) => (
<Placeholder
className="wc-block-attribute-filter"
icon={ <Icon icon={ category } /> }
label={ __( 'Filter by Attribute', 'woocommerce' ) }
instructions={ __(
'Enable customers to filter the product grid by selecting one or more attributes, such as color.',
'woocommerce'
) }
>
{ children }
</Placeholder>
);
export const NoAttributesPlaceholder = () => (
<AttributesPlaceholder>
<p>
{ __(
"Attributes are needed for filtering your products. You haven't created any attributes yet.",
'woocommerce'
) }
</p>
<Button
className="wc-block-attribute-filter__add-attribute-button"
variant="secondary"
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
target="_top"
>
{ __( 'Add new attribute', 'woocommerce' ) + ' ' }
<Icon icon={ external } />
</Button>
<Button
className="wc-block-attribute-filter__read_more_button"
variant="tertiary"
href="https://woocommerce.com/document/managing-product-taxonomies/"
target="_blank"
>
{ __( 'Learn more', 'woocommerce' ) }
</Button>
</AttributesPlaceholder>
);

View File

@ -5,49 +5,71 @@ import { __ } from '@wordpress/i18n';
export const attributeOptionsPreview = [
{
id: 23,
name: __( 'Blue', 'woocommerce' ),
slug: 'blue',
attr_slug: 'blue',
description: '',
parent: 0,
count: 4,
label: __( 'Blue', 'woocommerce' ),
value: 'blue',
rawData: {
id: 23,
name: __( 'Blue', 'woocommerce' ),
slug: 'blue',
attr_slug: 'blue',
description: '',
parent: 0,
count: 4,
},
},
{
id: 29,
name: __( 'Gray', 'woocommerce' ),
slug: 'gray',
attr_slug: 'gray',
description: '',
parent: 0,
count: 3,
label: __( 'Gray', 'woocommerce' ),
value: 'gray',
selected: true,
rawData: {
id: 29,
name: __( 'Gray', 'woocommerce' ),
slug: 'gray',
attr_slug: 'gray',
description: '',
parent: 0,
count: 3,
},
},
{
id: 24,
name: __( 'Green', 'woocommerce' ),
slug: 'green',
attr_slug: 'green',
description: '',
parent: 0,
count: 3,
label: __( 'Green', 'woocommerce' ),
value: 'green',
rawData: {
id: 24,
name: __( 'Green', 'woocommerce' ),
slug: 'green',
attr_slug: 'green',
description: '',
parent: 0,
count: 3,
},
},
{
id: 25,
name: __( 'Red', 'woocommerce' ),
slug: 'red',
attr_slug: 'red',
description: '',
parent: 0,
count: 4,
label: __( 'Red', 'woocommerce' ),
value: 'red',
selected: true,
rawData: {
id: 25,
name: __( 'Red', 'woocommerce' ),
slug: 'red',
attr_slug: 'red',
description: '',
parent: 0,
count: 4,
},
},
{
id: 30,
name: __( 'Yellow', 'woocommerce' ),
slug: 'yellow',
attr_slug: 'yellow',
description: '',
parent: 0,
count: 1,
label: __( 'Yellow', 'woocommerce' ),
value: 'yellow',
rawData: {
id: 30,
name: __( 'Yellow', 'woocommerce' ),
slug: 'yellow',
attr_slug: 'yellow',
description: '',
parent: 0,
count: 1,
},
},
];

View File

@ -5,29 +5,34 @@ import {
useCollection,
useCollectionData,
} from '@woocommerce/base-context/hooks';
import { getSetting } from '@woocommerce/settings';
import {
AttributeSetting,
AttributeTerm,
objectHasProp,
} from '@woocommerce/types';
import { useBlockProps } from '@wordpress/block-editor';
import { Disabled, Notice, withSpokenMessages } from '@wordpress/components';
import { useEffect, useState, useMemo } from '@wordpress/element';
import {
useBlockProps,
useInnerBlocksProps,
BlockContextProvider,
} from '@wordpress/block-editor';
import { withSpokenMessages } from '@wordpress/components';
import { useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import { AttributeDropdown } from './components/attribute-dropdown';
import { Preview as CheckboxListPreview } from './components/checkbox-list-editor';
import { Inspector } from './components/inspector';
import { NoAttributesPlaceholder } from './components/placeholder';
import { Inspector } from './inspector';
import { attributeOptionsPreview } from './constants';
import './style.scss';
import { EditProps, isAttributeCounts } from './types';
import { getAttributeFromId } from './utils';
import './editor.scss';
import { getAllowedBlocks } from '../../utils';
import { EXCLUDED_BLOCKS } from '../../constants';
import { FilterOptionItem } from '../../types';
import { InitialDisabled } from '../../components/initial-disabled';
import { Notice } from '../../components/notice';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
@ -47,8 +52,10 @@ const Edit = ( props: EditProps ) => {
const attributeObject = getAttributeFromId( attributeId );
const [ attributeOptions, setAttributeOptions ] = useState<
AttributeTerm[]
FilterOptionItem[]
>( [] );
const [ isOptionsLoading, setIsOptionsLoading ] =
useState< boolean >( true );
const { results: attributeTerms, isLoading: isTermsLoading } =
useCollection< AttributeTerm >( {
@ -59,7 +66,7 @@ const Edit = ( props: EditProps ) => {
query: { orderby: 'menu_order', hide_empty: hideEmpty },
} );
const { results: filteredCounts, isLoading: isCountsLoading } =
const { results: filteredCounts, isLoading: isFilterCountsLoading } =
useCollectionData( {
queryAttribute: {
taxonomy: attributeObject?.taxonomy || '',
@ -69,90 +76,137 @@ const Edit = ( props: EditProps ) => {
isEditor: true,
} );
const isLoading = isTermsLoading || isCountsLoading;
useEffect( () => {
if ( isTermsLoading || isFilterCountsLoading ) return;
const termIdHasProducts =
objectHasProp( filteredCounts, 'attribute_counts' ) &&
isAttributeCounts( filteredCounts.attribute_counts )
? filteredCounts.attribute_counts.map( ( term ) => term.term )
: [];
if ( termIdHasProducts.length === 0 && hideEmpty )
return setAttributeOptions( [] );
if ( termIdHasProducts.length === 0 && hideEmpty ) {
setAttributeOptions( [] );
} else {
setAttributeOptions(
attributeTerms
.filter( ( term ) => {
if ( hideEmpty )
return termIdHasProducts.includes( term.id );
return true;
} )
.sort( ( a, b ) => {
switch ( sortOrder ) {
case 'name-asc':
return a.name > b.name ? 1 : -1;
case 'name-desc':
return a.name < b.name ? 1 : -1;
case 'count-asc':
return a.count > b.count ? 1 : -1;
case 'count-desc':
default:
return a.count < b.count ? 1 : -1;
}
} )
.map( ( term, index ) => ( {
label: showCounts
? `${ term.name } (${ term.count })`
: term.name,
value: term.id.toString(),
selected: index === 1,
rawData: term,
} ) )
);
}
setAttributeOptions(
attributeTerms
.filter( ( term ) => {
if ( hideEmpty )
return termIdHasProducts.includes( term.id );
return true;
} )
.sort( ( a, b ) => {
switch ( sortOrder ) {
case 'name-asc':
return a.name > b.name ? 1 : -1;
case 'name-desc':
return a.name < b.name ? 1 : -1;
case 'count-asc':
return a.count > b.count ? 1 : -1;
case 'count-desc':
default:
return a.count < b.count ? 1 : -1;
}
} )
);
}, [ attributeTerms, filteredCounts, sortOrder, hideEmpty ] );
setIsOptionsLoading( false );
}, [
showCounts,
attributeTerms,
filteredCounts,
sortOrder,
hideEmpty,
isTermsLoading,
isFilterCountsLoading,
] );
const Wrapper = ( { children }: { children: React.ReactNode } ) => (
<div { ...useBlockProps() }>
<Inspector { ...props } />
{ children }
</div>
const { children, ...innerBlocksProps } = useInnerBlocksProps(
useBlockProps(),
{
allowedBlocks: getAllowedBlocks( EXCLUDED_BLOCKS ),
template: [
[
'core/group',
{
layout: {
type: 'flex',
flexWrap: 'nowrap',
},
metadata: {
name: __( 'Header', 'woocommerce' ),
},
style: {
spacing: {
blockGap: '0',
},
},
},
[
[
'core/heading',
{
level: 3,
content:
attributeObject?.label ||
__( 'Attribute', 'woocommerce' ),
},
],
[
'woocommerce/product-filter-clear-button',
{
lock: {
remove: true,
move: false,
},
},
],
],
],
[
displayStyle,
{
lock: {
remove: true,
},
},
],
],
}
);
const loadingState = useMemo( () => {
return [ ...Array( 5 ) ].map( ( x, i ) => (
<li
key={ i }
style={ {
/* stylelint-disable */
width: Math.floor( Math.random() * ( 100 - 25 ) ) + '%',
} }
>
&nbsp;
</li>
) );
}, [] );
const isLoading =
isTermsLoading || isFilterCountsLoading || isOptionsLoading;
if ( isPreview ) {
return (
<Wrapper>
<Disabled>
<CheckboxListPreview
items={ attributeOptionsPreview.map( ( term ) => {
if ( showCounts )
return `${ term.name } (${ term.count })`;
return term.name;
} ) }
/>
</Disabled>
</Wrapper>
);
}
// Block rendering starts.
if ( Object.keys( ATTRIBUTES ).length === 0 )
return (
<Wrapper>
<NoAttributesPlaceholder />
</Wrapper>
<div { ...innerBlocksProps }>
<Inspector { ...props } />
<Notice>
<p>
{ __(
"Attributes are needed for filtering your products. You haven't created any attributes yet.",
'woocommerce'
) }
</p>
</Notice>
</div>
);
if ( ! attributeId || ! attributeObject )
return (
<Wrapper>
<Notice status="warning" isDismissible={ false }>
<div { ...innerBlocksProps }>
<Inspector { ...props } />
<Notice>
<p>
{ __(
'Please select an attribute to use this filter!',
@ -160,22 +214,14 @@ const Edit = ( props: EditProps ) => {
) }
</p>
</Notice>
</Wrapper>
</div>
);
if ( isLoading )
if ( ! isLoading && attributeTerms.length === 0 )
return (
<Wrapper>
<ul className="is-loading wp-block-woocommerce-product-filter-attribute__loading">
{ loadingState }
</ul>
</Wrapper>
);
if ( attributeTerms.length === 0 )
return (
<Wrapper>
<Notice status="warning" isDismissible={ false }>
<div { ...innerBlocksProps }>
<Inspector { ...props } />
<Notice>
<p>
{ __(
'There are no products with the selected attributes.',
@ -183,30 +229,28 @@ const Edit = ( props: EditProps ) => {
) }
</p>
</Notice>
</Wrapper>
</div>
);
return (
<Wrapper>
<Disabled>
{ displayStyle === 'dropdown' ? (
<AttributeDropdown
label={
attributeObject.label ||
__( 'attribute', 'woocommerce' )
}
/>
) : (
<CheckboxListPreview
items={ attributeOptions.map( ( term ) => {
if ( showCounts )
return `${ term.name } (${ term.count })`;
return term.name;
} ) }
/>
) }
</Disabled>
</Wrapper>
<div { ...innerBlocksProps }>
<Inspector { ...props } />
<InitialDisabled>
<BlockContextProvider
value={ {
filterData: {
items:
attributeOptions.length === 0 && isPreview
? attributeOptionsPreview
: attributeOptions,
isLoading,
},
} }
>
{ children }
</BlockContextProvider>
</InitialDisabled>
</div>
);
};

View File

@ -1,8 +0,0 @@
.wp-block-woocommerce-product-filter-attribute__loading {
padding: 0;
li {
@include placeholder();
margin: 5px 0;
}
}

View File

@ -8,7 +8,7 @@ import { HTMLElementEvent } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { navigate } from '../product-filter/frontend';
import { navigate } from '../../frontend';
type AttributeFilterContext = {
attributeSlug: string;
@ -106,5 +106,12 @@ store( 'woocommerce/product-filter-attribute', {
navigate( getUrl( selectedTerms, attributeSlug, queryType ) );
},
clearFilters: () => {
const { attributeSlug, queryType } =
getContext< ActiveAttributeFilterContext >();
navigate( getUrl( [], attributeSlug, queryType ) );
},
},
} );

View File

@ -2,18 +2,21 @@
* External dependencies
*/
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
import { productFilterOptions } from '@woocommerce/icons';
import { productFilterAttribute } from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
import { registerBlockType } from '@wordpress/blocks';
import { AttributeSetting } from '@woocommerce/types';
import { registerBlockType } from '@wordpress/blocks';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import metadata from './block.json';
import Edit from './edit';
import Save from './save';
import './style.scss';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
if ( isExperimentalBlocksEnabled() ) {
const defaultAttribute = getSetting< AttributeSetting >(
'defaultProductFilterAttribute'
@ -21,7 +24,7 @@ if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
edit: Edit,
icon: productFilterOptions,
icon: productFilterAttribute,
attributes: {
...metadata.attributes,
attributeId: {
@ -29,5 +32,25 @@ if ( isExperimentalBlocksEnabled() ) {
default: parseInt( defaultAttribute.attribute_id, 10 ),
},
},
save: Save,
variations: ATTRIBUTES.map( ( attribute, index ) => {
return {
name: `product-filter-attribute-${ attribute.attribute_name }`,
title: `${ attribute.attribute_label } (Experimental)`,
description: sprintf(
// translators: %s is the attribute label.
__(
`Enable customers to filter the product collection by selecting one or more %s attributes.`,
'woocommerce'
),
attribute.attribute_label
),
attributes: {
attributeId: parseInt( attribute.attribute_id, 10 ),
},
isActive: [ 'attributeId' ],
isDefault: index === 0,
};
} ),
} );
}

View File

@ -5,8 +5,9 @@ import { getSetting } from '@woocommerce/settings';
import { AttributeSetting } from '@woocommerce/types';
import { InspectorControls } from '@wordpress/block-editor';
import { dispatch, useSelect } from '@wordpress/data';
import { createInterpolateElement } from '@wordpress/element';
import { createInterpolateElement, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Block, getBlockTypes, createBlock } from '@wordpress/blocks';
import {
ComboboxControl,
PanelBody,
@ -23,12 +24,15 @@ import {
/**
* Internal dependencies
*/
import { sortOrderOptions } from '../constants';
import { BlockAttributes, EditProps } from '../types';
import { getAttributeFromId } from '../utils';
import { sortOrderOptions } from './constants';
import { BlockAttributes, EditProps } from './types';
import { getAttributeFromId } from './utils';
import { getInnerBlockByName } from '../../utils';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
let displayStyleOptions: Block[] = [];
export const Inspector = ( {
clientId,
attributes,
@ -43,47 +47,29 @@ export const Inspector = ( {
hideEmpty,
clearButton,
} = attributes;
const { updateBlockAttributes } = dispatch( 'core/block-editor' );
const { productFilterWrapperBlockId, productFilterWrapperHeadingBlockId } =
useSelect(
( select ) => {
if ( ! clientId )
return {
productFilterWrapperBlockId: undefined,
productFilterWrapperHeadingBlockId: undefined,
};
const { updateBlockAttributes, insertBlock, replaceBlock } =
dispatch( 'core/block-editor' );
const filterBlock = useSelect(
( select ) => {
return select( 'core/block-editor' ).getBlock( clientId );
},
[ clientId ]
);
const [ displayStyleBlocksAttributes, setDisplayStyleBlocksAttributes ] =
useState< Record< string, unknown > >( {} );
const { getBlockParentsByBlockName, getBlock } =
select( 'core/block-editor' );
const filterHeadingBlock = getInnerBlockByName(
filterBlock,
'core/heading'
);
const parentBlocksByBlockName = getBlockParentsByBlockName(
clientId,
'woocommerce/product-filter'
);
if ( parentBlocksByBlockName.length === 0 )
return {
productFilterWrapperBlockId: undefined,
productFilterWrapperHeadingBlockId: undefined,
};
const parentBlockId = parentBlocksByBlockName[ 0 ];
const parentBlock = getBlock( parentBlockId );
const headerGroupBlock = parentBlock?.innerBlocks.find(
( block ) => block.name === 'core/group'
);
const headingBlock = headerGroupBlock?.innerBlocks.find(
( block ) => block.name === 'core/heading'
);
return {
productFilterWrapperBlockId: parentBlockId,
productFilterWrapperHeadingBlockId: headingBlock?.clientId,
};
},
[ clientId ]
if ( displayStyleOptions.length === 0 ) {
displayStyleOptions = getBlockTypes().filter( ( blockType ) =>
blockType.ancestor?.includes(
'woocommerce/product-filter-attribute'
)
);
}
return (
<>
@ -102,17 +88,9 @@ export const Inspector = ( {
} );
const attributeObject =
getAttributeFromId( numericId );
if ( productFilterWrapperBlockId ) {
if ( filterHeadingBlock ) {
updateBlockAttributes(
productFilterWrapperBlockId,
{
attributeId: numericId,
}
);
}
if ( productFilterWrapperHeadingBlockId ) {
updateBlockAttributes(
productFilterWrapperHeadingBlockId,
filterHeadingBlock.clientId,
{
content:
attributeObject?.label ??
@ -188,17 +166,46 @@ export const Inspector = ( {
value={ displayStyle }
onChange={ (
value: BlockAttributes[ 'displayStyle' ]
) => setAttributes( { displayStyle: value } ) }
) => {
if ( ! filterBlock ) return;
const currentStyleBlock = getInnerBlockByName(
filterBlock,
displayStyle
);
if ( currentStyleBlock ) {
setDisplayStyleBlocksAttributes( {
...displayStyleBlocksAttributes,
[ displayStyle ]:
currentStyleBlock.attributes,
} );
replaceBlock(
currentStyleBlock.clientId,
createBlock(
value,
displayStyleBlocksAttributes[ value ] ||
{}
)
);
} else {
insertBlock(
createBlock( value ),
filterBlock.innerBlocks.length,
filterBlock.clientId,
false
);
}
setAttributes( { displayStyle: value } );
} }
style={ { width: '100%' } }
>
<ToggleGroupControlOption
label={ __( 'List', 'woocommerce' ) }
value="list"
/>
<ToggleGroupControlOption
label={ __( 'Chips', 'woocommerce' ) }
value="chips"
/>
{ displayStyleOptions.map( ( blockType ) => (
<ToggleGroupControlOption
key={ blockType.name }
label={ blockType.title }
value={ blockType.name }
/>
) ) }
</ToggleGroupControl>
<ToggleControl
label={ __( 'Product counts', 'woocommerce' ) }

View File

@ -0,0 +1,12 @@
/**
* External dependencies
*/
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
const Save = () => {
const blockProps = useBlockProps.save();
const innerBlocksProps = useInnerBlocksProps.save( blockProps );
return <div { ...innerBlocksProps } />;
};
export default Save;

View File

@ -0,0 +1,50 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "woocommerce/product-filter-checkbox-list",
"version": "1.0.0",
"title": "List",
"description": "Display a list of filter options.",
"category": "woocommerce",
"keywords": [
"WooCommerce"
],
"textdomain": "woocommerce",
"apiVersion": 3,
"ancestor": [
"woocommerce/product-filter-attribute"
],
"supports": {
"color": {
"enableContrastChecker": false
}
},
"usesContext": [
"filterData"
],
"attributes": {
"optionElementBorder": {
"type": "string",
"default": ""
},
"customOptionElementBorder": {
"type": "string",
"default": ""
},
"optionElementSelected": {
"type": "string",
"default": ""
},
"customOptionElementSelected": {
"type": "string",
"default": ""
},
"optionElement": {
"type": "string",
"default": ""
},
"customOptionElement": {
"type": "string",
"default": ""
}
}
}

View File

@ -0,0 +1,223 @@
/**
* External dependencies
*/
import clsx from 'clsx';
import { __ } from '@wordpress/i18n';
import { Icon } from '@wordpress/components';
import { checkMark } from '@woocommerce/icons';
import { useMemo } from '@wordpress/element';
import {
useBlockProps,
withColors,
InspectorControls,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalColorGradientSettingsDropdown as ColorGradientSettingsDropdown,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import { EditProps } from './types';
const Edit = ( props: EditProps ): JSX.Element => {
const {
clientId,
context,
attributes,
setAttributes,
optionElementBorder,
setOptionElementBorder,
optionElementSelected,
setOptionElementSelected,
optionElement,
setOptionElement,
} = props;
const {
customOptionElementBorder,
customOptionElementSelected,
customOptionElement,
} = attributes;
const { filterData } = context;
const { isLoading, items } = filterData;
const colorGradientSettings = useMultipleOriginColorsAndGradients();
const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-checkbox-list', {
'is-loading': isLoading,
'has-option-element-border-color':
optionElementBorder.color || customOptionElementBorder,
'has-option-element-selected-color':
optionElementSelected.color || customOptionElementSelected,
'has-option-element-color':
optionElement.color || customOptionElement,
} ),
style: {
'--wc-product-filter-checkbox-list-option-element-border':
optionElementBorder.color || customOptionElementBorder,
'--wc-product-filter-checkbox-list-option-element-selected':
optionElementSelected.color || customOptionElementSelected,
'--wc-product-filter-checkbox-list-option-element':
optionElement.color || customOptionElement,
},
} );
const loadingState = useMemo( () => {
return [ ...Array( 5 ) ].map( ( x, i ) => (
<li
key={ i }
style={ {
/* stylelint-disable */
width: Math.floor( Math.random() * ( 100 - 25 ) ) + '%',
} }
>
&nbsp;
</li>
) );
}, [] );
if ( ! items ) {
return <></>;
}
const threshold = 15;
const isLongList = items.length > threshold;
return (
<>
<div { ...blockProps }>
<ul className="wc-block-product-filter-checkbox-list__list">
{ isLoading && loadingState }
{ ! isLoading &&
( isLongList
? items.slice( 0, threshold )
: items
).map( ( item, index ) => (
<li
key={ index }
className="wc-block-product-filter-checkbox-list__item"
>
<label
htmlFor={ `interactive-checkbox-${ index }` }
className=" wc-block-product-filter-checkbox-list__label"
>
<span className="wc-block-interactive-components-checkbox-list__input-wrapper">
<span className="wc-block-product-filter-checkbox-list__input-wrapper">
<input
name={ `interactive-checkbox-${ index }` }
type="checkbox"
className="wc-block-product-filter-checkbox-list__input"
defaultChecked={
!! item.selected
}
/>
<Icon
className="wc-block-product-filter-checkbox-list__mark"
icon={ checkMark }
/>
</span>
</span>
<span className="wc-block-product-filter-checkbox-list__text">
{ item.label }
</span>
</label>
</li>
) ) }
</ul>
{ ! isLoading && isLongList && (
<span className="wc-block-product-filter-checkbox-list__show-more">
<small>{ __( 'Show more…', 'woocommerce' ) }</small>
</span>
) }
</div>
<InspectorControls group="color">
{ colorGradientSettings.hasColorsOrGradients && (
<ColorGradientSettingsDropdown
__experimentalIsRenderedInSidebar
settings={ [
{
label: __(
'Option Element Border',
'woocommerce'
),
colorValue:
optionElementBorder.color ||
customOptionElementBorder,
isShownByDefault: true,
enableAlpha: true,
onColorChange: ( colorValue: string ) => {
setOptionElementBorder( colorValue );
setAttributes( {
customOptionElementBorder: colorValue,
} );
},
resetAllFilter: () => {
setOptionElementBorder( '' );
setAttributes( {
customOptionElementBorder: '',
} );
},
},
{
label: __(
'Option Element (Selected)',
'woocommerce'
),
colorValue:
optionElementSelected.color ||
customOptionElementSelected,
isShownByDefault: true,
enableAlpha: true,
onColorChange: ( colorValue: string ) => {
setOptionElementSelected( colorValue );
setAttributes( {
customOptionElementSelected: colorValue,
} );
},
resetAllFilter: () => {
setOptionElementSelected( '' );
setAttributes( {
customOptionElementSelected: '',
} );
},
},
{
label: __( 'Option Element', 'woocommerce' ),
colorValue:
optionElement.color || customOptionElement,
isShownByDefault: true,
enableAlpha: true,
onColorChange: ( colorValue: string ) => {
setOptionElement( colorValue );
setAttributes( {
customOptionElement: colorValue,
} );
},
resetAllFilter: () => {
setOptionElement( '' );
setAttributes( {
customOptionElement: '',
} );
},
},
] }
panelId={ clientId }
{ ...colorGradientSettings }
/>
) }
</InspectorControls>
</>
);
};
export default withColors( {
optionElementBorder: 'option-element-border',
optionElementSelected: 'option-element-border',
optionElement: 'option-element',
} )( Edit );

View File

@ -0,0 +1,10 @@
.wc-block-product-filter-checkbox-list.is-loading {
.wc-block-product-filter-checkbox-list__list {
padding: 0;
li {
@include placeholder();
margin: 5px 0;
}
}
}

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { getContext, store } from '@woocommerce/interactivity';
import { HTMLElementEvent } from '@woocommerce/types';
/**
* Internal dependencies
*/
export type CheckboxListContext = {
items: {
id: string;
label: string;
value: string;
checked: boolean;
}[];
showAll: boolean;
};
store( 'woocommerce/product-filter-checkbox-list', {
actions: {
showAllItems: () => {
const context = getContext< CheckboxListContext >();
context.showAll = true;
},
selectCheckboxItem: ( event: HTMLElementEvent< HTMLInputElement > ) => {
const context = getContext< CheckboxListContext >();
const value = event.target.value;
context.items = context.items.map( ( item ) => {
if ( item.value.toString() === value ) {
return {
...item,
checked: ! item.checked,
};
}
return item;
} );
},
},
} );

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
import { productFilterOptions } from '@woocommerce/icons';
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import metadata from './block.json';
import Edit from './edit';
import './style.scss';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
edit: Edit,
icon: productFilterOptions,
} );
}

View File

@ -0,0 +1,86 @@
:where(.wc-block-product-filter-checkbox-list__list) {
list-style: none outside;
margin: 0;
padding: 0;
}
.wc-block-product-filter-checkbox-list__item.hidden {
display: none;
}
:where(.wc-block-product-filter-checkbox-list__label) {
align-items: center;
display: flex;
gap: 0.625em;
}
.wc-block-product-filter-checkbox-list__item .wc-block-product-filter-checkbox-list__label {
margin-bottom: 0;
}
:where(.wc-block-product-filter-checkbox-list__input-wrapper) {
display: block;
position: relative;
}
.wc-block-product-filter-checkbox-list__input-wrapper::before {
content: "";
left: 0;
position: absolute;
top: 0;
background: currentColor;
opacity: 0.1;
width: 1em;
height: 1em;
border-radius: 2px;
.has-option-element-color & {
display: none;
}
}
:where(.wc-block-product-filter-checkbox-list__input) {
appearance: none;
border-radius: 2px;
border: 1px solid var(--wc-product-filter-checkbox-list-option-element-border, transparent);
color: inherit;
display: block;
font-size: inherit;
height: 1em;
margin: 0;
width: 1em;
background: var(--wc-product-filter-checkbox-list-option-element, transparent);
}
.wc-block-product-filter-checkbox-list__input:checked + .wc-block-product-filter-checkbox-list__mark {
display: block;
pointer-events: none;
}
.wc-block-product-filter-checkbox-list__input:focus {
outline-width: 1px;
outline-color: var(--wc-product-filter-checkbox-list-option-element-border, currentColor);
}
:where(.wc-block-product-filter-checkbox-list__mark) {
box-sizing: border-box;
display: none;
height: 1em;
left: 0;
padding: 0.2em;
position: absolute;
top: 0;
width: 1em;
color: var(--wc-product-filter-checkbox-list-option-element-selected, currentColor);
}
:where(.wc-block-product-filter-checkbox-list__show-more) {
cursor: pointer;
text-decoration: underline;
}
.wc-block-product-filter-checkbox-list__show-more.hidden {
display: none;
}

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { FilterBlockContext } from '../../types';
export type Color = {
slug: string;
class: string;
name: string;
color: string;
};
export type BlockAttributes = {
className: string;
optionElementBorder: string;
customOptionElementBorder: string;
optionElementSelected: string;
customOptionElementSelected: string;
optionElement: string;
customOptionElement: string;
};
export type EditProps = BlockEditProps< BlockAttributes > & {
context: FilterBlockContext;
optionElementBorder: Color;
setOptionElementBorder: ( value: string ) => void;
optionElementSelected: Color;
setOptionElementSelected: ( value: string ) => void;
optionElement: Color;
setOptionElement: ( value: string ) => void;
};

View File

@ -0,0 +1,22 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"name": "woocommerce/product-filter-chips",
"version": "1.0.0",
"title": "Chips",
"description": "Display filter options as chips.",
"category": "woocommerce",
"keywords": [
"WooCommerce"
],
"textdomain": "woocommerce",
"apiVersion": 3,
"ancestor": [
"woocommerce/product-filter-attribute"
],
"supports": {},
"usesContext": [
"filterData",
"isParentSelected"
],
"attributes": {}
}

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import './style.scss';
const Edit = () => {
return <div { ...useBlockProps() }>These are chips.</div>;
};
export default Edit;

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
import { productFilterOptions } from '@woocommerce/icons';
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import metadata from './block.json';
import Edit from './edit';
import './style.scss';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
edit: Edit,
icon: productFilterOptions,
} );
}

View File

@ -0,0 +1,3 @@
:where(.wc-block-product-filter-chips) {
// WIP
}

View File

@ -6,13 +6,9 @@ import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { BLOCK_NAME_MAP } from './constants';
export type FilterType = keyof typeof BLOCK_NAME_MAP;
export type BlockAttributes = {
filterType: FilterType;
heading: string;
className: string;
};
export type EditProps = BlockEditProps< BlockAttributes >;

View File

@ -1,3 +1,8 @@
/**
* Logic in this file is unused and should be moved to product-fitlers block.
*
* @see https://github.com/woocommerce/woocommerce/issues/50868
*/
/**
* External dependencies
*/
@ -6,7 +11,7 @@ import { store, getContext } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
import { navigate } from '../product-filter/frontend';
import { navigate } from '../../frontend';
const getQueryParams = ( e: Event ) => {
const filterNavContainer = ( e.target as HTMLElement )?.closest(

View File

@ -9,7 +9,7 @@ import { debounce } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { navigate } from '../product-filter/frontend';
import { navigate } from '../../frontend';
import type { PriceFilterContext, PriceFilterStore } from './types';
const getUrl = ( context: PriceFilterContext ) => {

View File

@ -1,129 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { BlockVariation } from '@wordpress/blocks';
import {
productFilterActive,
productFilterAttribute,
productFilterPrice,
productFilterRating,
productFilterStockStatus,
} from '@woocommerce/icons';
import { getSetting } from '@woocommerce/settings';
import { AttributeSetting, objectHasProp } from '@woocommerce/types';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
const variations: BlockVariation[] = [
{
name: 'product-filter-active',
title: __( 'Active (Experimental)', 'woocommerce' ),
description: __(
'Display the currently active filters.',
'woocommerce'
),
attributes: {
heading: __( 'Active filters', 'woocommerce' ),
filterType: 'active-filters',
},
icon: productFilterActive,
isDefault: true,
},
{
name: 'product-filter-price',
title: __( 'Price (Experimental)', 'woocommerce' ),
description: __(
'Enable customers to filter the product collection by choosing a price range.',
'woocommerce'
),
attributes: {
filterType: 'price-filter',
heading: __( 'Price', 'woocommerce' ),
},
icon: productFilterPrice,
},
{
name: 'product-filter-stock-status',
title: __( 'Status (Experimental)', 'woocommerce' ),
description: __(
'Enable customers to filter the product collection by stock status.',
'woocommerce'
),
attributes: {
filterType: 'stock-filter',
heading: __( 'Status', 'woocommerce' ),
},
icon: productFilterStockStatus,
},
{
name: 'product-filter-rating',
title: __( 'Rating (Experimental)', 'woocommerce' ),
description: __(
'Enable customers to filter the product collection by rating.',
'woocommerce'
),
attributes: {
filterType: 'rating-filter',
heading: __( 'Rating', 'woocommerce' ),
},
icon: productFilterRating,
},
];
ATTRIBUTES.forEach( ( attribute ) => {
variations.push( {
name: `product-filter-attribute-${ attribute.attribute_name }`,
title: `${ attribute.attribute_label } (Experimental)`,
description: sprintf(
// translators: %s is the attribute label.
__(
`Enable customers to filter the product collection by selecting one or more %s attributes.`,
'woocommerce'
),
attribute.attribute_label
),
attributes: {
filterType: 'attribute-filter',
heading: attribute.attribute_label,
attributeId: parseInt( attribute.attribute_id, 10 ),
},
icon: productFilterAttribute,
// Can be `isActive: [ 'filterType', 'attributeId' ]`, but the API is available from 6.6.
isActive: ( blockAttributes, variationAttributes ) => {
return (
blockAttributes.filterType === variationAttributes.filterType &&
blockAttributes.attributeId === variationAttributes.attributeId
);
},
} );
} );
variations.push( {
name: 'product-filter-attribute',
title: __( 'Attribute (Experimental)', 'woocommerce' ),
description: __(
'Enable customers to filter the product collection by selecting one or more attributes, such as color.',
'woocommerce'
),
attributes: {
filterType: 'attribute-filter',
heading: __( 'Attribute', 'woocommerce' ),
attributeId: 0,
},
icon: productFilterAttribute,
} );
/**
* Add `isActive` function to all Product Filter block variations.
* `isActive` function is used to find a variation match from a created
* Block by providing its attributes.
*/
variations.forEach( ( variation ) => {
if ( ! objectHasProp( variation, 'isActive' ) ) {
// @ts-expect-error: `isActive` is currently typed wrong in `@wordpress/blocks`.
variation.isActive = [ 'filterType' ];
}
} );
export const blockVariations = variations;

View File

@ -1,47 +0,0 @@
{
"name": "woocommerce/product-filter",
"version": "1.0.0",
"title": "Product Filter (Experimental)",
"description": "A block that adds product filters to the product collection.",
"category": "woocommerce",
"keywords": [
"WooCommerce",
"Filters"
],
"textdomain": "woocommerce",
"supports": {
"html": false,
"reusable": false,
"inserter": true
},
"ancestor": [
"woocommerce/product-filters"
],
"usesContext": [
"query",
"queryId"
],
"attributes": {
"filterType": {
"type": "string"
},
"heading": {
"type": "string"
},
"isPreview": {
"type": "boolean",
"default": false
},
"attributeId": {
"type": "number",
"default": 0
}
},
"example": {
"attributes": {
"isPreview": true
}
},
"apiVersion": 3,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@ -1,36 +0,0 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { Notice } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const Warning = () => {
const isWidgetEditor = getSetting< boolean >( 'isWidgetEditor' );
if ( isWidgetEditor ) {
return (
<Notice status="info" isDismissible={ false }>
{ __(
'The widget area containing Collection Filters block needs to be placed on a product archive page for filters to function properly.',
'woocommerce'
) }
</Notice>
);
}
const isSiteEditor = getSetting< boolean >( 'isSiteEditor' );
if ( ! isSiteEditor ) {
return (
<Notice status="warning" isDismissible={ false }>
{ __(
'When added to a post or page, Collection Filters block needs to be nested inside a Product Collection block to function properly.',
'woocommerce'
) }
</Notice>
);
}
return null;
};
export default Warning;

View File

@ -1,8 +0,0 @@
export const BLOCK_NAME_MAP = {
'active-filters': 'woocommerce/product-filter-active',
'price-filter': 'woocommerce/product-filter-price',
'stock-filter': 'woocommerce/product-filter-stock-status',
'rating-filter': 'woocommerce/product-filter-rating',
'attribute-filter': 'woocommerce/product-filter-attribute',
'clear-button': 'woocommerce/product-filter-clear-button',
};

View File

@ -1,113 +0,0 @@
/**
* External dependencies
*/
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import Warning from './components/warning';
import './editor.scss';
import { getAllowedBlocks } from './utils';
import { BLOCK_NAME_MAP } from './constants';
import type { FilterType } from './types';
const Edit = ( {
attributes,
clientId,
}: BlockEditProps< {
heading: string;
filterType: FilterType;
isPreview: boolean;
attributeId: number | undefined;
} > ) => {
const blockProps = useBlockProps();
const isNested = useSelect( ( select ) => {
const { getBlockParentsByBlockName } = select( 'core/block-editor' );
return !! getBlockParentsByBlockName(
clientId,
'woocommerce/product-collection'
).length;
} );
return (
<nav { ...blockProps }>
{ ! isNested && <Warning /> }
<InnerBlocks
allowedBlocks={ getAllowedBlocks( [
...Object.values( BLOCK_NAME_MAP ),
'woocommerce/product-filter',
'woocommerce/filter-wrapper',
'woocommerce/product-collection',
'core/query',
] ) }
template={ [
/**
* We want to hide the clear filter button for active filters block
* as it has its own "clear all" button.
*/
attributes.filterType === 'active-filters'
? [
'core/heading',
{ level: 3, content: attributes.heading || '' },
]
: [
'core/group',
{
layout: {
type: 'flex',
flexWrap: 'nowrap',
},
metadata: {
name: __( 'Header', 'woocommerce' ),
},
style: {
spacing: {
blockGap: '0',
},
},
},
[
[
'core/heading',
{
level: 3,
content: attributes.heading || '',
},
],
[
'woocommerce/product-filter-clear-button',
{
lock: {
remove: true,
move: false,
},
},
],
],
],
[
BLOCK_NAME_MAP[ attributes.filterType ],
{
lock: {
remove: true,
},
isPreview: attributes.isPreview,
attributeId:
attributes.filterType === 'attribute-filter' &&
attributes.attributeId
? attributes.attributeId
: undefined,
},
],
] }
/>
</nav>
);
};
export default Edit;

View File

@ -1,5 +0,0 @@
.wp-block-woocommerce-collection-filters {
.components-notice {
margin: 0;
}
}

View File

@ -1,42 +0,0 @@
/**
* External dependencies
*/
import { navigate as navigateFn } from '@woocommerce/interactivity';
import { getSetting } from '@woocommerce/settings';
const isBlockTheme = getSetting< boolean >( 'isBlockTheme' );
const isProductArchive = getSetting< boolean >( 'isProductArchive' );
const needsRefresh = getSetting< boolean >(
'needsRefreshForInteractivityAPI',
false
);
export function navigate( href: string, options = {} ) {
/**
* We may need to reset the current page when changing filters.
* This is because the current page may not exist for this set
* of filters and will 404 when the user navigates to it.
*
* There are different pagination formats to consider, as documented here:
* https://github.com/WordPress/gutenberg/blob/317eb8f14c8e1b81bf56972cca2694be250580e3/packages/block-library/src/query-pagination-numbers/index.php#L22-L85
*/
const url = new URL( href );
// When pretty permalinks are enabled, the page number may be in the path name.
url.pathname = url.pathname.replace( /\/page\/[0-9]+/i, '' );
// When plain permalinks are enabled, the page number may be in the "paged" query parameter.
url.searchParams.delete( 'paged' );
// On posts and pages the page number will be in a query parameter that
// identifies which block we are paginating.
url.searchParams.forEach( ( _, key ) => {
if ( key.match( /^query(?:-[0-9]+)?-page$/ ) ) {
url.searchParams.delete( key );
}
} );
// Make sure to update the href with the changes.
href = url.href;
if ( needsRefresh || ( ! isBlockTheme && isProductArchive ) ) {
return ( window.location.href = href );
}
return navigateFn( href, options );
}

View File

@ -1,73 +0,0 @@
/**
* External dependencies
*/
import {
BlockInstance,
createBlock,
registerBlockType,
} from '@wordpress/blocks';
import { Icon, more } from '@wordpress/icons';
import { isExperimentalBlocksEnabled } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import save from './save';
import { BLOCK_NAME_MAP } from './constants';
import { BlockAttributes } from './types';
import { blockVariations } from './block-variations';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ more }
className="wc-block-editor-components-block-icon"
/>
),
},
edit,
save,
variations: blockVariations,
transforms: {
from: [
{
type: 'block',
blocks: [ 'woocommerce/filter-wrapper' ],
transform: (
attributes: BlockAttributes,
innerBlocks: BlockInstance[]
) => {
const newInnerBlocks: BlockInstance[] = [];
// Loop through inner blocks to preserve the block order.
innerBlocks.forEach( ( block ) => {
if (
block.name ===
`woocommerce/${ attributes.filterType }`
) {
newInnerBlocks.push(
createBlock(
BLOCK_NAME_MAP[ attributes.filterType ],
block.attributes
)
);
}
if ( block.name === 'core/heading' ) {
newInnerBlocks.push( block );
}
} );
return createBlock(
'woocommerce/product-filter',
attributes,
newInnerBlocks
);
},
},
],
},
} );
}

View File

@ -1,8 +0,0 @@
/**
* External dependencies
*/
import { InnerBlocks } from '@wordpress/block-editor';
export default function save() {
return <InnerBlocks.Content />;
}

View File

@ -1,18 +0,0 @@
/**
* External dependencies
*/
import { getBlockTypes } from '@wordpress/blocks';
/**
* Returns an array of allowed block names excluding the disallowedBlocks array.
*
* @param disallowedBlocks Array of block names to disallow.
* @return Array of allowed block names.
*/
export const getAllowedBlocks = ( disallowedBlocks: string[] ) => {
const allBlocks = getBlockTypes();
return allBlocks
.map( ( block ) => block.name )
.filter( ( name ) => ! disallowedBlocks.includes( name ) );
};

View File

@ -8,7 +8,7 @@ import { DropdownContext } from '@woocommerce/interactivity-components/dropdown'
/**
* Internal dependencies
*/
import { navigate } from '../product-filter/frontend';
import { navigate } from '../../frontend';
function getUrl( filters: Array< string | null > ) {
filters = filters.filter( Boolean );

View File

@ -9,7 +9,7 @@ import { CheckboxListContext } from '@woocommerce/interactivity-components/check
/**
* Internal dependencies
*/
import { navigate } from '../product-filter/frontend';
import { navigate } from '../../frontend';
const getUrl = ( activeFilters: string ) => {
const url = new URL( window.location.href );

View File

@ -7,8 +7,8 @@ export type BlockOverlayAttributeOptions =
( typeof BlockOverlayAttribute )[ keyof typeof BlockOverlayAttribute ];
export interface BlockAttributes {
productId?: string;
setAttributes: ( attributes: ProductFiltersBlockAttributes ) => void;
productId?: string;
overlay: BlockOverlayAttributeOptions;
overlayIcon:
| 'filter-icon-1'
@ -18,3 +18,23 @@ export interface BlockAttributes {
overlayButtonStyle: 'label-icon' | 'label' | 'icon';
overlayIconSize?: number;
}
export type FilterOptionItem = {
label: string;
value: string;
selected?: boolean;
rawData?: Record< string, unknown >;
};
export type FilterBlockContext = {
filterData: {
isLoading: boolean;
items?: FilterOptionItem[];
range?: {
min: number;
max: number;
step: number;
};
};
isParentSelected: boolean;
};

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { BlockInstance, getBlockTypes } from '@wordpress/blocks';
/**
* Returns an array of allowed block names excluding the disallowedBlocks array.
*
* @param disallowedBlocks Array of block names to disallow.
* @return Array of allowed block names.
*/
export const getAllowedBlocks = ( disallowedBlocks: string[] ) => {
const allBlocks = getBlockTypes();
return allBlocks
.map( ( block ) => block.name )
.filter( ( name ) => ! disallowedBlocks.includes( name ) );
};
export const getInnerBlockByName = (
block: BlockInstance | null,
name: string
): BlockInstance | null => {
if ( ! block ) return null;
if ( block.innerBlocks.length === 0 ) return null;
for ( const innerBlock of block.innerBlocks ) {
if ( innerBlock.name === name ) return innerBlock;
const innerInnerBlock = getInnerBlockByName( innerBlock, name );
if ( innerInnerBlock ) return innerInnerBlock;
}
return null;
};

View File

@ -0,0 +1,10 @@
export const SEARCH_BLOCK_NAME = 'core/search';
export const SEARCH_VARIATION_NAME = 'woocommerce/product-search';
export enum PositionOptions {
OUTSIDE = 'button-outside',
INSIDE = 'button-inside',
NO_BUTTON = 'no-button',
BUTTON_ONLY = 'button-only',
INPUT_AND_BUTTON = 'input-and-button',
}

View File

@ -2,6 +2,7 @@
/**
* External dependencies
*/
import { addFilter } from '@wordpress/hooks';
import { store as blockEditorStore, Warning } from '@wordpress/block-editor';
import { useDispatch, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
@ -9,6 +10,7 @@ import { Icon, search } from '@wordpress/icons';
import { getSettingWithCoercion } from '@woocommerce/settings';
import { isBoolean } from '@woocommerce/types';
import { Button } from '@wordpress/components';
import type { Block as BlockType } from '@wordpress/blocks';
import {
// @ts-ignore waiting for @types/wordpress__blocks update
registerBlockVariation,
@ -21,8 +23,10 @@ import {
*/
import './style.scss';
import './editor.scss';
import { withProductSearchControls } from './inspector-controls';
import Block from './block';
import Edit from './edit';
import { SEARCH_BLOCK_NAME, SEARCH_VARIATION_NAME } from './constants';
const isBlockVariationAvailable = getSettingWithCoercion(
'isBlockVariationAvailable',
@ -71,6 +75,7 @@ const PRODUCT_SEARCH_ATTRIBUTES = {
query: {
post_type: 'product',
},
namespace: SEARCH_VARIATION_NAME,
};
const DeprecatedBlockEdit = ( { clientId }: { clientId: string } ) => {
@ -115,7 +120,7 @@ const DeprecatedBlockEdit = ( { clientId }: { clientId: string } ) => {
);
};
registerBlockType( 'woocommerce/product-search', {
registerBlockType( SEARCH_VARIATION_NAME, {
title: __( 'Product Search', 'woocommerce' ),
apiVersion: 3,
icon: {
@ -146,7 +151,7 @@ registerBlockType( 'woocommerce/product-search', {
isMatch: ( { idBase, instance } ) =>
idBase === 'woocommerce_product_search' && !! instance?.raw,
transform: ( { instance } ) =>
createBlock( 'woocommerce/product-search', {
createBlock( SEARCH_VARIATION_NAME, {
label:
instance.raw.title ||
PRODUCT_SEARCH_ATTRIBUTES.label,
@ -172,9 +177,31 @@ registerBlockType( 'woocommerce/product-search', {
},
} );
function registerProductSearchNamespace( props: BlockType, blockName: string ) {
if ( blockName === 'core/search' ) {
// Gracefully handle if settings.attributes is undefined.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- We need this because `attributes` is marked as `readonly`
props.attributes = {
...props.attributes,
namespace: {
type: 'string',
},
};
}
return props;
}
addFilter(
'blocks.registerBlockType',
SEARCH_VARIATION_NAME,
registerProductSearchNamespace
);
if ( isBlockVariationAvailable ) {
registerBlockVariation( 'core/search', {
name: 'woocommerce/product-search',
name: SEARCH_VARIATION_NAME,
title: __( 'Product Search', 'woocommerce' ),
icon: {
src: (
@ -199,4 +226,9 @@ if ( isBlockVariationAvailable ) {
),
attributes: PRODUCT_SEARCH_ATTRIBUTES,
} );
addFilter(
'editor.BlockEdit',
SEARCH_BLOCK_NAME,
withProductSearchControls
);
}

View File

@ -0,0 +1,156 @@
/**
* External dependencies
*/
import { type ElementType, useEffect, useState } from '@wordpress/element';
import { EditorBlock } from '@woocommerce/types';
import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor';
import {
PanelBody,
RadioControl,
ToggleControl,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because `__experimentalToggleGroupControlOption` is not yet in the type definitions.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import {
getInputAndButtonOption,
getSelectedRadioControlOption,
isInputAndButtonOption,
isWooSearchBlockVariation,
} from './utils';
import { ButtonPositionProps, ProductSearchBlockProps } from './types';
import { PositionOptions } from './constants';
const ProductSearchControls = ( props: ProductSearchBlockProps ) => {
const { attributes, setAttributes } = props;
const { buttonPosition, buttonUseIcon, showLabel } = attributes;
const [ initialPosition, setInitialPosition ] =
useState< ButtonPositionProps >( buttonPosition );
useEffect( () => {
if (
isInputAndButtonOption( buttonPosition ) &&
initialPosition !== buttonPosition
) {
setInitialPosition( buttonPosition );
}
}, [ buttonPosition ] );
return (
<InspectorControls group="styles">
<PanelBody title={ __( 'Styles', 'woocommerce' ) }>
<RadioControl
selected={ getSelectedRadioControlOption( buttonPosition ) }
options={ [
{
label: __( 'Input and button', 'woocommerce' ),
value: PositionOptions.INPUT_AND_BUTTON,
},
{
label: __( 'Input only', 'woocommerce' ),
value: PositionOptions.NO_BUTTON,
},
{
label: __( 'Button only', 'woocommerce' ),
value: PositionOptions.BUTTON_ONLY,
},
] }
onChange={ (
selected: Partial< ButtonPositionProps > &
PositionOptions.INPUT_AND_BUTTON
) => {
if ( selected !== PositionOptions.INPUT_AND_BUTTON ) {
setAttributes( {
buttonPosition: selected,
} );
} else {
const newButtonPosition =
getInputAndButtonOption( initialPosition );
setAttributes( {
buttonPosition: newButtonPosition,
} );
}
} }
/>
{ buttonPosition !== PositionOptions.NO_BUTTON && (
<>
{ buttonPosition !== PositionOptions.BUTTON_ONLY && (
<ToggleGroupControl
label={ __( 'BUTTON POSITION', 'woocommerce' ) }
isBlock
onChange={ ( value: ButtonPositionProps ) => {
setAttributes( {
buttonPosition: value,
} );
} }
value={ getInputAndButtonOption(
buttonPosition
) }
>
<ToggleGroupControlOption
value={ PositionOptions.INSIDE }
label={ __( 'Inside', 'woocommerce' ) }
/>
<ToggleGroupControlOption
value={ PositionOptions.OUTSIDE }
label={ __( 'Outside', 'woocommerce' ) }
/>
</ToggleGroupControl>
) }
<ToggleGroupControl
label={ __( 'BUTTON APPEARANCE', 'woocommerce' ) }
isBlock
onChange={ ( value: boolean ) => {
setAttributes( {
buttonUseIcon: value,
} );
} }
value={ buttonUseIcon }
>
<ToggleGroupControlOption
value={ false }
label={ __( 'Text', 'woocommerce' ) }
/>
<ToggleGroupControlOption
value={ true }
label={ __( 'Icon', 'woocommerce' ) }
/>
</ToggleGroupControl>
</>
) }
<ToggleControl
label={ __( 'Show input label', 'woocommerce' ) }
checked={ showLabel }
onChange={ ( showInputLabel: boolean ) =>
setAttributes( {
showLabel: showInputLabel,
} )
}
/>
</PanelBody>
</InspectorControls>
);
};
export const withProductSearchControls =
< T extends EditorBlock< T > >( BlockEdit: ElementType ) =>
( props: ProductSearchBlockProps ) => {
return isWooSearchBlockVariation( props ) ? (
<>
<ProductSearchControls { ...props } />
<BlockEdit { ...props } />
</>
) : (
<BlockEdit { ...props } />
);
};

View File

@ -0,0 +1,23 @@
/**
* External dependencies
*/
import type { EditorBlock } from '@woocommerce/types';
export type ButtonPositionProps =
| 'button-outside'
| 'button-inside'
| 'no-button'
| 'button-only';
export interface SearchBlockAttributes {
buttonPosition: ButtonPositionProps;
buttonText?: string;
buttonUseIcon: boolean;
isSearchFieldHidden: boolean;
label?: string;
namespace?: string;
placeholder?: string;
showLabel: boolean;
}
export type ProductSearchBlockProps = EditorBlock< SearchBlockAttributes >;

View File

@ -0,0 +1,75 @@
/**
* Internal dependencies
*/
import {
PositionOptions,
SEARCH_BLOCK_NAME,
SEARCH_VARIATION_NAME,
} from './constants';
import { ButtonPositionProps, ProductSearchBlockProps } from './types';
/**
* Identifies if a block is a Search block variation from our conventions
*
* We are extending Gutenberg's core Search block with our variations, and
* also adding extra namespaced attributes. If those namespaced attributes
* are present, we can be fairly sure it is our own registered variation.
*
* @param {ProductSearchBlockProps} block - A WooCommerce block.
*/
export function isWooSearchBlockVariation( block: ProductSearchBlockProps ) {
return (
block.name === SEARCH_BLOCK_NAME &&
block.attributes?.namespace === SEARCH_VARIATION_NAME
);
}
/**
* Checks if the given button position is a valid option for input and button placement.
*
* The function verifies if the provided `buttonPosition` matches one of the predefined
* values for placing a button either inside or outside an input field.
*
* @param {string} buttonPosition - The position of the button to check.
*/
export function isInputAndButtonOption( buttonPosition: string ): boolean {
return (
buttonPosition === 'button-outside' ||
buttonPosition === 'button-inside'
);
}
/**
* Returns the option for the selected button position
*
* Based on the provided `buttonPosition`, the function returns a predefined option
* if the position is valid for input and button placement. If the position is not
* one of the predefined options, it returns the original `buttonPosition`.
*
* @param {string} buttonPosition - The position of the button to evaluate.
*/
export function getSelectedRadioControlOption(
buttonPosition: string
): string {
if ( isInputAndButtonOption( buttonPosition ) ) {
return PositionOptions.INPUT_AND_BUTTON;
}
return buttonPosition;
}
/**
* Returns the appropriate option for input and button placement based on the given value
*
* This function checks if the provided `value` is a valid option for placing a button either
* inside or outside an input field. If the `value` is valid, it is returned as is. If the `value`
* is not valid, the function returns a default option.
*
* @param {ButtonPositionProps} value - The position of the button to evaluate.
*/
export function getInputAndButtonOption( value: ButtonPositionProps ) {
if ( isInputAndButtonOption( value ) ) {
return value;
}
// The default value is 'inside' for input and button.
return PositionOptions.OUTSIDE;
}

View File

@ -0,0 +1,184 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import clsx from 'clsx';
import type { CSSProperties } from 'react';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
type RatingProps = {
className: string;
reviews: number;
rating: number;
parentClassName?: string;
};
export const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
export const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( {
className,
parentClassName,
}: {
className: string;
parentClassName: string;
} ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ clsx(
`${ className }__norating-container`,
`${ parentClassName }-product-rating__norating-container`
) }
>
<div className={ `${ className }__norating` } role="img">
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woocommerce' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { className, rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woocommerce' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woocommerce'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ clsx(
`${ className }__stars`,
`${ parentClassName }__product-rating__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
const ReviewsCount = ( props: {
className: string;
reviews: number;
} ): JSX.Element => {
const { className, reviews } = props;
const reviewsCount = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woocommerce'
),
reviews
);
return (
<span className={ `${ className }__reviews_count` }>
{ reviewsCount }
</span>
);
};
type ProductRatingProps = {
className: string;
showReviewCount?: boolean;
showMockedReviews?: boolean;
parentClassName?: string;
rating: number;
reviews: number;
styleProps: {
className: string;
style: CSSProperties;
};
textAlign?: string;
};
export const ProductRating = (
props: ProductRatingProps
): JSX.Element | null => {
const {
className = 'wc-block-components-product-rating',
showReviewCount,
showMockedReviews,
parentClassName = '',
rating,
reviews,
styleProps,
textAlign,
} = props;
const wrapperClassName = clsx( styleProps.className, className, {
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
} );
const mockedRatings = showMockedReviews && (
<NoRating className={ className } parentClassName={ parentClassName } />
);
const content = reviews ? (
<Rating
className={ className }
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
const isReviewCountVisible = reviews && showReviewCount;
return (
<div className={ wrapperClassName } style={ styleProps.style }>
<div className={ `${ className }__container` }>
{ content }
{ isReviewCountVisible ? (
<ReviewsCount className={ className } reviews={ reviews } />
) : null }
</div>
</div>
);
};

View File

@ -4,6 +4,7 @@ export { default as bagAlt } from './library/bag-alt';
export { default as barcode } from './library/barcode';
export { default as cart } from './library/cart';
export { default as cartOutline } from './library/cart-outline';
export { default as checkMark } from './library/check-mark';
export { default as checkPayment } from './library/check-payment';
export { default as closeSquareShadow } from './library/close-square-shadow';
export { default as customerAccount } from './library/customer-account';

View File

@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { IconProps } from '@wordpress/icons/build-types/icon';
import { Path, SVG } from '@wordpress/primitives';
const CheckMark = ( props: IconProps ) => (
<SVG
viewBox="0 0 10 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{ ...props }
>
<Path
d="M9.25 1.19922L3.75 6.69922L1 3.94922"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SVG>
);
export default CheckMark;

View File

@ -2,3 +2,4 @@
* Internal dependencies
*/
import './store-api-nonce';
import './remove-user-locale';

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { isStoreApiRequest } from './store-api-nonce';
/**
* Middleware to add the '_locale=site' query parameter from API requests.
*
* TODO: Remove once https://github.com/WordPress/gutenberg/issues/16805 is fixed and replace by removing userLocaleMiddleware middleware.
*
* @param {Object} options Fetch options.
* @param {Object} options.url The URL of the request.
* @param {Object} options.path The path of the request.
* @param {Function} next The next middleware or fetchHandler to call.
* @return {*} The evaluated result of the remaining middleware chain.
*/
const removeUserLocaleMiddleware = (
options: { url?: string; path?: string },
next: ( options: { url?: string; path?: string } ) => Promise< unknown >
): Promise< unknown > => {
if ( typeof options.url === 'string' && isStoreApiRequest( options ) ) {
options.url = addQueryArgs( options.url, { _locale: 'site' } );
}
if ( typeof options.path === 'string' && isStoreApiRequest( options ) ) {
options.path = addQueryArgs( options.path, { _locale: 'site' } );
}
return next( options );
};
apiFetch.use( removeUserLocaleMiddleware );

View File

@ -23,7 +23,7 @@ try {
*
* @return {boolean} Returns true if this is a store request.
*/
const isStoreApiRequest = ( options ) => {
export const isStoreApiRequest = ( options ) => {
const url = options.url || options.path;
if ( ! url || ! options.method || options.method === 'GET' ) {
return false;

View File

@ -88,10 +88,6 @@ const blocks = {
'product-filters': {
isExperimental: true,
},
'product-filter': {
isExperimental: true,
customDir: 'product-filters/inner-blocks/product-filter',
},
'product-filters-overlay': {
isExperimental: true,
customDir: 'product-filters/inner-blocks/overlay',
@ -124,6 +120,14 @@ const blocks = {
customDir: 'product-filters/inner-blocks/clear-button',
isExperimental: true,
},
'product-filter-checkbox-list': {
customDir: 'product-filters/inner-blocks/checkbox-list',
isExperimental: true,
},
'product-filter-chips': {
customDir: 'product-filters/inner-blocks/chips',
isExperimental: true,
},
'order-confirmation-summary': {
customDir: 'order-confirmation/summary',
},

View File

@ -256,7 +256,7 @@
"pnpm": "9.1.3"
},
"dependencies": {
"@ariakit/react": "^0.4.4",
"@ariakit/react": "^0.4.5",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
@ -288,7 +288,7 @@
"fast-deep-equal": "^3.1.3",
"fast-sort": "^3.4.0",
"html-react-parser": "3.0.4",
"postcode-validator": "3.8.15",
"postcode-validator": "3.9.2",
"preact": "^10.19.3",
"prop-types": "^15.8.1",
"react-number-format": "4.9.3",

View File

@ -13,6 +13,7 @@ const CUSTOM_REGEXES = new Map< string, RegExp >( [
[ 'JP', /^([0-9]{3})([-]?)([0-9]{4})$/ ],
[ 'KH', /^[0-9]{6}$/ ], // Cambodia (6-digit postal code).
[ 'LI', /^(94[8-9][0-9])$/ ],
[ 'MN', /^[0-9]{5}(-[0-9]{4})?$/ ], // Mongolia (5-digit postal code or 5-digit postal code followed by a hyphen and 4-digit postal code).
[ 'NI', /^[1-9]{1}[0-9]{4}$/ ], // Nicaragua (5-digit postal code)
[ 'NL', /^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i ],
[ 'SI', /^([1-9][0-9]{3})$/ ],

Some files were not shown because too many files have changed in this diff Show More