diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/block.json b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/block.json
new file mode 100644
index 00000000000..bd489fa977e
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/block.json
@@ -0,0 +1,46 @@
+{
+ "name": "woocommerce/collection-stock-filter",
+ "version": "1.0.0",
+ "title": "Stock Filter",
+ "description": "Enable customers to filter the product collection by stock status.",
+ "category": "woocommerce",
+ "keywords": [ "WooCommerce", "filter", "stock" ],
+ "supports": {
+ "interactivity": true,
+ "html": false,
+ "multiple": false
+ },
+ "attributes": {
+ "className": {
+ "type": "string",
+ "default": ""
+ },
+ "showCounts": {
+ "type": "boolean",
+ "default": false
+ },
+ "displayStyle": {
+ "type": "string",
+ "default": "list"
+ },
+ "selectType": {
+ "type": "string",
+ "default": "multiple"
+ },
+ "isPreview": {
+ "type": "boolean",
+ "default": false
+ },
+ "queryParam": {
+ "type": "object",
+ "default": {
+ "calculate_stock_status_counts": "true"
+ }
+ }
+ },
+ "usesContext": [ "collectionData" ],
+ "ancestor": [ "woocommerce/collection-filters" ],
+ "textdomain": "woo-gutenberg-products-block",
+ "apiVersion": 2,
+ "$schema": "https://schemas.wp.org/trunk/block.json"
+}
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/components/inspector.tsx b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/components/inspector.tsx
new file mode 100644
index 00000000000..973a125e052
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/components/inspector.tsx
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import { InspectorControls } from '@wordpress/block-editor';
+import { __ } from '@wordpress/i18n';
+import {
+ PanelBody,
+ ToggleControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import { EditProps } from '../types';
+
+export const Inspector = ( { attributes, setAttributes }: EditProps ) => {
+ const { showCounts, selectType, displayStyle } = attributes;
+
+ return (
+
+
+
+ setAttributes( {
+ showCounts: ! showCounts,
+ } )
+ }
+ />
+
+ setAttributes( {
+ selectType: value,
+ } )
+ }
+ className="wc-block-attribute-filter__multiple-toggle"
+ >
+
+
+
+
+ setAttributes( {
+ displayStyle: value,
+ } )
+ }
+ className="wc-block-attribute-filter__display-toggle"
+ >
+
+
+
+
+
+ );
+};
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/edit.tsx b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/edit.tsx
new file mode 100644
index 00000000000..de7bfc23242
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/edit.tsx
@@ -0,0 +1,131 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from '@wordpress/element';
+import classnames from 'classnames';
+import { useBlockProps } from '@wordpress/block-editor';
+import { Disabled } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { Icon, chevronDown } from '@wordpress/icons';
+import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
+import { CheckboxList } from '@woocommerce/blocks-components';
+import Label from '@woocommerce/base-components/filter-element-label';
+import FormTokenField from '@woocommerce/base-components/form-token-field';
+import type { BlockEditProps } from '@wordpress/blocks';
+import { getSetting } from '@woocommerce/settings';
+import {
+ useCollectionData,
+ useQueryStateByContext,
+} from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { BlockProps } from './types';
+import { Inspector } from './components/inspector';
+
+type CollectionData = {
+ // attribute_counts: null | unknown;
+ // price_range: null | unknown;
+ // rating_counts: null | unknown;
+ stock_status_counts: StockStatusCount[];
+};
+
+type StockStatusCount = {
+ status: string;
+ count: number;
+};
+
+const Edit = ( props: BlockEditProps< BlockProps > & { context: Context } ) => {
+ const blockProps = useBlockProps( {
+ className: classnames(
+ 'wc-block-stock-filter',
+ props.attributes.className
+ ),
+ } );
+
+ const { showCounts, displayStyle } = props.attributes;
+ const stockStatusOptions: Record< string, string > = getSetting(
+ 'stockStatusOptions',
+ {}
+ );
+
+ const [ queryState ] = useQueryStateByContext();
+
+ const { results: filteredCounts } = useCollectionData( {
+ queryStock: true,
+ queryState,
+ isEditor: true,
+ } );
+
+ const listOptions = useMemo( () => {
+ return Object.entries( stockStatusOptions ).map( ( [ key, value ] ) => {
+ const count =
+ // @ts-expect-error - there is a fault with useCollectionData types, it can be non-array.
+ ( filteredCounts as CollectionData )?.stock_status_counts?.find(
+ ( item: StockStatusCount ) => item.status === key
+ )?.count;
+
+ return {
+ value: key,
+ label: (
+
+ ),
+ };
+ } );
+ }, [ stockStatusOptions, filteredCounts, showCounts ] );
+
+ return (
+ <>
+ {
+
+
+
+
+ { displayStyle === 'dropdown' ? (
+ <>
+ null }
+ value={ [] }
+ />
+
+ >
+ ) : (
+ null }
+ isLoading={ false }
+ isDisabled={ true }
+ />
+ ) }
+
+
+
+ }
+ >
+ );
+};
+
+export default Edit;
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/frontend.ts b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/frontend.ts
new file mode 100644
index 00000000000..ec6e2e54c47
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/frontend.ts
@@ -0,0 +1,79 @@
+/**
+ * External dependencies
+ */
+import {
+ store as interactivityStore,
+ navigate,
+} from '@woocommerce/interactivity';
+import { DropdownContext } from '@woocommerce/interactivity-components/dropdown';
+import { HTMLElementEvent } from '@woocommerce/types';
+
+const getUrl = ( activeFilters: string ) => {
+ const url = new URL( window.location.href );
+ const { searchParams } = url;
+
+ if ( activeFilters !== '' ) {
+ searchParams.set( 'filter_stock_status', activeFilters );
+ } else {
+ searchParams.delete( 'filter_stock_status' );
+ }
+
+ return url.href;
+};
+
+type StockFilterState = {
+ filters: {
+ stockStatus: string;
+ activeFilters: string;
+ showDropdown: boolean;
+ };
+};
+
+type ActionProps = {
+ state: StockFilterState;
+ event: HTMLElementEvent< HTMLInputElement >;
+};
+
+interactivityStore( {
+ state: {
+ filters: {
+ stockStatus: '',
+ },
+ },
+ actions: {
+ filters: {
+ navigate: ( { context }: { context: DropdownContext } ) => {
+ if ( context.woocommerceDropdown.selectedItem.value ) {
+ navigate(
+ getUrl( context.woocommerceDropdown.selectedItem.value )
+ );
+ }
+ },
+ updateProducts: ( { event }: ActionProps ) => {
+ // get the active filters from the url:
+ const url = new URL( window.location.href );
+ const currentFilters =
+ url.searchParams.get( 'filter_stock_status' ) || '';
+
+ // split out the active filters into an array.
+ const filtersArr =
+ currentFilters === '' ? [] : currentFilters.split( ',' );
+
+ // if checked and not already in activeFilters, add to activeFilters
+ // if not checked and in activeFilters, remove from activeFilters.
+ if ( event.target.checked ) {
+ if ( ! currentFilters.includes( event.target.value ) ) {
+ filtersArr.push( event.target.value );
+ }
+ } else {
+ const index = filtersArr.indexOf( event.target.value );
+ if ( index > -1 ) {
+ filtersArr.splice( index, 1 );
+ }
+ }
+
+ navigate( getUrl( filtersArr.join( ',' ) ) );
+ },
+ },
+ },
+} );
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/index.tsx
new file mode 100644
index 00000000000..d87742faff0
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/index.tsx
@@ -0,0 +1,27 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import { Icon, box } from '@wordpress/icons';
+import { isExperimentalBuild } from '@woocommerce/block-settings';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import edit from './edit';
+import metadata from './block.json';
+
+if ( isExperimentalBuild() ) {
+ registerBlockType( metadata, {
+ icon: {
+ src: (
+
+ ),
+ },
+ edit,
+ } );
+}
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/preview.tsx b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/preview.tsx
new file mode 100644
index 00000000000..ea7735516ea
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/preview.tsx
@@ -0,0 +1,25 @@
+/**
+ * External dependencies
+ */
+import Label from '@woocommerce/base-components/filter-element-label';
+
+export const previewOptions = [
+ {
+ value: 'preview-1',
+ name: 'In Stock',
+ label: ,
+ textLabel: 'In Stock (3)',
+ },
+ {
+ value: 'preview-2',
+ name: 'Out of stock',
+ label: ,
+ textLabel: 'Out of stock (3)',
+ },
+ {
+ value: 'preview-3',
+ name: 'On backorder',
+ label: ,
+ textLabel: 'On backorder (2)',
+ },
+];
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/style.scss b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/style.scss
new file mode 100644
index 00000000000..75bbc6a6265
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/style.scss
@@ -0,0 +1,181 @@
+@import "../../../shared/styles/style";
+
+// Import styles we need to render the checkbox list and checkbox control.
+@import "../../../../../../packages/components/checkbox-list/style";
+@import "../../../../../../packages/checkout/components/checkbox-control/style";
+
+
+.wp-block-woocommerce-stock-filter {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ text-transform: inherit;
+ }
+}
+
+.wc-block-stock-filter {
+ &.is-loading {
+ @include placeholder();
+ margin-top: $gap;
+ box-shadow: none;
+ border-radius: 0;
+ }
+
+ margin-bottom: $gap-large;
+
+ .wc-block-stock-filter-list {
+ margin: 0;
+
+ li {
+ label {
+ cursor: pointer;
+ }
+
+ input {
+ cursor: pointer;
+ display: inline-block;
+ }
+ }
+ }
+
+ &.style-dropdown {
+ @include includeFormTokenFieldFix();
+ position: relative;
+ display: flex;
+ gap: $gap;
+ align-items: flex-start;
+
+ .wc-block-components-filter-submit-button {
+ height: 36px;
+ line-height: 1;
+ }
+
+ > svg {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ }
+ }
+
+ .wc-blocks-components-form-token-field-wrapper {
+ flex-grow: 1;
+ max-width: unset;
+ width: 0;
+ height: max-content;
+
+ &:not(.is-loading) {
+ border: 1px solid $gray-700 !important;
+ border-radius: 4px;
+ }
+
+ &.is-loading {
+ border-radius: em(4px);
+ }
+
+ .components-form-token-field {
+ border-radius: inherit;
+ }
+ }
+
+ .wc-blocks-components-form-token-field-wrapper .components-form-token-field__input-container {
+ @include reset-color();
+ @include reset-typography();
+ border: 0;
+ padding: $gap-smaller;
+ border-radius: inherit;
+
+ .components-form-token-field__input {
+ @include font-size(small);
+
+ &::placeholder {
+ color: $black;
+ }
+ }
+
+ .components-form-token-field__suggestions-list {
+ border: 1px solid $gray-700;
+ border-radius: 4px;
+ margin-top: $gap-smaller;
+ max-height: 21em;
+
+ .components-form-token-field__suggestion {
+ color: $black;
+ border: 1px solid $gray-400;
+ border-radius: 4px;
+ margin: $gap-small;
+ padding: $gap-small;
+ }
+ }
+
+ .components-form-token-field__token,
+ .components-form-token-field__suggestion {
+ @include font-size(small);
+ }
+ }
+
+ .wc-block-components-product-rating {
+ margin-bottom: 0;
+ }
+}
+
+.wc-blocks-components-form-token-field-wrapper:not(.single-selection) .components-form-token-field__input-container {
+ padding: $gap-smallest 30px $gap-smallest $gap-smaller;
+
+ .components-form-token-field__token-text {
+ background-color: $white;
+ border: 1px solid;
+ border-right: 0;
+ border-radius: 25px 0 0 25px;
+ padding: em($gap-smallest) em($gap-smaller) em($gap-smallest) em($gap-small);
+ line-height: 22px;
+ }
+
+ > .components-form-token-field__input {
+ margin: em($gap-smallest) 0;
+ }
+
+ .components-button.components-form-token-field__remove-token {
+ background-color: $white;
+ border: 1px solid;
+ border-left: 0;
+ border-radius: 0 25px 25px 0;
+ padding: 1px em($gap-smallest) 0 0;
+
+ &.has-icon svg {
+ background-color: $gray-200;
+ border-radius: 25px;
+ }
+ }
+}
+
+.wc-block-stock-filter__actions {
+ align-items: center;
+ display: flex;
+ gap: $gap;
+ justify-content: flex-end;
+ margin-top: $gap;
+
+ // The specificity here is needed to overwrite the margin-top that is inherited on WC block template pages such as Shop.
+ button[type="submit"]:not(.wp-block-search__button).wc-block-components-filter-submit-button {
+ margin-left: 0;
+ margin-top: 0;
+ @include font-size(small);
+ }
+
+ .wc-block-stock-filter__button {
+ margin-top: em($gap-smaller);
+ padding: em($gap-smaller) em($gap);
+ @include font-size(small);
+ }
+}
+
+.editor-styles-wrapper .wc-block-stock-filter .wc-block-stock-filter__button {
+ margin-top: em($gap-smaller);
+ padding: em($gap-smaller) em($gap);
+ @include font-size(small);
+}
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/types.ts
new file mode 100644
index 00000000000..73ec85a5967
--- /dev/null
+++ b/plugins/woocommerce-blocks/assets/js/blocks/collection-filters/inner-blocks/stock-filter/types.ts
@@ -0,0 +1,27 @@
+/**
+ * External dependencies
+ */
+import { BlockEditProps } from '@wordpress/blocks';
+
+export interface BlockProps {
+ className?: string;
+ showCounts: boolean;
+ isPreview?: boolean;
+ displayStyle: string;
+ selectType: string;
+ isEditor: boolean;
+}
+
+export interface DisplayOption {
+ value: string;
+ name: string;
+ label: JSX.Element;
+ textLabel: string;
+}
+
+export type Current = {
+ slug: string;
+ name: string;
+};
+
+export type EditProps = BlockEditProps< BlockProps >;
diff --git a/plugins/woocommerce-blocks/assets/js/interactivity/directives.js b/plugins/woocommerce-blocks/assets/js/interactivity/directives.js
index b747fa0cfdf..fc870f8e5b7 100644
--- a/plugins/woocommerce-blocks/assets/js/interactivity/directives.js
+++ b/plugins/woocommerce-blocks/assets/js/interactivity/directives.js
@@ -1,4 +1,10 @@
-import { useContext, useMemo, useEffect, useLayoutEffect } from 'preact/hooks';
+import {
+ useContext,
+ useMemo,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+} from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
import { useSignalEffect } from './utils';
import { directive } from './hooks';
@@ -7,18 +13,16 @@ import { prefetch, navigate } from './router';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
-const mergeDeepSignals = ( target, source ) => {
+const mergeDeepSignals = ( target, source, overwrite ) => {
for ( const k in source ) {
- if ( typeof peek( target, k ) === 'undefined' ) {
- target[ `$${ k }` ] = source[ `$${ k }` ];
- } else if (
- isObject( peek( target, k ) ) &&
- isObject( peek( source, k ) )
- ) {
+ if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) {
mergeDeepSignals(
target[ `$${ k }` ].peek(),
- source[ `$${ k }` ].peek()
+ source[ `$${ k }` ].peek(),
+ overwrite
);
+ } else if ( overwrite || typeof peek( target, k ) === 'undefined' ) {
+ target[ `$${ k }` ] = source[ `$${ k }` ];
}
}
};
@@ -29,20 +33,24 @@ export default () => {
'context',
( {
directives: {
- context: { default: context },
+ context: { default: newContext },
},
props: { children },
- context: inherited,
+ context: inheritedContext,
} ) => {
- const { Provider } = inherited;
- const inheritedValue = useContext( inherited );
- const value = useMemo( () => {
- const localValue = deepSignal( context );
- mergeDeepSignals( localValue, inheritedValue );
- return localValue;
- }, [ context, inheritedValue ] );
+ const { Provider } = inheritedContext;
+ const inheritedValue = useContext( inheritedContext );
+ const currentValue = useRef( deepSignal( {} ) );
+ currentValue.current = useMemo( () => {
+ const newValue = deepSignal( newContext );
+ mergeDeepSignals( newValue, inheritedValue );
+ mergeDeepSignals( currentValue.current, newValue, true );
+ return currentValue.current;
+ }, [ newContext, inheritedValue ] );
- return { children };
+ return (
+ { children }
+ );
},
{ priority: 5 }
);
@@ -87,9 +95,17 @@ export default () => {
// data-wc-on--[event]
directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
const contextValue = useContext( context );
+ const events = new Map();
Object.entries( on ).forEach( ( [ name, path ] ) => {
- element.props[ `on${ name }` ] = ( event ) => {
- evaluate( path, { event, context: contextValue } );
+ const event = name.split( '--' )[ 0 ];
+ if ( ! events.has( event ) ) events.set( event, new Set() );
+ events.get( event ).add( path );
+ } );
+ events.forEach( ( paths, event ) => {
+ element.props[ `on${ event }` ] = ( event ) => {
+ paths.forEach( ( path ) => {
+ evaluate( path, { event, context: contextValue } );
+ } );
};
} );
} );
diff --git a/plugins/woocommerce-blocks/bin/webpack-entries.js b/plugins/woocommerce-blocks/bin/webpack-entries.js
index 3c34ac3fde0..f037be12806 100644
--- a/plugins/woocommerce-blocks/bin/webpack-entries.js
+++ b/plugins/woocommerce-blocks/bin/webpack-entries.js
@@ -96,6 +96,10 @@ const blocks = {
'collection-filters': {
isExperimental: true,
},
+ 'collection-stock-filter': {
+ isExperimental: true,
+ customDir: 'collection-filters/inner-blocks/stock-filter',
+ },
'collection-price-filter': {
customDir: 'collection-filters/inner-blocks/price-filter',
isExperimental: true,
@@ -196,6 +200,10 @@ const entries = {
priceFormat: './packages/prices/index.js',
blocksCheckout: './packages/checkout/index.js',
blocksComponents: './packages/components/index.ts',
+
+ // interactivity components, exported as separate entries for now
+ 'wc-interactivity-dropdown':
+ './packages/interactivity-components/dropdown/index.ts',
},
main: {
// Shared blocks code
diff --git a/plugins/woocommerce-blocks/packages/interactivity-components/dropdown/index.ts b/plugins/woocommerce-blocks/packages/interactivity-components/dropdown/index.ts
new file mode 100644
index 00000000000..69b7180ea4b
--- /dev/null
+++ b/plugins/woocommerce-blocks/packages/interactivity-components/dropdown/index.ts
@@ -0,0 +1,95 @@
+/**
+ * External dependencies
+ */
+import { store as interactivityStore } from '@woocommerce/interactivity';
+
+export type DropdownContext = {
+ woocommerceDropdown: {
+ currentItem: {
+ label: string;
+ value: string;
+ };
+ selectedItem: {
+ label: string | null;
+ value: string | null;
+ };
+ hoveredItem: {
+ label: string | null;
+ value: string | null;
+ };
+ isOpen: boolean;
+ };
+};
+
+type Store = {
+ context: DropdownContext;
+ selectors: unknown;
+ ref: HTMLElement;
+};
+
+interactivityStore( {
+ state: {},
+ selectors: {
+ woocommerceDropdown: {
+ placeholderText: ( { context }: { context: DropdownContext } ) => {
+ const {
+ woocommerceDropdown: { selectedItem },
+ } = context;
+
+ return selectedItem.label || 'Select an option';
+ },
+ isSelected: ( { context }: { context: DropdownContext } ) => {
+ const {
+ woocommerceDropdown: {
+ currentItem: { value },
+ },
+ } = context;
+
+ return (
+ context.woocommerceDropdown.selectedItem.value === value ||
+ context.woocommerceDropdown.hoveredItem.value === value
+ );
+ },
+ },
+ },
+ actions: {
+ woocommerceDropdown: {
+ toggleIsOpen: ( store: Store ) => {
+ const {
+ context: { woocommerceDropdown },
+ } = store;
+
+ woocommerceDropdown.isOpen = ! woocommerceDropdown.isOpen;
+ },
+ selectDropdownItem: ( {
+ context,
+ }: {
+ context: DropdownContext;
+ } ) => {
+ const {
+ woocommerceDropdown: {
+ currentItem: { label, value },
+ },
+ } = context;
+
+ context.woocommerceDropdown.selectedItem = { label, value };
+ context.woocommerceDropdown.isOpen = false;
+ },
+ addHoverClass: ( { context }: { context: DropdownContext } ) => {
+ const {
+ woocommerceDropdown: {
+ currentItem: { label, value },
+ },
+ } = context;
+
+ context.woocommerceDropdown.hoveredItem = { label, value };
+ },
+ removeHoverClass: ( { context }: { context: DropdownContext } ) => {
+ context.woocommerceDropdown.hoveredItem = {
+ label: null,
+ value: null,
+ };
+ },
+ },
+ },
+} );
diff --git a/plugins/woocommerce-blocks/src/AssetsController.php b/plugins/woocommerce-blocks/src/AssetsController.php
index 3fbd7b961ac..7e81b6e948a 100644
--- a/plugins/woocommerce-blocks/src/AssetsController.php
+++ b/plugins/woocommerce-blocks/src/AssetsController.php
@@ -64,6 +64,9 @@ final class AssetsController {
$this->api->register_script( 'wc-blocks-checkout', 'build/blocks-checkout.js', [] );
$this->api->register_script( 'wc-blocks-components', 'build/blocks-components.js', [] );
+ // Register the interactivity components here for now.
+ $this->api->register_script( 'wc-interactivity-dropdown', 'build/wc-interactivity-dropdown.js', [] );
+
wp_add_inline_script(
'wc-blocks-middleware',
"
diff --git a/plugins/woocommerce-blocks/src/BlockTypes/CollectionFilters.php b/plugins/woocommerce-blocks/src/BlockTypes/CollectionFilters.php
index a3c5d24208a..d3e26974204 100644
--- a/plugins/woocommerce-blocks/src/BlockTypes/CollectionFilters.php
+++ b/plugins/woocommerce-blocks/src/BlockTypes/CollectionFilters.php
@@ -125,13 +125,7 @@ final class CollectionFilters extends AbstractBlock {
);
if ( ! empty( $response['body'] ) ) {
- $normalized_response = array();
-
- foreach ( $response['body'] as $key => $data ) {
- $normalized_response[ $key ] = (array) $data;
- }
-
- return $normalized_response;
+ return json_decode( wp_json_encode( $response['body'] ), true );
}
return array();
diff --git a/plugins/woocommerce-blocks/src/BlockTypes/CollectionStockFilter.php b/plugins/woocommerce-blocks/src/BlockTypes/CollectionStockFilter.php
new file mode 100644
index 00000000000..2fc9c47beb4
--- /dev/null
+++ b/plugins/woocommerce-blocks/src/BlockTypes/CollectionStockFilter.php
@@ -0,0 +1,172 @@
+asset_data_registry->add( 'stockStatusOptions', wc_get_product_stock_status_options(), true );
+ $this->asset_data_registry->add( 'hideOutOfStockItems', 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ), true );
+ }
+
+ /**
+ * Include and render the block.
+ *
+ * @param array $attributes Block attributes. Default empty array.
+ * @param string $content Block content. Default empty string.
+ * @param WP_Block $block Block instance.
+ * @return string Rendered block type output.
+ */
+ protected function render( $attributes, $content, $block ) {
+ // don't render if its admin, or ajax in progress.
+ if ( is_admin() || wp_doing_ajax() ) {
+ return '';
+ }
+
+ $stock_status_counts = $block->context['collectionData']['stock_status_counts'] ?? [];
+ $wrapper_attributes = get_block_wrapper_attributes();
+
+ wc_store(
+ array(
+ 'state' => array(
+ 'filters' => array(
+ 'stockStatus' => $stock_status_counts,
+ 'activeFilters' => '',
+ ),
+ ),
+ )
+ );
+
+ return sprintf(
+ '',
+ $wrapper_attributes,
+ $this->get_stock_filter_html( $stock_status_counts, $attributes ),
+ );
+ }
+
+ /**
+ * Stock filter HTML
+ *
+ * @param array $stock_counts An array of stock counts.
+ * @param array $attributes Block attributes. Default empty array.
+ * @return string Rendered block type output.
+ */
+ private function get_stock_filter_html( $stock_counts, $attributes ) {
+ $display_style = $attributes['displayStyle'] ?? 'list';
+ $stock_statuses = wc_get_product_stock_status_options();
+
+ // check the url params to select initial item on page load.
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
+ $selected_stock_status = isset( $_GET[ self::STOCK_STATUS_QUERY_VAR ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::STOCK_STATUS_QUERY_VAR ] ) ) : '';
+
+ $list_items = array_map(
+ function( $item ) use ( $stock_statuses ) {
+ return array(
+ 'label' => $stock_statuses[ $item['status'] ],
+ 'value' => $item['status'],
+ );
+ },
+ $stock_counts
+ );
+
+ $selected_items = array_values(
+ array_filter(
+ $list_items,
+ function( $item ) use ( $selected_stock_status ) {
+ return $item['value'] === $selected_stock_status;
+ }
+ )
+ );
+
+ // Just for the dropdown, we can only select 1 item.
+ $selected_item = $selected_items[0] ?? array(
+ 'label' => null,
+ 'value' => null,
+ );
+
+ ob_start();
+ ?>
+
+
+
+
+
+
+ $list_items,
+ 'action' => 'actions.filters.navigate',
+ 'selected_item' => $selected_item,
+ )
+ );
+ ?>
+
+
+ null,
+ 'value' => null,
+ );
+
+ $dropdown_context = array(
+ 'woocommerceDropdown' => array(
+ 'selectedItem' => $selected_item,
+ 'hoveredItem' => array(
+ 'label' => null,
+ 'value' => null,
+ ),
+ 'isOpen' => false,
+ ),
+ );
+
+ $action = $props['action'] ?? '';
+
+ // Items should be an array of objects with a label and value property.
+ $items = $props['items'] ?? [];
+
+ ob_start();
+ ?>
+
+