Merge branch 'trunk' into 51232-fixed-fatal-error-call-to-member

This commit is contained in:
Seghir Nadir 2024-09-18 12:03:12 +02:00 committed by GitHub
commit 2c9e2f7555
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
147 changed files with 7516 additions and 1082 deletions

View File

@ -31,7 +31,7 @@ jobs:
run: unzip plugins/woocommerce/woocommerce.zip -d zipfile
- name: Upload the zip file as an artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@ -0,0 +1,102 @@
name: Performance metrics
on:
pull_request:
paths:
- 'plugins/woocommerce/composer.*'
- 'plugins/woocommerce/client/admin/config/**'
- 'plugins/woocommerce/includes/**'
- 'plugins/woocommerce/lib/**'
- 'plugins/woocommerce/patterns/**'
- 'plugins/woocommerce/src/**'
- 'plugins/woocommerce/templates/**'
- 'plugins/woocommerce/tests/metrics/**'
- 'plugins/woocommerce/.wp-env.json'
- '.github/workflows/pr-assess-performance.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true
env:
WP_ARTIFACTS_PATH: ${{ github.workspace }}/tools/compare-perf/artifacts/
jobs:
benchmark:
name: Evaluate performance metrics
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
name: Checkout (${{ github.event_name == 'pull_request' && github.head_ref || github.sha }})
with:
fetch-depth: 0
- uses: ./.github/actions/setup-woocommerce-monorepo
name: Install Monorepo
with:
install: '@woocommerce/plugin-woocommerce...'
build: '@woocommerce/plugin-woocommerce'
build-type: 'full'
pull-playwright-cache: true
pull-package-deps: '@woocommerce/plugin-woocommerce'
#TODO: Inject WordPress version as per plugin requirements (relying to defaults currently).
- name: Start Test Environment
run: |
pnpm --filter="@woocommerce/plugin-woocommerce" test:e2e:install &
pnpm --filter="@woocommerce/plugin-woocommerce" env:test
# TODO: cache results if pushed to trunk
- name: Measure performance (@${{ github.sha }})
run: |
RESULTS_ID="editor_${{ github.sha }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics editor
RESULTS_ID="product-editor_${{ github.sha }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics product-editor
# In alignment with .github/workflows/scripts/run-metrics.sh, we should checkout 3d7d7f02017383937f1a4158d433d0e5d44b3dc9
# as baseline. But to avoid switching branches in 'Analyze results' step, we pick 55f855a2e6d769b5ae44305b2772eb30d3e721df
# which introduced reporting mode for the perf utility.
- name: Checkout (55f855a2e6d769b5ae44305b2772eb30d3e721df@trunk, further references as 'baseline')
run: |
git reset --hard && git checkout 55f855a2e6d769b5ae44305b2772eb30d3e721df
echo "WC_TRUNK_SHA=55f855a2e6d769b5ae44305b2772eb30d3e721df" >> $GITHUB_ENV
# Artifacts download/upload would be more reliable, but we couldn't make it working...
- uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319
name: Cache measurements (baseline)
with:
path: tools/compare-perf/artifacts/*_${{ env.WC_TRUNK_SHA }}_*
key: ${{ runner.os }}-woocommerce-performance-measures-${{ env.WC_TRUNK_SHA }}
- name: Verify cached measurements (baseline)
run: |
if test -n "$(find tools/compare-perf/artifacts/ -maxdepth 1 -name '*_${{ env.WC_TRUNK_SHA }}_*' -print -quit)"
then
echo "WC_MEASURE_BASELINE=no" >> $GITHUB_ENV
else
ls -l tools/compare-perf/artifacts/
echo "Triggering baseline benchmarking"
echo "WC_MEASURE_BASELINE=yes" >> $GITHUB_ENV
fi
- name: Build (baseline)
if: ${{ env.WC_MEASURE_BASELINE == 'yes' }}
run: |
git clean -n -d -X ./packages ./plugins | grep -v vendor | grep -v node_modules | sed -e 's/Would remove //g' | tr '\n' '\0' | xargs -0 rm -r
pnpm install --filter='@woocommerce/plugin-woocommerce...' --frozen-lockfile --config.dedupe-peer-dependents=false
pnpm --filter='@woocommerce/plugin-woocommerce' build
#TODO: is baseline Wordpress version changes, restart environment targeting it.
- name: Measure performance (@${{ env.WC_TRUNK_SHA }})
if: ${{ env.WC_MEASURE_BASELINE == 'yes' }}
run: |
RESULTS_ID="editor_${{ env.WC_TRUNK_SHA }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics editor
RESULTS_ID="product-editor_${{ env.WC_TRUNK_SHA }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics product-editor
- name: Analyze results
run: |
pnpm install --filter='compare-perf...' --frozen-lockfile --config.dedupe-peer-dependents=false
pnpm --filter="compare-perf" run compare compare-performance ${{ github.sha }} ${{ env.WC_TRUNK_SHA }} --tests-branch ${{ github.sha }} --skip-benchmarking
# TODO: Publish to CodeVitals (see .github/workflows/scripts/run-metrics.sh) if pushed to trunk

View File

@ -186,7 +186,7 @@ jobs:
run: bash bin/build-zip.sh
- name: Upload the zip file as an artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -216,7 +216,7 @@ jobs:
run: bash bin/build-zip.sh
- name: Upload the zip file as an artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -231,7 +231,7 @@ jobs:
if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }}
steps:
- id: download
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -279,7 +279,7 @@ jobs:
working-directory: tools/monorepo-utils
- id: download
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -300,7 +300,7 @@ jobs:
if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }}
steps:
- id: download
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -348,7 +348,7 @@ jobs:
working-directory: tools/monorepo-utils
- id: download
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -369,7 +369,7 @@ jobs:
if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }}
steps:
- id: download
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -380,7 +380,7 @@ jobs:
run: unzip ${{ steps.download.outputs.download-path }}/woocommerce.zip -d zipfile
- name: Upload the zip file as an artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -395,7 +395,7 @@ jobs:
if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }}
steps:
- id: download
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -406,7 +406,7 @@ jobs:
run: unzip ${{ steps.download.outputs.download-path }}/woocommerce.zip -d zipfile
- name: Upload the zip file as an artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@ -1,6 +1,8 @@
name: Storybook GitHub Pages
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch:
permissions:

30
.husky/post-checkout Executable file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env bash
. "$(dirname "$0")/_/husky.sh"
# The hook documentation: https://git-scm.com/docs/githooks.html#_post_checkout
CHECKOUT_TYPE=$3
HEAD_NEW=$2
HEAD_PREVIOUS=$1
whiteColoured='\033[0m'
orangeColoured='\033[1;33m'
# '1' is a branch checkout
if [ "$CHECKOUT_TYPE" = '1' ]; then
# Prompt about pnpm versions mismatch when switching between branches.
currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v 2>/dev/null ) || echo 'n/a' )
targetPnpmVersion=$( grep packageManager package.json | sed -nr 's/.+packageManager.+pnpm@([[:digit:].]+).+/\1/p' )
if [ "$currentPnpmVersion" != "$targetPnpmVersion" ]; then
printf "${orangeColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. If you are working on something in this branch, here are some hints on how to solve this:\n"
printf "${orangeColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n"
printf "${orangeColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n"
fi
# Auto-refresh dependencies when switching between branches.
changedManifests=$( ( git diff --name-only $HEAD_NEW $HEAD_PREVIOUS | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' )
if [ -n "$changedManifests" ]; then
printf "${whiteColoured}The following file(s) in the new branch differs from the original one, dependencies might need to be refreshed:\n"
printf "${whiteColoured} %s\n" $changedManifests
printf "${orangeColoured}If you are working on something in this branch, ensure to refresh dependencies with 'pnpm install --frozen-lockfile'\n"
fi
fi

View File

@ -1,6 +1,8 @@
#!/usr/bin/env bash
. "$(dirname "$0")/_/husky.sh"
# The hook documentation: https://git-scm.com/docs/githooks.html#_post_merge
changedManifests=$( ( git diff --name-only HEAD ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' )
if [ -n "$changedManifests" ]; then
printf "It was a change in the following file(s) - refreshing dependencies:\n"

View File

@ -3,8 +3,8 @@
"MD003": { "style": "atx" },
"MD007": { "indent": 4 },
"MD013": { "line_length": 9999 },
"MD024": { "allow_different_nesting": true },
"MD033": { "allowed_elements": ["video"] },
"MD024": { "siblings_only": true },
"MD033": { "allowed_elements": [ "video" ] },
"no-hard-tabs": false,
"whitespace": false
}

2
.npmrc
View File

@ -1,3 +1,5 @@
; adding this as npm 7 automatically installs peer dependencies but pnpm does not
auto-install-peers=true
strict-peer-dependencies=false
; See https://github.com/pnpm/pnpm/pull/8363 (we adding the setting now, to not miss when migrating to pnpm 9.7+)
manage-package-manager-versions=true

View File

@ -1059,7 +1059,7 @@
"menu_title": "DOM Events",
"tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md",
"hash": "59a4b49eb146774d33229bc60ab7d8f74381493f6e7089ca8f0e2d0eb433a7a4",
"hash": "85cffe1cc273621f16f7362b5efe28ede9689cee0a6e87d0d426014bacc24b05",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md",
"id": "c8d247b91472740075871e6b57a9583d893ac650"
}
@ -1229,7 +1229,7 @@
"menu_title": "Core critical flows",
"tags": "reference",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/quality-and-best-practices/core-critical-flows.md",
"hash": "472f5a240abe907fec83a8a9f88af6699f2d994aa7ae87faa1716a087baa66db",
"hash": "34109195216ebcb5b23e741391b9f355ba861777a5533d4ef1e341472cb5209e",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/quality-and-best-practices/core-critical-flows.md",
"id": "e561b46694dba223c38b87613ce4907e4e14333a"
},
@ -1804,5 +1804,5 @@
"categories": []
}
],
"hash": "dfe48a2a48d383c1f4e5b5bb4258d369dd4e80ac8582462b16bbfedd879cb0bf"
"hash": "47dc2a7e213e1e9d83e93a85dafdf8c7b539cc5474b4166eb6398b734d150ff3"
}

View File

@ -10,13 +10,13 @@ tags: how-to
This event is triggered when Product Collection block was rendered or re-rendered (e.g. due to page change).
### `wc-blocks_product_list_rendered` - `detail` parameters
### `detail` parameters
| Parameter | Type | Default value | Description |
| ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. |
### `wc-blocks_product_list_rendered` - Example usage
### Example usage
```javascript
window.document.addEventListener(
@ -32,14 +32,14 @@ window.document.addEventListener(
This event is triggered when some blocks are clicked in order to view product (redirect to product page).
### `wc-blocks_viewed_product` - `detail` parameters
### `detail` parameters
| Parameter | Type | Default value | Description |
| ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. |
| `productId` | number | | Product ID |
### `wc-blocks_viewed_product` Example usage
### Example usage
```javascript
window.document.addEventListener(

View File

@ -147,13 +147,14 @@ These flows will continually evolve as the platform evolves with flows updated,
### Merchant - Settings
| User Type | Flow Area | Flow Name | Test File |
| --------- | --------- | -------------------------------------- | ---------------------------------------- |
| --------- | --------- |----------------------------------------|------------------------------------------|
| Merchant | Settings | Update General Settings | merchant/settings-general.spec.js |
| Merchant | Settings | Add Tax Rates | merchant/settings-tax.spec.js |
| Merchant | Settings | Add Shipping Zones | merchant/create-shipping-zones.spec.js |
| Merchant | Settings | Add Shipping Classes | merchant/create-shipping-classes.spec.js |
| Merchant | Settings | Enable local pickup for checkout block | merchant/settings-shipping.spec.js |
| Merchant | Settings | Update payment settings | admin-tasks/payment.spec.js |
| Merchant | Settings | Handle Product Brands | merchant/create-product-brand.spec.js |
### Merchant - Coupons

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Added pre-API call permission checks for some API calls that were being called on non-admin accessible screens

View File

@ -10,11 +10,14 @@ import { apiFetch } from '@wordpress/data-controls';
import { NAMESPACE } from '../constants';
import { setNotes, setNotesQuery, setError } from './actions';
import { NoteQuery, Note } from './types';
import { checkUserCapability } from '../utils';
export function* getNotes( query: NoteQuery = {} ) {
const url = addQueryArgs( `${ NAMESPACE }/admin/notes`, query );
try {
yield checkUserCapability( 'manage_woocommerce' );
const notes: Note[] = yield apiFetch( {
path: url,
} );

View File

@ -31,6 +31,7 @@ import {
TaskListType,
} from './types';
import { Plugin } from '../plugins/types';
import { checkUserCapability } from '../utils';
const resolveSelect =
controls && controls.resolveSelect ? controls.resolveSelect : select;
@ -68,6 +69,8 @@ export function* getEmailPrefill() {
export function* getTaskLists() {
const deprecatedTasks = new DeprecatedTasks();
try {
yield checkUserCapability( 'manage_woocommerce' );
const results: TaskListType[] = yield apiFetch( {
path: WC_ADMIN_NAMESPACE + '/onboarding/tasks',
method: deprecatedTasks.hasDeprecatedTasks() ? 'POST' : 'GET',

View File

@ -27,6 +27,7 @@ import {
RecommendedTypes,
JetpackConnectionDataResponse,
} from './types';
import { checkUserCapability } from '../utils';
// Can be removed in WP 5.9, wp.data is supported in >5.7.
const resolveSelect =
@ -61,6 +62,8 @@ type ConnectJetpackResponse = {
export function* getActivePlugins() {
yield setIsRequesting( 'getActivePlugins', true );
try {
yield checkUserCapability( 'manage_woocommerce' );
const url = WC_ADMIN_NAMESPACE + '/plugins/active';
const results: PluginGetResponse = yield apiFetch( {
path: url,
@ -77,6 +80,8 @@ export function* getInstalledPlugins() {
yield setIsRequesting( 'getInstalledPlugins', true );
try {
yield checkUserCapability( 'manage_woocommerce' );
const url = WC_ADMIN_NAMESPACE + '/plugins/installed';
const results: PluginGetResponse = yield apiFetch( {
path: url,
@ -111,6 +116,8 @@ export function* getJetpackConnectionData() {
yield setIsRequesting( 'getJetpackConnectionData', true );
try {
yield checkUserCapability( 'manage_woocommerce' );
const url = JETPACK_NAMESPACE + '/connection/data';
const results: JetpackConnectionDataResponse = yield apiFetch( {

View File

@ -2,14 +2,15 @@
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import { apiFetch } from '@wordpress/data-controls';
import { apiFetch, select } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { BaseQueryParams } from './types/query-params';
import { fetchWithHeaders } from './controls';
import { USER_STORE_NAME } from './user';
import { WCUser } from './user/types';
function replacer( _: string, value: unknown ) {
if ( value ) {
if ( Array.isArray( value ) ) {
@ -100,3 +101,20 @@ export function* request< Query extends BaseQueryParams, DataType >(
return { items: response.data, totalCount };
}
}
/**
* Utility function to check if the current user has a specific capability.
*
* @param {string} capability - The capability to check (e.g. 'manage_woocommerce').
* @throws {Error} If the user does not have the required capability.
*/
export function* checkUserCapability( capability: string ) {
const currentUser: WCUser< 'capabilities' > = yield select(
USER_STORE_NAME,
'getCurrentUser'
);
if ( ! currentUser.capabilities[ capability ] ) {
throw new Error( `User does not have ${ capability } capability.` );
}
}

View File

@ -269,7 +269,8 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
visible:
( isEmbedded || ! isHomescreen ) &&
! isPerformingSetupTask() &&
! isProductScreen(),
! isProductScreen() &&
currentUserCan( 'manage_woocommerce' ),
};
const feedback = {

View File

@ -23,6 +23,7 @@ import { GlobalStylesRenderer } from '@wordpress/edit-site/build-module/componen
/**
* Internal dependencies
*/
import { trackEvent } from '../tracking';
import { editorIsLoaded } from '../utils';
import { BlockEditorContainer } from './block-editor-container';
@ -63,6 +64,7 @@ export const Editor = ( { isLoading }: { isLoading: boolean } ) => {
useEffect( () => {
if ( ! isLoading ) {
editorIsLoaded();
trackEvent( 'customize_your_store_assembler_hub_editor_loaded' );
}
}, [ isLoading ] );

View File

@ -100,7 +100,10 @@ function log_remote_event() {
time(),
'critical',
'Test PHP event from WC Beta Tester',
array( 'source' => 'wc-beta-tester' )
array(
'source' => 'wc-beta-tester',
'remote-logging' => true,
)
);
if ( $result ) {

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Set remote-logging context to true in log remote event method

View File

@ -25,7 +25,11 @@ interface CheckoutAddress {
setBillingAddress: ( data: Partial< BillingAddress > ) => void;
setEmail: ( value: string ) => void;
useShippingAsBilling: boolean;
editingBillingAddress: boolean;
editingShippingAddress: boolean;
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
setEditingBillingAddress: ( isEditing: boolean ) => void;
setEditingShippingAddress: ( isEditing: boolean ) => void;
defaultFields: AddressFields;
showShippingFields: boolean;
showBillingFields: boolean;
@ -40,15 +44,25 @@ interface CheckoutAddress {
*/
export const useCheckoutAddress = (): CheckoutAddress => {
const { needsShipping } = useShippingData();
const { useShippingAsBilling, prefersCollection } = useSelect(
( select ) => ( {
useShippingAsBilling:
select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(),
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
} )
);
const { __internalSetUseShippingAsBilling } =
useDispatch( CHECKOUT_STORE_KEY );
const {
useShippingAsBilling,
prefersCollection,
editingBillingAddress,
editingShippingAddress,
} = useSelect( ( select ) => ( {
useShippingAsBilling:
select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(),
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
editingBillingAddress:
select( CHECKOUT_STORE_KEY ).getEditingBillingAddress(),
editingShippingAddress:
select( CHECKOUT_STORE_KEY ).getEditingShippingAddress(),
} ) );
const {
__internalSetUseShippingAsBilling,
setEditingBillingAddress,
setEditingShippingAddress,
} = useDispatch( CHECKOUT_STORE_KEY );
const {
billingAddress,
setBillingAddress,
@ -77,6 +91,10 @@ export const useCheckoutAddress = (): CheckoutAddress => {
defaultFields,
useShippingAsBilling,
setUseShippingAsBilling: __internalSetUseShippingAsBilling,
editingBillingAddress,
editingShippingAddress,
setEditingBillingAddress,
setEditingShippingAddress,
needsShipping,
showShippingFields:
! forcedBillingAddress && needsShipping && ! prefersCollection,

View File

@ -7,14 +7,12 @@ import {
useCheckoutAddress,
useEditorContext,
noticeContexts,
useShippingData,
} from '@woocommerce/base-context';
import Noninteractive from '@woocommerce/base-components/noninteractive';
import type { ShippingAddress, FormFieldsConfig } from '@woocommerce/settings';
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY } from '@woocommerce/block-data';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
@ -36,14 +34,9 @@ const Block = ( {
showPhoneField: boolean;
requirePhoneField: boolean;
} ): JSX.Element => {
const {
shippingAddress,
billingAddress,
setShippingAddress,
useBillingAsShipping,
} = useCheckoutAddress();
const { billingAddress, setShippingAddress, useBillingAsShipping } =
useCheckoutAddress();
const { isEditor } = useEditorContext();
const { needsShipping } = useShippingData();
// Syncs shipping address with billing address if "Force shipping to the customer billing address" is enabled.
useEffectOnce( () => {
@ -101,19 +94,6 @@ const Block = ( {
};
} );
// Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor.
const hasAddress = !! (
billingAddress.address_1 &&
( billingAddress.first_name || billingAddress.last_name )
);
const { email, ...billingAddressWithoutEmail } = billingAddress;
const billingMatchesShipping = isShallowEqual(
billingAddressWithoutEmail,
shippingAddress
);
const defaultEditingAddress =
isEditor || ! hasAddress || ( needsShipping && billingMatchesShipping );
return (
<>
<StoreNoticesContainer context={ noticeContext } />
@ -121,7 +101,6 @@ const Block = ( {
{ cartDataLoaded ? (
<CustomerAddress
addressFieldsConfig={ addressFieldsConfig }
defaultEditing={ defaultEditingAddress }
/>
) : null }
</WrapperComponent>

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { useState, useCallback, useEffect } from '@wordpress/element';
import { useCallback, useEffect } from '@wordpress/element';
import { Form } from '@woocommerce/base-components/cart-checkout';
import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context';
import type {
@ -20,19 +20,18 @@ import AddressCard from '../../address-card';
const CustomerAddress = ( {
addressFieldsConfig,
defaultEditing = false,
}: {
addressFieldsConfig: FormFieldsConfig;
defaultEditing?: boolean;
} ) => {
const {
billingAddress,
setShippingAddress,
setBillingAddress,
useBillingAsShipping,
editingBillingAddress: editing,
setEditingBillingAddress: setEditing,
} = useCheckoutAddress();
const { dispatchCheckoutEvent } = useStoreEvents();
const [ editing, setEditing ] = useState( defaultEditing );
// Forces editing state if store has errors.
const { hasValidationErrors, invalidProps } = useSelect( ( select ) => {
@ -55,7 +54,7 @@ const CustomerAddress = ( {
if ( invalidProps.length > 0 && editing === false ) {
setEditing( true );
}
}, [ editing, hasValidationErrors, invalidProps.length ] );
}, [ editing, hasValidationErrors, invalidProps.length, setEditing ] );
const onChangeAddress = useCallback(
( values: AddressFormValues ) => {
@ -86,7 +85,7 @@ const CustomerAddress = ( {
isExpanded={ editing }
/>
),
[ billingAddress, addressFieldsConfig, editing ]
[ billingAddress, addressFieldsConfig, editing, setEditing ]
);
const renderAddressFormComponent = useCallback(

View File

@ -47,6 +47,7 @@ const Block = ( {
billingAddress,
useShippingAsBilling,
setUseShippingAsBilling,
setEditingBillingAddress,
} = useCheckoutAddress();
const { isEditor } = useEditorContext();
const isGuest = getSetting( 'currentUserId' ) === 0;
@ -116,10 +117,6 @@ const Block = ( {
const noticeContext = useShippingAsBilling
? [ noticeContexts.SHIPPING_ADDRESS, noticeContexts.BILLING_ADDRESS ]
: [ noticeContexts.SHIPPING_ADDRESS ];
const hasAddress = !! (
shippingAddress.address_1 &&
( shippingAddress.first_name || shippingAddress.last_name )
);
const { cartDataLoaded } = useSelect( ( select ) => {
const store = select( CART_STORE_KEY );
@ -128,9 +125,6 @@ const Block = ( {
};
} );
// Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor.
const defaultEditingAddress = isEditor || ! hasAddress;
return (
<>
<StoreNoticesContainer context={ noticeContext } />
@ -138,7 +132,6 @@ const Block = ( {
{ cartDataLoaded ? (
<CustomerAddress
addressFieldsConfig={ addressFieldsConfig }
defaultEditing={ defaultEditingAddress }
/>
) : null }
</WrapperComponent>
@ -151,6 +144,7 @@ const Block = ( {
if ( checked ) {
syncBillingWithShipping();
} else {
setEditingBillingAddress( true );
clearBillingAddress( billingAddress );
}
} }

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { useState, useCallback, useEffect } from '@wordpress/element';
import { useCallback, useEffect } from '@wordpress/element';
import { Form } from '@woocommerce/base-components/cart-checkout';
import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context';
import type {
@ -20,19 +20,18 @@ import AddressCard from '../../address-card';
const CustomerAddress = ( {
addressFieldsConfig,
defaultEditing = false,
}: {
addressFieldsConfig: FormFieldsConfig;
defaultEditing?: boolean;
} ) => {
const {
shippingAddress,
setShippingAddress,
setBillingAddress,
useShippingAsBilling,
editingShippingAddress: editing,
setEditingShippingAddress: setEditing,
} = useCheckoutAddress();
const { dispatchCheckoutEvent } = useStoreEvents();
const [ editing, setEditing ] = useState( defaultEditing );
// Forces editing state if store has errors.
const { hasValidationErrors, invalidProps } = useSelect( ( select ) => {
@ -54,7 +53,7 @@ const CustomerAddress = ( {
if ( invalidProps.length > 0 && editing === false ) {
setEditing( true );
}
}, [ editing, hasValidationErrors, invalidProps.length ] );
}, [ editing, hasValidationErrors, invalidProps.length, setEditing ] );
const onChangeAddress = useCallback(
( values: AddressFormValues ) => {
@ -85,7 +84,7 @@ const CustomerAddress = ( {
isExpanded={ editing }
/>
),
[ shippingAddress, addressFieldsConfig, editing ]
[ shippingAddress, addressFieldsConfig, editing, setEditing ]
);
const renderAddressFormComponent = useCallback(

View File

@ -21,15 +21,36 @@ import {
import type { ProductCollectionEditComponentProps } from '../types';
import { getCollectionByName } from '../collections';
const ProductPicker = ( props: ProductCollectionEditComponentProps ) => {
const ProductPicker = (
props: ProductCollectionEditComponentProps & {
isDeletedProductReference: boolean;
}
) => {
const blockProps = useBlockProps();
const attributes = props.attributes;
const { attributes, isDeletedProductReference } = props;
const collection = getCollectionByName( attributes.collection );
if ( ! collection ) {
return;
return null;
}
const infoText = isDeletedProductReference
? __(
'Previously selected product is no longer available.',
'woocommerce'
)
: createInterpolateElement(
sprintf(
/* translators: %s: collection title */
__(
'<strong>%s</strong> requires a product to be selected in order to display associated items.',
'woocommerce'
),
collection.title
),
{ strong: <strong /> }
);
return (
<div { ...blockProps }>
<Placeholder className="wc-blocks-product-collection__editor-product-picker">
@ -38,21 +59,7 @@ const ProductPicker = ( props: ProductCollectionEditComponentProps ) => {
icon={ info }
className="wc-blocks-product-collection__info-icon"
/>
<Text>
{ createInterpolateElement(
sprintf(
/* translators: %s: collection title */
__(
'<strong>%s</strong> requires a product to be selected in order to display associated items.',
'woocommerce'
),
collection.title
),
{
strong: <strong />,
}
) }
</Text>
<Text>{ infoText }</Text>
</HStack>
<ProductControl
selected={

View File

@ -174,6 +174,10 @@ $max-button-width: calc(100% / #{$max-button-columns});
.wc-blocks-product-collection__info-icon {
fill: var(--wp--preset--color--luminous-vivid-orange, #e26f56);
}
.woocommerce-search-list__search {
margin: 0;
}
}
// Linked Product Control

View File

@ -5,11 +5,13 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useGetLocation } from '@woocommerce/blocks/product-template/utils';
import { Spinner, Flex } from '@wordpress/components';
/**
* Internal dependencies
*/
import {
ProductCollectionContentProps,
ProductCollectionEditComponentProps,
ProductCollectionUIStatesInEditor,
} from '../types';
@ -17,7 +19,7 @@ import ProductCollectionPlaceholder from './product-collection-placeholder';
import ProductCollectionContent from './product-collection-content';
import CollectionSelectionModal from './collection-selection-modal';
import './editor.scss';
import { getProductCollectionUIStateInEditor } from '../utils';
import { useProductCollectionUIState } from '../utils';
import ProductPicker from './ProductPicker';
const Edit = ( props: ProductCollectionEditComponentProps ) => {
@ -31,49 +33,65 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
[ clientId ]
);
const productCollectionUIStateInEditor =
getProductCollectionUIStateInEditor( {
hasInnerBlocks,
const { productCollectionUIStateInEditor, isLoading } =
useProductCollectionUIState( {
location,
attributes: props.attributes,
attributes,
hasInnerBlocks,
usesReference: props.usesReference,
} );
/**
* Component to render based on the UI state.
*/
let Component,
isUsingReferencePreviewMode = false;
switch ( productCollectionUIStateInEditor ) {
case ProductCollectionUIStatesInEditor.COLLECTION_PICKER:
Component = ProductCollectionPlaceholder;
break;
case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER:
Component = ProductPicker;
break;
case ProductCollectionUIStatesInEditor.VALID:
Component = ProductCollectionContent;
break;
case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW:
Component = ProductCollectionContent;
isUsingReferencePreviewMode = true;
break;
default:
// By default showing collection chooser.
Component = ProductCollectionPlaceholder;
// Show spinner while calculating Editor UI state.
if ( isLoading ) {
return (
<Flex justify="center" align="center">
<Spinner />
</Flex>
);
}
const productCollectionContentProps: ProductCollectionContentProps = {
...props,
openCollectionSelectionModal: () => setIsSelectionModalOpen( true ),
location,
isUsingReferencePreviewMode:
productCollectionUIStateInEditor ===
ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW,
};
const renderComponent = () => {
switch ( productCollectionUIStateInEditor ) {
case ProductCollectionUIStatesInEditor.COLLECTION_PICKER:
return <ProductCollectionPlaceholder { ...props } />;
case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER:
return (
<ProductPicker
{ ...props }
isDeletedProductReference={ false }
/>
);
case ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE:
return (
<ProductPicker
{ ...props }
isDeletedProductReference={ true }
/>
);
case ProductCollectionUIStatesInEditor.VALID:
case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW:
return (
<ProductCollectionContent
{ ...productCollectionContentProps }
/>
);
default:
return <ProductCollectionPlaceholder { ...props } />;
}
};
return (
<>
<Component
{ ...props }
openCollectionSelectionModal={ () =>
setIsSelectionModalOpen( true )
}
isUsingReferencePreviewMode={ isUsingReferencePreviewMode }
location={ location }
usesReference={ props.usesReference }
/>
{ renderComponent() }
{ isSelectionModalOpen && (
<CollectionSelectionModal
clientId={ clientId }

View File

@ -7,10 +7,10 @@ import { InspectorAdvancedControls } from '@wordpress/block-editor';
* Internal dependencies
*/
import ForcePageReloadControl from './force-page-reload-control';
import type { ProductCollectionEditComponentProps } from '../../types';
import type { ProductCollectionContentProps } from '../../types';
export default function ProductCollectionAdvancedInspectorControls(
props: Omit< ProductCollectionEditComponentProps, 'preview' >
props: ProductCollectionContentProps
) {
const { clientId, attributes, setAttributes } = props;
const { forcePageReload } = attributes;

View File

@ -27,7 +27,7 @@ import {
import metadata from '../../block.json';
import { useTracksLocation } from '../../tracks-utils';
import {
ProductCollectionEditComponentProps,
ProductCollectionContentProps,
ProductCollectionAttributes,
CoreFilterNames,
FilterName,
@ -58,7 +58,7 @@ const prepareShouldShowFilter =
};
const ProductCollectionInspectorControls = (
props: ProductCollectionEditComponentProps
props: ProductCollectionContentProps
) => {
const { attributes, context, setAttributes } = props;
const { query, hideControls, displayLayout } = attributes;

View File

@ -18,7 +18,7 @@ import fastDeepEqual from 'fast-deep-equal/es6';
import type {
ProductCollectionAttributes,
ProductCollectionQuery,
ProductCollectionEditComponentProps,
ProductCollectionContentProps,
} from '../types';
import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants';
import {
@ -68,7 +68,7 @@ const useQueryId = (
const ProductCollectionContent = ( {
preview: { setPreviewState, initialPreviewState } = {},
...props
}: ProductCollectionEditComponentProps ) => {
}: ProductCollectionContentProps ) => {
const isInitialAttributesSet = useRef( false );
const {
clientId,

View File

@ -11,10 +11,10 @@ import { setQueryAttribute } from '../../utils';
import DisplaySettingsToolbar from './display-settings-toolbar';
import DisplayLayoutToolbar from './display-layout-toolbar';
import CollectionChooserToolbar from './collection-chooser-toolbar';
import type { ProductCollectionEditComponentProps } from '../../types';
import type { ProductCollectionContentProps } from '../../types';
export default function ToolbarControls(
props: Omit< ProductCollectionEditComponentProps, 'preview' >
props: ProductCollectionContentProps
) {
const { attributes, openCollectionSelectionModal, setAttributes } = props;
const { query, displayLayout } = attributes;

View File

@ -14,9 +14,9 @@ export enum ProductCollectionUIStatesInEditor {
PRODUCT_REFERENCE_PICKER = 'product_context_picker',
VALID_WITH_PREVIEW = 'uses_reference_preview_mode',
VALID = 'valid',
DELETED_PRODUCT_REFERENCE = 'deleted_product_reference',
// Future states
// INVALID = 'invalid',
// DELETED_PRODUCT_REFERENCE = 'deleted_product_reference',
}
export interface ProductCollectionAttributes {
@ -110,7 +110,6 @@ export interface ProductCollectionQuery {
export type ProductCollectionEditComponentProps =
BlockEditProps< ProductCollectionAttributes > & {
openCollectionSelectionModal: () => void;
preview?: {
initialPreviewState?: PreviewState;
setPreviewState?: SetPreviewState;
@ -119,8 +118,13 @@ export type ProductCollectionEditComponentProps =
context: {
templateSlug: string;
};
isUsingReferencePreviewMode: boolean;
};
export type ProductCollectionContentProps =
ProductCollectionEditComponentProps & {
location: WooCommerceBlockLocation;
isUsingReferencePreviewMode: boolean;
openCollectionSelectionModal: () => void;
};
export type TProductCollectionOrder = 'asc' | 'desc';

View File

@ -3,10 +3,16 @@
*/
import { store as blockEditorStore } from '@wordpress/block-editor';
import { addFilter } from '@wordpress/hooks';
import { select } from '@wordpress/data';
import { select, useSelect } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
import { isWpVersion } from '@woocommerce/settings';
import type { BlockEditProps, Block } from '@wordpress/blocks';
import { useEffect, useLayoutEffect, useState } from '@wordpress/element';
import {
useEffect,
useLayoutEffect,
useState,
useMemo,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import type { ProductResponseItem } from '@woocommerce/types';
import { getProduct } from '@woocommerce/editor-components/utils';
@ -193,7 +199,7 @@ export const getUsesReferencePreviewMessage = (
return '';
};
export const getProductCollectionUIStateInEditor = ( {
export const useProductCollectionUIState = ( {
location,
usesReference,
attributes,
@ -203,59 +209,111 @@ export const getProductCollectionUIStateInEditor = ( {
usesReference?: string[] | undefined;
attributes: ProductCollectionAttributes;
hasInnerBlocks: boolean;
} ): ProductCollectionUIStatesInEditor => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isCollectionSelected = !! attributes.collection;
} ) => {
// Fetch product to check if it's deleted.
// `product` will be undefined if it doesn't exist.
const productId = attributes.query?.productReference;
const { product, hasResolved } = useSelect(
( selectFunc ) => {
if ( ! productId ) {
return { product: null, hasResolved: true };
}
/**
* Case 1: Product context picker
*/
const isProductContextRequired = usesReference?.includes( 'product' );
const isProductContextSelected =
( attributes.query?.productReference ?? null ) !== null;
if (
isCollectionSelected &&
isProductContextRequired &&
! isInRequiredLocation &&
! isProductContextSelected
) {
return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER;
}
const { getEntityRecord, hasFinishedResolution } =
selectFunc( coreDataStore );
const selectorArgs = [ 'postType', 'product', productId ];
return {
product: getEntityRecord( ...selectorArgs ),
hasResolved: hasFinishedResolution(
'getEntityRecord',
selectorArgs
),
};
},
[ productId ]
);
const productCollectionUIStateInEditor = useMemo( () => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isCollectionSelected = !! attributes.collection;
/**
* Case 2: Preview mode - based on `usesReference` value
*/
if ( isInRequiredLocation ) {
/**
* Block shouldn't be in preview mode when:
* 1. Current location is archive and termId is available.
* 2. Current location is product and productId is available.
*
* Because in these cases, we have required context on the editor side.
* Case 1: Product context picker
*/
const isArchiveLocationWithTermId =
location.type === LocationType.Archive &&
( location.sourceData?.termId ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
const isProductContextRequired = usesReference?.includes( 'product' );
const isProductContextSelected =
( attributes.query?.productReference ?? null ) !== null;
if (
! isArchiveLocationWithTermId &&
! isProductLocationWithProductId
isCollectionSelected &&
isProductContextRequired &&
! isInRequiredLocation &&
! isProductContextSelected
) {
return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW;
return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER;
}
}
/**
* Case 3: Collection chooser
*/
if ( ! hasInnerBlocks && ! isCollectionSelected ) {
return ProductCollectionUIStatesInEditor.COLLECTION_PICKER;
}
// Case 2: Deleted product reference
if (
isCollectionSelected &&
isProductContextRequired &&
! isInRequiredLocation &&
isProductContextSelected
) {
const isProductDeleted =
productId &&
( product === undefined || product?.status === 'trash' );
if ( isProductDeleted ) {
return ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE;
}
}
return ProductCollectionUIStatesInEditor.VALID;
/**
* Case 3: Preview mode - based on `usesReference` value
*/
if ( isInRequiredLocation ) {
/**
* Block shouldn't be in preview mode when:
* 1. Current location is archive and termId is available.
* 2. Current location is product and productId is available.
*
* Because in these cases, we have required context on the editor side.
*/
const isArchiveLocationWithTermId =
location.type === LocationType.Archive &&
( location.sourceData?.termId ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
if (
! isArchiveLocationWithTermId &&
! isProductLocationWithProductId
) {
return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW;
}
}
/**
* Case 4: Collection chooser
*/
if ( ! hasInnerBlocks && ! isCollectionSelected ) {
return ProductCollectionUIStatesInEditor.COLLECTION_PICKER;
}
return ProductCollectionUIStatesInEditor.VALID;
}, [
location.type,
location.sourceData?.termId,
location.sourceData?.productId,
usesReference,
attributes.collection,
productId,
product,
hasInnerBlocks,
attributes.query?.productReference,
] );
return { productCollectionUIStateInEditor, isLoading: ! hasResolved };
};
export const useSetPreviewState = ( {

View File

@ -2,47 +2,12 @@
* External dependencies
*/
import {
getContext as getContextFn,
getContext,
store,
navigate as navigateFn,
} from '@woocommerce/interactivity';
import { getSetting } from '@woocommerce/settings';
export interface ProductFiltersContext {
isDialogOpen: boolean;
hasPageWithWordPressAdminBar: boolean;
}
const getContext = ( ns?: string ) =>
getContextFn< ProductFiltersContext >( ns );
store( 'woocommerce/product-filters', {
state: {
isDialogOpen: () => {
const context = getContext();
return context.isDialogOpen;
},
},
actions: {
openDialog: () => {
const context = getContext();
document.body.classList.add( 'wc-modal--open' );
context.hasPageWithWordPressAdminBar = Boolean(
document.getElementById( 'wpadminbar' )
);
context.isDialogOpen = true;
},
closeDialog: () => {
const context = getContext();
document.body.classList.remove( 'wc-modal--open' );
context.isDialogOpen = false;
},
},
callbacks: {},
} );
const isBlockTheme = getSetting< boolean >( 'isBlockTheme' );
const isProductArchive = getSetting< boolean >( 'isProductArchive' );
const needsRefresh = getSetting< boolean >(
@ -50,6 +15,28 @@ const needsRefresh = getSetting< boolean >(
false
);
function isParamsEqual(
obj1: Record< string, string >,
obj2: Record< string, string >
): boolean {
const keys1 = Object.keys( obj1 );
const keys2 = Object.keys( obj2 );
// First check if both objects have the same number of keys
if ( keys1.length !== keys2.length ) {
return false;
}
// Check if all keys and values are the same
for ( const key of keys1 ) {
if ( obj1[ key ] !== obj2[ key ] ) {
return false;
}
}
return true;
}
export function navigate( href: string, options = {} ) {
/**
* We may need to reset the current page when changing filters.
@ -79,3 +66,58 @@ export function navigate( href: string, options = {} ) {
}
return navigateFn( href, options );
}
export interface ProductFiltersContext {
isDialogOpen: boolean;
hasPageWithWordPressAdminBar: boolean;
params: Record< string, string >;
originalParams: Record< string, string >;
}
store( 'woocommerce/product-filters', {
state: {
isDialogOpen: () => {
const context = getContext< ProductFiltersContext >();
return context.isDialogOpen;
},
},
actions: {
openDialog: () => {
const context = getContext< ProductFiltersContext >();
document.body.classList.add( 'wc-modal--open' );
context.hasPageWithWordPressAdminBar = Boolean(
document.getElementById( 'wpadminbar' )
);
context.isDialogOpen = true;
},
closeDialog: () => {
const context = getContext< ProductFiltersContext >();
document.body.classList.remove( 'wc-modal--open' );
context.isDialogOpen = false;
},
},
callbacks: {
maybeNavigate: () => {
const { params, originalParams } =
getContext< ProductFiltersContext >();
if ( isParamsEqual( params, originalParams ) ) {
return;
}
const url = new URL( window.location.href );
const { searchParams } = url;
for ( const key in originalParams ) {
searchParams.delete( key, originalParams[ key ] );
}
for ( const key in params ) {
searchParams.set( key, params[ key ] );
}
navigate( url.href );
},
},
} );

View File

@ -6,22 +6,15 @@ import { store, getContext } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
import { navigate } from '../../frontend';
type ActiveFiltersContext = {
queryId: number;
params: string[];
};
import { ProductFiltersContext } from '../../frontend';
store( 'woocommerce/product-filter-active', {
actions: {
clearAll: () => {
const { params } = getContext< ActiveFiltersContext >();
const url = new URL( window.location.href );
const { searchParams } = url;
params.forEach( ( param ) => searchParams.delete( param ) );
navigate( url.href );
const productFiltersContext = getContext< ProductFiltersContext >(
'woocommerce/product-filters'
);
productFiltersContext.params = {};
},
},
} );

View File

@ -1,14 +1,12 @@
/**
* External dependencies
*/
import { store, getContext } from '@woocommerce/interactivity';
import { DropdownContext } from '@woocommerce/interactivity-components/dropdown';
import { HTMLElementEvent } from '@woocommerce/types';
import { store, getContext, getElement } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
import { navigate } from '../../frontend';
import { ProductFiltersContext } from '../../frontend';
type AttributeFilterContext = {
attributeSlug: string;
@ -16,102 +14,72 @@ type AttributeFilterContext = {
selectType: 'single' | 'multiple';
};
interface ActiveAttributeFilterContext extends AttributeFilterContext {
value: string;
}
function nonNullable< T >( value: T ): value is NonNullable< T > {
return value !== null && value !== undefined;
}
function getUrl(
selectedTerms: string[],
slug: string,
queryType: 'or' | 'and'
) {
const url = new URL( window.location.href );
const { searchParams } = url;
if ( selectedTerms.length > 0 ) {
searchParams.set( `filter_${ slug }`, selectedTerms.join( ',' ) );
searchParams.set( `query_type_${ slug }`, queryType );
} else {
searchParams.delete( `filter_${ slug }` );
searchParams.delete( `query_type_${ slug }` );
}
return url.href;
}
function getSelectedTermsFromUrl( slug: string ) {
const url = new URL( window.location.href );
return ( url.searchParams.get( `filter_${ slug }` ) || '' )
.split( ',' )
.filter( Boolean );
}
store( 'woocommerce/product-filter-attribute', {
actions: {
navigate: () => {
const dropdownContext = getContext< DropdownContext >(
'woocommerce/interactivity-dropdown'
);
const context = getContext< AttributeFilterContext >();
const filters = dropdownContext.selectedItems
.map( ( item ) => item.value )
.filter( nonNullable );
toggleFilter: () => {
const { ref } = getElement();
const targetAttribute =
ref.getAttribute( 'data-attribute-value' ) ?? 'value';
const termSlug = ref.getAttribute( targetAttribute );
navigate(
getUrl( filters, context.attributeSlug, context.queryType )
);
},
updateProducts: ( event: HTMLElementEvent< HTMLInputElement > ) => {
if ( ! event.target.value ) return;
if ( ! termSlug ) return;
const context = getContext< AttributeFilterContext >();
let selectedTerms = getSelectedTermsFromUrl(
context.attributeSlug
const { attributeSlug, queryType } =
getContext< AttributeFilterContext >();
const productFiltersContext = getContext< ProductFiltersContext >(
'woocommerce/product-filters'
);
if (
event.target.checked &&
! selectedTerms.includes( event.target.value )
! (
`filter_${ attributeSlug }` in productFiltersContext.params
)
) {
if ( context.selectType === 'multiple' )
selectedTerms.push( event.target.value );
if ( context.selectType === 'single' )
selectedTerms = [ event.target.value ];
} else {
selectedTerms = selectedTerms.filter(
( value ) => value !== event.target.value
);
productFiltersContext.params = {
...productFiltersContext.params,
[ `filter_${ attributeSlug }` ]: termSlug,
[ `query_type_${ attributeSlug }` ]: queryType,
};
return;
}
navigate(
getUrl(
selectedTerms,
context.attributeSlug,
context.queryType
)
);
},
removeFilter: () => {
const { attributeSlug, queryType, value } =
getContext< ActiveAttributeFilterContext >();
const selectedTerms =
productFiltersContext.params[
`filter_${ attributeSlug }`
].split( ',' );
if ( selectedTerms.includes( termSlug ) ) {
const remainingSelectedTerms = selectedTerms.filter(
( term ) => term !== termSlug
);
if ( remainingSelectedTerms.length > 0 ) {
productFiltersContext.params[
`filter_${ attributeSlug }`
] = remainingSelectedTerms.join( ',' );
} else {
const updatedParams = productFiltersContext.params;
let selectedTerms = getSelectedTermsFromUrl( attributeSlug );
delete updatedParams[ `filter_${ attributeSlug }` ];
delete updatedParams[ `query_type_${ attributeSlug }` ];
selectedTerms = selectedTerms.filter( ( item ) => item !== value );
navigate( getUrl( selectedTerms, attributeSlug, queryType ) );
productFiltersContext.params = updatedParams;
}
} else {
productFiltersContext.params[ `filter_${ attributeSlug }` ] =
selectedTerms.concat( termSlug ).join( ',' );
}
},
clearFilters: () => {
const { attributeSlug, queryType } =
getContext< ActiveAttributeFilterContext >();
const { attributeSlug } = getContext< AttributeFilterContext >();
const productFiltersContext = getContext< ProductFiltersContext >(
'woocommerce/product-filters'
);
const updatedParams = productFiltersContext.params;
navigate( getUrl( [], attributeSlug, queryType ) );
delete updatedParams[ `filter_${ attributeSlug }` ];
delete updatedParams[ `query_type_${ attributeSlug }` ];
productFiltersContext.params = updatedParams;
},
},
} );

View File

@ -24,6 +24,7 @@ import {
import './style.scss';
import './editor.scss';
import { EditProps } from './types';
import { getColorClasses, getColorVars } from './utils';
const Edit = ( props: EditProps ): JSX.Element => {
const {
@ -51,21 +52,9 @@ const Edit = ( props: EditProps ): JSX.Element => {
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,
...getColorClasses( attributes ),
} ),
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,
},
style: getColorVars( attributes ),
} );
const loadingState = useMemo( () => {
@ -131,9 +120,9 @@ const Edit = ( props: EditProps ): JSX.Element => {
) ) }
</ul>
{ ! isLoading && isLongList && (
<span className="wc-block-product-filter-checkbox-list__show-more">
<small>{ __( 'Show more…', 'woocommerce' ) }</small>
</span>
<button className="wc-block-product-filter-checkbox-list__show-more">
{ __( 'Show more…', 'woocommerce' ) }
</button>
) }
</div>
<InspectorControls group="color">

View File

@ -11,10 +11,12 @@ import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json';
import Edit from './edit';
import './style.scss';
import Save from './save';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
edit: Edit,
icon: productFilterOptions,
save: Save,
} );
}

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import clsx from 'clsx';
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
import { getColorClasses, getColorVars } from './utils';
const Save = ( {
attributes,
style,
}: {
attributes: BlockAttributes;
style: Record< string, string >;
} ) => {
const blockProps = useBlockProps.save( {
className: clsx(
'wc-block-product-filter-checkbox-list',
attributes.className,
getColorClasses( attributes )
),
style: {
...style,
...getColorVars( attributes ),
},
} );
return <div { ...blockProps } />;
};
export default Save;

View File

@ -4,11 +4,6 @@
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;
@ -34,6 +29,7 @@
width: 1em;
height: 1em;
border-radius: 2px;
pointer-events: none;
.has-option-element-color & {
display: none;
@ -51,6 +47,7 @@
margin: 0;
width: 1em;
background: var(--wc-product-filter-checkbox-list-option-element, transparent);
cursor: pointer;
}
.wc-block-product-filter-checkbox-list__input:checked + .wc-block-product-filter-checkbox-list__mark {
@ -75,12 +72,15 @@
color: var(--wc-product-filter-checkbox-list-option-element-selected, currentColor);
}
:where(.wc-block-product-filter-checkbox-list__text) {
font-size: 0.875em;
}
: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;
appearance: none;
background: transparent;
border: none;
padding: 0;
}

View File

@ -0,0 +1,66 @@
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
function getCSSVar( slug: string | undefined, value: string | undefined ) {
if ( slug ) {
return `var(--wp--preset--color--${ slug })`;
}
return value || '';
}
export function getColorVars( attributes: BlockAttributes ) {
const {
optionElement,
optionElementBorder,
optionElementSelected,
customOptionElement,
customOptionElementBorder,
customOptionElementSelected,
} = attributes;
const vars: Record< string, string > = {
'--wc-product-filter-checkbox-list-option-element': getCSSVar(
optionElement,
customOptionElement
),
'--wc-product-filter-checkbox-list-option-element-border': getCSSVar(
optionElementBorder,
customOptionElementBorder
),
'--wc-product-filter-checkbox-list-option-element-selected': getCSSVar(
optionElementSelected,
customOptionElementSelected
),
};
return Object.keys( vars ).reduce(
( acc: Record< string, string >, key ) => {
if ( vars[ key ] ) {
acc[ key ] = vars[ key ];
}
return acc;
},
{}
);
}
export function getColorClasses( attributes: BlockAttributes ) {
const {
optionElement,
optionElementBorder,
optionElementSelected,
customOptionElement,
customOptionElementBorder,
customOptionElementSelected,
} = attributes;
return {
'has-option-element-color': optionElement || customOptionElement,
'has-option-element-border-color':
optionElementBorder || customOptionElementBorder,
'has-option-element-selected-color':
optionElementSelected || customOptionElementSelected,
};
}

View File

@ -15,8 +15,44 @@
],
"supports": {},
"usesContext": [
"filterData",
"isParentSelected"
"filterData"
],
"attributes": {}
"attributes": {
"chipText":{
"type": "string"
},
"customChipText":{
"type": "string"
},
"chipBackground":{
"type": "string"
},
"customChipBackground":{
"type": "string"
},
"chipBorder":{
"type": "string"
},
"customChipBorder":{
"type": "string"
},
"selectedChipText":{
"type": "string"
},
"customSelectedChipText":{
"type": "string"
},
"selectedChipBackground":{
"type": "string"
},
"customSelectedChipBackground":{
"type": "string"
},
"selectedChipBorder":{
"type": "string"
},
"customSelectedChipBorder":{
"type": "string"
}
}
}

View File

@ -1,15 +1,260 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { useMemo } from '@wordpress/element';
import clsx from 'clsx';
import {
InspectorControls,
useBlockProps,
withColors,
// @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 { EditProps } from './types';
import './editor.scss';
import { getColorClasses, getColorVars } from './utils';
const Edit = () => {
return <div { ...useBlockProps() }>These are chips.</div>;
const Edit = ( props: EditProps ): JSX.Element => {
const colorGradientSettings = useMultipleOriginColorsAndGradients();
const {
context,
clientId,
attributes,
setAttributes,
chipText,
setChipText,
chipBackground,
setChipBackground,
chipBorder,
setChipBorder,
selectedChipText,
setSelectedChipText,
selectedChipBackground,
setSelectedChipBackground,
selectedChipBorder,
setSelectedChipBorder,
} = props;
const {
customChipText,
customChipBackground,
customChipBorder,
customSelectedChipText,
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
const { filterData } = context;
const { isLoading, items } = filterData;
const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-chips', {
'is-loading': isLoading,
...getColorClasses( attributes ),
} ),
style: getColorVars( attributes ),
} );
const loadingState = useMemo( () => {
return [ ...Array( 10 ) ].map( ( _, i ) => (
<div
className="wc-block-product-filter-chips__item"
key={ i }
style={ {
/* stylelint-disable */
width: Math.floor( Math.random() * ( 100 - 25 ) ) + '%',
} }
>
&nbsp;
</div>
) );
}, [] );
if ( ! items ) {
return <></>;
}
const threshold = 15;
const isLongList = items.length > threshold;
return (
<>
<div { ...blockProps }>
<div className="wc-block-product-filter-chips__items">
{ isLoading && loadingState }
{ ! isLoading &&
( isLongList
? items.slice( 0, threshold )
: items
).map( ( item, index ) => (
<div
key={ index }
className="wc-block-product-filter-chips__item"
aria-checked={ !! item.selected }
>
<span className="wc-block-product-filter-chips__label">
{ item.label }
</span>
</div>
) ) }
</div>
{ ! isLoading && isLongList && (
<button className="wc-block-product-filter-chips__show-more">
{ __( 'Show more…', 'woocommerce' ) }
</button>
) }
</div>
<InspectorControls group="color">
{ colorGradientSettings.hasColorsOrGradients && (
<ColorGradientSettingsDropdown
__experimentalIsRenderedInSidebar
settings={ [
{
label: __(
'Unselected Chip Text',
'woocommerce'
),
colorValue: chipText.color || customChipText,
onColorChange: ( colorValue: string ) => {
setChipText( colorValue );
setAttributes( {
customChipText: colorValue,
} );
},
resetAllFilter: () => {
setChipText( '' );
setAttributes( {
customChipText: '',
} );
},
},
{
label: __(
'Unselected Chip Border',
'woocommerce'
),
colorValue:
chipBorder.color || customChipBorder,
onColorChange: ( colorValue: string ) => {
setChipBorder( colorValue );
setAttributes( {
customChipBorder: colorValue,
} );
},
resetAllFilter: () => {
setChipBorder( '' );
setAttributes( {
customChipBorder: '',
} );
},
},
{
label: __(
'Unselected Chip Background',
'woocommerce'
),
colorValue:
chipBackground.color ||
customChipBackground,
onColorChange: ( colorValue: string ) => {
setChipBackground( colorValue );
setAttributes( {
customChipBackground: colorValue,
} );
},
resetAllFilter: () => {
setChipBackground( '' );
setAttributes( {
customChipBackground: '',
} );
},
},
{
label: __(
'Selected Chip Text',
'woocommerce'
),
colorValue:
selectedChipText.color ||
customSelectedChipText,
onColorChange: ( colorValue: string ) => {
setSelectedChipText( colorValue );
setAttributes( {
customSelectedChipText: colorValue,
} );
},
resetAllFilter: () => {
setSelectedChipText( '' );
setAttributes( {
customSelectedChipText: '',
} );
},
},
{
label: __(
'Selected Chip Border',
'woocommerce'
),
colorValue:
selectedChipBorder.color ||
customSelectedChipBorder,
onColorChange: ( colorValue: string ) => {
setSelectedChipBorder( colorValue );
setAttributes( {
customSelectedChipBorder: colorValue,
} );
},
resetAllFilter: () => {
setSelectedChipBorder( '' );
setAttributes( {
customSelectedChipBorder: '',
} );
},
},
{
label: __(
'Selected Chip Background',
'woocommerce'
),
colorValue:
selectedChipBackground.color ||
customSelectedChipBackground,
onColorChange: ( colorValue: string ) => {
setSelectedChipBackground( colorValue );
setAttributes( {
customSelectedChipBackground:
colorValue,
} );
},
resetAllFilter: () => {
setSelectedChipBackground( '' );
setAttributes( {
customSelectedChipBackground: '',
} );
},
},
] }
panelId={ clientId }
{ ...colorGradientSettings }
/>
) }
</InspectorControls>
</>
);
};
export default Edit;
export default withColors( {
chipText: 'chip-text',
chipBorder: 'chip-border',
chipBackground: 'chip-background',
selectedChipText: 'selected-chip-text',
selectedChipBorder: 'selected-chip-border',
selectedChipBackground: 'selected-chip-background',
} )( Edit );

View File

@ -0,0 +1,6 @@
.wc-block-product-filter-chips.is-loading {
.wc-block-product-filter-chips__item {
@include placeholder();
margin: 5px 0;
}
}

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { getElement, getContext, store } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
export type ChipsContext = {
items: {
id: string;
label: string;
value: string;
checked: boolean;
}[];
showAll: boolean;
};
store( 'woocommerce/product-filter-chips', {
actions: {
showAllItems: () => {
const context = getContext< ChipsContext >();
context.showAll = true;
},
selectItem: () => {
const { ref } = getElement();
const value = ref.getAttribute( 'value' );
if ( ! value ) return;
const context = getContext< ChipsContext >();
context.items = context.items.map( ( item ) => {
if ( item.value.toString() === value ) {
return {
...item,
checked: ! item.checked,
};
}
return item;
} );
},
},
} );

View File

@ -10,11 +10,13 @@ import { registerBlockType } from '@wordpress/blocks';
*/
import metadata from './block.json';
import Edit from './edit';
import Save from './save';
import './style.scss';
if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, {
edit: Edit,
icon: productFilterOptions,
save: Save,
} );
}

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import clsx from 'clsx';
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
import { getColorClasses, getColorVars } from './utils';
const Save = ( {
attributes,
style,
}: {
attributes: BlockAttributes;
style: Record< string, string >;
} ) => {
const blockProps = useBlockProps.save( {
className: clsx(
'wc-block-product-filter-chips',
attributes.className,
getColorClasses( attributes )
),
style: {
...style,
...getColorVars( attributes ),
},
} );
return <div { ...blockProps } />;
};
export default Save;

View File

@ -1,3 +1,55 @@
:where(.wc-block-product-filter-chips) {
// WIP
:where(.wc-block-product-filter-chips__items) {
display: flex;
flex-wrap: wrap;
gap: $gap-smallest;
}
:where(.wc-block-product-filter-chips__item) {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
padding: $gap-smallest $gap-smaller;
appearance: none;
background: transparent;
border-radius: 2px;
font-size: 0.875em;
cursor: pointer;
.has-chip-text & {
color: var(--wc-product-filter-chips-text);
}
.has-chip-background & {
background: var(--wc-product-filter-chips-background);
}
.has-chip-border & {
border-color: var(--wc-product-filter-chips-border);
}
}
:where(.wc-block-product-filter-chips__item[aria-checked="true"]) {
background: currentColor;
.has-selected-chip-text & {
color: var(--wc-product-filter-chips-selected-text);
}
.has-selected-chip-background & {
background: var(--wc-product-filter-chips-selected-background);
}
.has-selected-chip-border & {
border-color: var(--wc-product-filter-chips-selected-border);
}
}
:where(
.wc-block-product-filter-chips:not(.has-selected-chip-text)
.wc-block-product-filter-chips__item[aria-checked="true"]
> .wc-block-product-filter-chips__label
) {
filter: invert(100%);
}
:where(.wc-block-product-filter-chips__show-more) {
text-decoration: underline;
appearance: none;
background: transparent;
border: none;
padding: 0;
}

View File

@ -6,9 +6,44 @@ import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { FilterBlockContext } from '../../types';
export type Color = {
slug?: string;
name?: string;
class?: string;
color: string;
};
export type BlockAttributes = {
className: string;
chipText?: string;
customChipText?: string;
chipBackground?: string;
customChipBackground?: string;
chipBorder?: string;
customChipBorder?: string;
selectedChipText?: string;
customSelectedChipText?: string;
selectedChipBackground?: string;
customSelectedChipBackground?: string;
selectedChipBorder?: string;
customSelectedChipBorder?: string;
};
export type EditProps = BlockEditProps< BlockAttributes >;
export type EditProps = BlockEditProps< BlockAttributes > & {
style: Record< string, string >;
context: FilterBlockContext;
chipText: Color;
setChipText: ( value: string ) => void;
chipBackground: Color;
setChipBackground: ( value: string ) => void;
chipBorder: Color;
setChipBorder: ( value: string ) => void;
selectedChipText: Color;
setSelectedChipText: ( value: string ) => void;
selectedChipBackground: Color;
setSelectedChipBackground: ( value: string ) => void;
selectedChipBorder: Color;
setSelectedChipBorder: ( value: string ) => void;
};

View File

@ -0,0 +1,91 @@
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
function getCSSVar( slug: string | undefined, value: string | undefined ) {
if ( slug ) {
return `var(--wp--preset--color--${ slug })`;
}
return value || '';
}
export function getColorVars( attributes: BlockAttributes ) {
const {
chipText,
chipBackground,
chipBorder,
selectedChipText,
selectedChipBackground,
selectedChipBorder,
customChipText,
customChipBackground,
customChipBorder,
customSelectedChipText,
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
const vars: Record< string, string > = {
'--wc-product-filter-chips-text': getCSSVar( chipText, customChipText ),
'--wc-product-filter-chips-background': getCSSVar(
chipBackground,
customChipBackground
),
'--wc-product-filter-chips-border': getCSSVar(
chipBorder,
customChipBorder
),
'--wc-product-filter-chips-selected-text': getCSSVar(
selectedChipText,
customSelectedChipText
),
'--wc-product-filter-chips-selected-background': getCSSVar(
selectedChipBackground,
customSelectedChipBackground
),
'--wc-product-filter-chips-selected-border': getCSSVar(
selectedChipBorder,
customSelectedChipBorder
),
};
return Object.keys( vars ).reduce(
( acc: Record< string, string >, key ) => {
if ( vars[ key ] ) {
acc[ key ] = vars[ key ];
}
return acc;
},
{}
);
}
export function getColorClasses( attributes: BlockAttributes ) {
const {
chipText,
chipBackground,
chipBorder,
selectedChipText,
selectedChipBackground,
selectedChipBorder,
customChipText,
customChipBackground,
customChipBorder,
customSelectedChipText,
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
return {
'has-chip-text-color': chipText || customChipText,
'has-chip-background-color': chipBackground || customChipBackground,
'has-chip-border-color': chipBorder || customChipBorder,
'has-selected-chip-text-color':
selectedChipText || customSelectedChipText,
'has-selected-chip-background-color':
selectedChipBackground || customSelectedChipBackground,
'has-selected-chip-border-color':
selectedChipBorder || customSelectedChipBorder,
};
}

View File

@ -10,6 +10,10 @@ import {
import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import ErrorPlaceholder, {
ErrorObject,
} from '@woocommerce/editor-components/error-placeholder';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
@ -132,14 +136,16 @@ export const Edit = ( {
useEffect( () => {
const mode = getMode( currentTemplateId, templateType );
const newProductGalleryClientId =
attributes.productGalleryClientId || clientId;
setAttributes( {
...attributes,
mode,
productGalleryClientId: clientId,
productGalleryClientId: newProductGalleryClientId,
} );
// Move the Thumbnails block to the correct above or below the Large Image based on the thumbnailsPosition attribute.
moveInnerBlocksToPosition( attributes, clientId );
moveInnerBlocksToPosition( attributes, newProductGalleryClientId );
}, [
setAttributes,
attributes,
@ -148,6 +154,18 @@ export const Edit = ( {
templateType,
] );
if ( attributes.productGalleryClientId !== clientId ) {
const error = {
message: __(
'productGalleryClientId and clientId codes mismatch.',
'woocommerce'
),
type: 'general',
} as ErrorObject;
return <ErrorPlaceholder error={ error } isLoading={ false } />;
}
return (
<div { ...blockProps }>
<InspectorControls>

View File

@ -136,10 +136,10 @@ export const moveInnerBlocksToPosition = (
): void => {
const { getBlock, getBlockRootClientId, getBlockIndex } =
select( 'core/block-editor' );
const { moveBlockToPosition } = dispatch( 'core/block-editor' );
const productGalleryBlock = getBlock( clientId );
if ( productGalleryBlock ) {
if ( productGalleryBlock?.name === 'woocommerce/product-gallery' ) {
const { moveBlockToPosition } = dispatch( 'core/block-editor' );
const previousLayout = productGalleryBlock.innerBlocks.length
? productGalleryBlock.innerBlocks[ 0 ].attributes.layout
: null;

View File

@ -17,4 +17,6 @@ export const ACTION_TYPES = {
SET_REDIRECT_URL: 'SET_REDIRECT_URL',
SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT',
SET_USE_SHIPPING_AS_BILLING: 'SET_USE_SHIPPING_AS_BILLING',
SET_EDITING_BILLING_ADDRESS: 'SET_EDITING_BILLING_ADDRESS',
SET_EDITING_SHIPPING_ADDRESS: 'SET_EDITING_SHIPPING_ADDRESS',
} as const;

View File

@ -118,6 +118,30 @@ export const __internalSetUseShippingAsBilling = (
useShippingAsBilling,
} );
/**
* Set whether the billing address is being edited
*
* @param isEditing True if the billing address is being edited, false otherwise
*/
export const setEditingBillingAddress = ( isEditing: boolean ) => {
return {
type: types.SET_EDITING_BILLING_ADDRESS,
isEditing,
};
};
/**
* Set whether the shipping address is being edited
*
* @param isEditing True if the shipping address is being edited, false otherwise
*/
export const setEditingShippingAddress = ( isEditing: boolean ) => {
return {
type: types.SET_EDITING_SHIPPING_ADDRESS,
isEditing,
};
};
/**
* Whether an account should be created for the user while checking out
*
@ -182,6 +206,8 @@ export type CheckoutAction =
| typeof __internalSetCustomerId
| typeof __internalSetCustomerPassword
| typeof __internalSetUseShippingAsBilling
| typeof setEditingBillingAddress
| typeof setEditingShippingAddress
| typeof __internalSetShouldCreateAccount
| typeof __internalSetOrderNotes
| typeof setPrefersCollection

View File

@ -23,8 +23,28 @@ export type CheckoutState = {
shouldCreateAccount: boolean; // Should a user account be created?
status: STATUS; // Status of the checkout
useShippingAsBilling: boolean; // Should the billing form be hidden and inherit the shipping address?
editingBillingAddress: boolean; // Is the billing address being edited?
editingShippingAddress: boolean; // Is the shipping address being edited?
};
// Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor.
const hasBillingAddress = !! (
checkoutData.billing_address.address_1 &&
( checkoutData.billing_address.first_name ||
checkoutData.billing_address.last_name )
);
const hasShippingAddress = !! (
checkoutData.shipping_address.address_1 &&
( checkoutData.shipping_address.first_name ||
checkoutData.shipping_address.last_name )
);
const billingMatchesShipping = isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
);
export const defaultState: CheckoutState = {
additionalFields: checkoutData.additional_fields || {},
calculatingCount: 0,
@ -38,8 +58,7 @@ export const defaultState: CheckoutState = {
redirectUrl: '',
shouldCreateAccount: false,
status: STATUS.IDLE,
useShippingAsBilling: isSameAddress(
checkoutData.billing_address,
checkoutData.shipping_address
),
useShippingAsBilling: billingMatchesShipping,
editingBillingAddress: ! hasBillingAddress,
editingShippingAddress: ! hasShippingAddress,
};

View File

@ -130,6 +130,20 @@ const reducer = ( state = defaultState, action: CheckoutAction ) => {
}
break;
case types.SET_EDITING_BILLING_ADDRESS:
newState = {
...state,
editingBillingAddress: action.isEditing,
};
break;
case types.SET_EDITING_SHIPPING_ADDRESS:
newState = {
...state,
editingShippingAddress: action.isEditing,
};
break;
case types.SET_SHOULD_CREATE_ACCOUNT:
if (
action.shouldCreateAccount !== undefined &&

View File

@ -36,6 +36,14 @@ export const getUseShippingAsBilling = ( state: CheckoutState ) => {
return state.useShippingAsBilling;
};
export const getEditingBillingAddress = ( state: CheckoutState ) => {
return state.editingBillingAddress;
};
export const getEditingShippingAddress = ( state: CheckoutState ) => {
return state.editingShippingAddress;
};
export const getExtensionData = ( state: CheckoutState ) => {
return state.extensionData;
};

View File

@ -1,5 +1,7 @@
# Checkout Store (`wc/store/checkout`) <!-- omit in toc -->
<!-- markdownlint-disable MD024 -->
> 💡 What's the difference between the Cart Store and the Checkout Store?
>
> The **Cart Store (`wc/store/cart`)** manages and retrieves data about the shopping cart, including items, customer data, and interactions like coupons.
@ -173,6 +175,36 @@ const store = select( CHECKOUT_STORE_KEY );
const useShippingAsBilling = store.getUseShippingAsBilling();
```
### getEditingBillingAddress
Returns true if the billing address is being edited.
#### _Returns_ <!-- omit in toc -->
- `boolean`: True if the billing address is being edited.
#### _Example_ <!-- omit in toc -->
```js
const store = select( CHECKOUT_STORE_KEY );
const editingBillingAddress = store.getEditingBillingAddress();
```
### getEditingShippingAddress
Returns true if the shipping address is being edited.
#### _Returns_ <!-- omit in toc -->
- `boolean`: True if the shipping address is being edited.
#### _Example_ <!-- omit in toc -->
```js
const store = select( CHECKOUT_STORE_KEY );
const editingShippingAddress = store.getEditingShippingAddress();
```
### hasError
Returns true if an error occurred, and false otherwise.
@ -293,7 +325,6 @@ const store = select( CHECKOUT_STORE_KEY );
const isCalculating = store.isCalculating();
```
### prefersCollection
Returns true if the customer prefers to collect their order, and false otherwise.
@ -326,6 +357,36 @@ const store = dispatch( CHECKOUT_STORE_KEY );
store.setPrefersCollection( true );
```
### setEditingBillingAddress
Set the billing address to editing state or collapsed state. Note that if the address has invalid fields, it will not be set to collapsed state.
#### _Parameters_ <!-- omit in toc -->
- _isEditing_ `boolean`: True to set the billing address to editing state, false to set it to collapsed state.
#### _Example_ <!-- omit in toc -->
```js
const store = dispatch( CHECKOUT_STORE_KEY );
store.setEditingBillingAddress( true );
```
### setEditingShippingAddress
Set the shipping address to editing state or collapsed state. Note that if the address has invalid fields, it will not be set to collapsed state.
#### _Parameters_ <!-- omit in toc -->
- _isEditing_ `boolean`: True to set the shipping address to editing state, false to set it to collapsed state.
#### _Example_ <!-- omit in toc -->
```js
const store = dispatch( CHECKOUT_STORE_KEY );
store.setEditingShippingAddress( true );
```
<!-- FEEDBACK -->
---

View File

@ -16,7 +16,6 @@
.wc-block-components-panel__button {
box-sizing: border-box;
height: auto;
line-height: inherit;
margin-top: em(6px);
padding-right: #{24px + $gap-smaller};
padding-top: em($gap-small - 6px);

View File

@ -207,7 +207,8 @@ class ProductCollectionPage {
}
async chooseProductInEditorProductPickerIfAvailable(
pageReference: Page | FrameLocator
pageReference: Page | FrameLocator,
productName = 'Album'
) {
const editorProductPicker = pageReference.locator(
SELECTORS.productPicker
@ -217,7 +218,7 @@ class ProductCollectionPage {
await editorProductPicker
.locator( 'label' )
.filter( {
hasText: 'Album',
hasText: productName,
} )
.click();
}

View File

@ -356,4 +356,84 @@ test.describe( 'Product Collection registration', () => {
await expect( previewButtonLocator ).toBeHidden();
} );
} );
test( 'Product picker should be shown when selected product is deleted', async ( {
pageObject,
admin,
editor,
requestUtils,
page,
} ) => {
// Add a new test product to the database
let testProductId: number | null = null;
const newProduct = await requestUtils.rest( {
method: 'POST',
path: 'wc/v3/products',
data: {
name: 'A Test Product',
price: 10,
},
} );
testProductId = newProduct.id;
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost(
'myCustomCollectionWithProductContext'
);
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas,
'A Test Product'
);
await expect( editorProductPicker ).toBeHidden();
await editor.saveDraft();
// Delete the product
await requestUtils.rest( {
method: 'DELETE',
path: `wc/v3/products/${ testProductId }`,
} );
// Product picker should be shown in Editor
await admin.page.reload();
const deletedProductPicker = editor.canvas.getByText(
'Previously selected product'
);
await expect( deletedProductPicker ).toBeVisible();
// Change status from "trash" to "publish"
await requestUtils.rest( {
method: 'PUT',
path: `wc/v3/products/${ testProductId }`,
data: {
status: 'publish',
},
} );
// Product Picker shouldn't be shown as product is available now
await page.reload();
await expect( editorProductPicker ).toBeHidden();
// Delete the product from database, instead of trashing it
await requestUtils.rest( {
method: 'DELETE',
path: `wc/v3/products/${ testProductId }`,
params: {
// Bypass trash and permanently delete the product
force: true,
},
} );
// Product picker should be shown in Editor
await expect( deletedProductPicker ).toBeVisible();
} );
} );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Product Collection: Added Editor UI for missing product reference

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Comment: Fixed call to a member function is_visible() on string | content-product.php:23

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Comment: Changed Product attributes placeholder to e.g. length or weight

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Move address card state management to data stores in Checkout block.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add `locale` param when redirecting to the Jetpack auth page.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Expand the e2e suite we're running on WPCOM part #2.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix bug where manually triggering `added_to_cart` event without a button element caused an Exception.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Track customize_your_store_assembler_hub_editor_loaded event to measure CYS loading time

View File

@ -0,0 +1,5 @@
Significance: patch
Type: add
Comment: [Experimental] Product Filters Chips style and new interactivity API implementation

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Added missing wp-block- classes to order confirmation, store notices, and breadcrumb blocks.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Updating Markdown linter rule

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Improve remote logging structure and content

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Reducing noise in remote logging

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Reduce dependency of remote logging on WC_Tracks

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix error when adding the Product Gallery (Beta) block into a pattern

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
wc_get_cart_url should only return current URL if on the cart page. This excludes the usage of WOOCOMMERCE_CART.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Added pre-API call permission checks for some API calls that were being called on non-admin accessible screens

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Fix a type mismatch in UpdateProducts.php

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Update unit test to account for WordPress nightly change. See core trac ticket 61739

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Introduced Product Brands.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Refine PHP Fatal Error Counting in MC Stat

View File

@ -0,0 +1,3 @@
table.wp-list-table .column-taxonomy-product_brand {
width: 10%;
}

View File

@ -0,0 +1,173 @@
/* Brand description on archives */
.tax-product_brand .brand-description {
overflow: hidden;
zoom: 1;
}
.tax-product_brand .brand-description img.brand-thumbnail {
width: 25%;
float: right;
}
.tax-product_brand .brand-description .text {
width: 72%;
float: left;
}
/* Brand description widget */
.widget_brand_description img {
-webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
-moz-box-sizing: border-box; /* Firefox, other Gecko */
box-sizing: border-box; /* Opera/IE 8+ */
width: 100%;
max-width: none;
height: auto;
margin: 0 0 1em;
}
/* Brand thumbnails widget */
ul.brand-thumbnails {
margin-left: 0;
margin-bottom: 0;
clear: both;
list-style: none;
}
ul.brand-thumbnails:before {
clear: both;
content: "";
display: table;
}
ul.brand-thumbnails:after {
clear: both;
content: "";
display: table;
}
ul.brand-thumbnails li {
float: left;
margin: 0 3.8% 1em 0;
padding: 0;
position: relative;
width: 22.05%; /* 4 columns */
}
ul.brand-thumbnails.fluid-columns li {
width: auto;
}
ul.brand-thumbnails:not(.fluid-columns) li.first {
clear: both;
}
ul.brand-thumbnails:not(.fluid-columns) li.last {
margin-right: 0;
}
ul.brand-thumbnails.columns-1 li {
width: 100%;
margin-right: 0;
}
ul.brand-thumbnails.columns-2 li {
width: 48%;
}
ul.brand-thumbnails.columns-3 li {
width: 30.75%;
}
ul.brand-thumbnails.columns-5 li {
width: 16.95%;
}
ul.brand-thumbnails.columns-6 li {
width: 13.5%;
}
.brand-thumbnails li img {
-webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */
-moz-box-sizing: border-box; /* Firefox, other Gecko */
box-sizing: border-box; /* Opera/IE 8+ */
width: 100%;
max-width: none;
height: auto;
margin: 0;
}
@media screen and (max-width: 768px) {
ul.brand-thumbnails:not(.fluid-columns) li {
width: 48% !important;
}
ul.brand-thumbnails:not(.fluid-columns) li.first {
clear: none;
}
ul.brand-thumbnails:not(.fluid-columns) li.last {
margin-right: 3.8%
}
ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(odd) {
clear: both;
}
ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(even) {
margin-right: 0;
}
}
/* Brand thumbnails description */
.brand-thumbnails-description li {
text-align: center;
}
.brand-thumbnails-description li .term-thumbnail img {
display: inline;
}
.brand-thumbnails-description li .term-description {
margin-top: 1em;
text-align: left;
}
/* A-Z Shortcode */
#brands_a_z h3:target {
text-decoration: underline;
}
ul.brands_index {
list-style: none outside;
overflow: hidden;
zoom: 1;
}
ul.brands_index li {
float: left;
margin: 0 2px 2px 0;
}
ul.brands_index li a, ul.brands_index li span {
border: 1px solid #ccc;
padding: 6px;
line-height: 1em;
float: left;
text-decoration: none;
}
ul.brands_index li span {
border-color: #eee;
color: #ddd;
}
ul.brands_index li a:hover {
border-width: 2px;
padding: 5px;
text-decoration: none;
}
ul.brands_index li a.active {
border-width: 2px;
padding: 5px;
}
div#brands_a_z a.top {
border: 1px solid #ccc;
padding: 4px;
line-height: 1em;
float: right;
text-decoration: none;
font-size: 0.8em;
}

View File

@ -0,0 +1,94 @@
/* global wc_enhanced_select_params */
/* global wpApiSettings */
jQuery( function( $ ) {
function getEnhancedSelectFormatString() {
return {
'language': {
errorLoading: function() {
// Workaround for https://github.com/select2/select2/issues/4355 instead of i18n_ajax_error.
return wc_enhanced_select_params.i18n_searching;
},
inputTooLong: function( args ) {
var overChars = args.input.length - args.maximum;
if ( 1 === overChars ) {
return wc_enhanced_select_params.i18n_input_too_long_1;
}
return wc_enhanced_select_params.i18n_input_too_long_n.replace( '%qty%', overChars );
},
inputTooShort: function( args ) {
var remainingChars = args.minimum - args.input.length;
if ( 1 === remainingChars ) {
return wc_enhanced_select_params.i18n_input_too_short_1;
}
return wc_enhanced_select_params.i18n_input_too_short_n.replace( '%qty%', remainingChars );
},
loadingMore: function() {
return wc_enhanced_select_params.i18n_load_more;
},
maximumSelected: function( args ) {
if ( args.maximum === 1 ) {
return wc_enhanced_select_params.i18n_selection_too_long_1;
}
return wc_enhanced_select_params.i18n_selection_too_long_n.replace( '%qty%', args.maximum );
},
noResults: function() {
return wc_enhanced_select_params.i18n_no_matches;
},
searching: function() {
return wc_enhanced_select_params.i18n_searching;
}
}
};
}
try {
$( document.body )
.on( 'wc-enhanced-select-init', function() {
// Ajax category search boxes
$( ':input.wc-brands-search' ).filter( ':not(.enhanced)' ).each( function() {
var select2_args = $.extend( {
allowClear : $( this ).data( 'allow_clear' ) ? true : false,
placeholder : $( this ).data( 'placeholder' ),
minimumInputLength: $( this ).data( 'minimum_input_length' ) ? $( this ).data( 'minimum_input_length' ) : 3,
escapeMarkup : function( m ) {
return m;
},
ajax: {
url: wpApiSettings.root + 'wc/v3/products/brands',
dataType: 'json',
delay: 250,
headers: {
'X-WP-Nonce': wpApiSettings.nonce
},
data: function( params ) {
return {
hide_empty: 1,
search: params.term
};
},
processResults: function( data ) {
const results = data
.map( term => ({ id: term.slug, text: term.name + ' (' + term.count + ')' }) )
return {
results
};
},
cache: true
}
}, getEnhancedSelectFormatString() );
$( this ).selectWoo( select2_args ).addClass( 'enhanced' );
});
})
.trigger( 'wc-enhanced-select-init' );
} catch( err ) {
// If select2 failed (conflict?) log the error but don't stop other scripts breaking.
window.console.log( err );
}
});

View File

@ -173,6 +173,8 @@ jQuery( function( $ ) {
* Update cart page elements after add to cart events.
*/
AddToCartHandler.prototype.updateButton = function( e, fragments, cart_hash, $button ) {
// Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function.
// If there is no button we don't want to crash.
$button = typeof $button === 'undefined' ? false : $button;
if ( $button ) {
@ -222,19 +224,25 @@ jQuery( function( $ ) {
* Update cart live region message after add/remove cart events.
*/
AddToCartHandler.prototype.alertCartUpdated = function( e, fragments, cart_hash, $button ) {
var message = $button.data( 'success_message' );
// Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function.
// If there is no button we don't want to crash.
$button = typeof $button === 'undefined' ? false : $button;
if ( !message ) {
return;
}
if ( $button ) {
var message = $button.data( 'success_message' );
if ( !message ) {
return;
}
// If the response after adding/removing an item to/from the cart is really fast,
// screen readers may not have time to identify the changes in the live region element.
// So, we add a delay to ensure an interval between messages.
e.data.addToCartHandler.$liveRegion
.delay(1000)
.text( message )
.attr( 'aria-relevant', 'all' );
// If the response after adding/removing an item to/from the cart is really fast,
// screen readers may not have time to identify the changes in the live region element.
// So, we add a delay to ensure an interval between messages.
e.data.addToCartHandler.$liveRegion
.delay(1000)
.text( message )
.attr( 'aria-relevant', 'all' );
}
};
/**

View File

@ -0,0 +1,792 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName.
/**
* Brands Admin Page
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @package WooCommerce\Admin
* @version 9.4.0
*/
declare( strict_types = 1);
use Automattic\Jetpack\Constants;
/**
* WC_Brands_Admin class.
*/
class WC_Brands_Admin {
/**
* Settings array.
*
* @var array
*/
public $settings_tabs;
/**
* Admin fields.
*
* @var array
*/
public $fields = array();
/**
* __construct function.
*/
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'scripts' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'styles' ) );
add_action( 'product_brand_add_form_fields', array( $this, 'add_thumbnail_field' ) );
add_action( 'product_brand_edit_form_fields', array( $this, 'edit_thumbnail_field' ), 10, 1 );
add_action( 'created_term', array( $this, 'thumbnail_field_save' ), 10, 1 );
add_action( 'edit_term', array( $this, 'thumbnail_field_save' ), 10, 1 );
add_action( 'product_brand_pre_add_form', array( $this, 'taxonomy_description' ) );
add_filter( 'woocommerce_sortable_taxonomies', array( $this, 'sort_brands' ) );
add_filter( 'manage_edit-product_brand_columns', array( $this, 'columns' ) );
add_filter( 'manage_product_brand_custom_column', array( $this, 'column' ), 10, 3 );
add_filter( 'manage_product_posts_columns', array( $this, 'product_columns' ), 20, 1 );
add_filter(
'woocommerce_products_admin_list_table_filters',
function ( $args ) {
$args['product_brand'] = array( $this, 'render_product_brand_filter' );
return $args;
}
);
$this->settings_tabs = array(
'brands' => __( 'Brands', 'woocommerce' ),
);
// Hiding setting for future depreciation. Only users who have touched this settings should see it.
$setting_value = get_option( 'wc_brands_show_description' );
if ( is_string( $setting_value ) ) {
// Add the settings fields to each tab.
$this->init_form_fields();
add_action( 'woocommerce_get_sections_products', array( $this, 'add_settings_tab' ) );
add_action( 'woocommerce_get_settings_products', array( $this, 'add_settings_section' ), null, 2 );
}
add_action( 'woocommerce_update_options_catalog', array( $this, 'save_admin_settings' ) );
/* 2.1 */
add_action( 'woocommerce_update_options_products', array( $this, 'save_admin_settings' ) );
// Add brands filtering to the coupon creation screens.
add_action( 'woocommerce_coupon_options_usage_restriction', array( $this, 'add_coupon_brands_fields' ) );
add_action( 'woocommerce_coupon_options_save', array( $this, 'save_coupon_brands' ) );
// Permalinks.
add_filter( 'pre_update_option_woocommerce_permalinks', array( $this, 'validate_product_base' ) );
add_action( 'current_screen', array( $this, 'add_brand_base_setting' ) );
// CSV Import/Export Support.
// https://github.com/woocommerce/woocommerce/wiki/Product-CSV-Importer-&-Exporter
// Import.
add_filter( 'woocommerce_csv_product_import_mapping_options', array( $this, 'add_column_to_importer_exporter' ), 10 );
add_filter( 'woocommerce_csv_product_import_mapping_default_columns', array( $this, 'add_default_column_mapping' ), 10 );
add_filter( 'woocommerce_product_import_inserted_product_object', array( $this, 'process_import' ), 10, 2 );
// Export.
add_filter( 'woocommerce_product_export_column_names', array( $this, 'add_column_to_importer_exporter' ), 10 );
add_filter( 'woocommerce_product_export_product_default_columns', array( $this, 'add_column_to_importer_exporter' ), 10 );
add_filter( 'woocommerce_product_export_product_column_brand_ids', array( $this, 'get_column_value_brand_ids' ), 10, 2 );
}
/**
* Add the settings for the new "Brands" subtab.
*
* @since 9.4.0
*
* @param array $settings Settings.
* @param array $current_section Current section.
*/
public function add_settings_section( $settings, $current_section ) {
if ( 'brands' === $current_section ) {
$settings = $this->settings;
}
return $settings;
}
/**
* Add a new "Brands" subtab to the "Products" tab.
*
* @since 9.4.0
* @param array $sections Sections.
*/
public function add_settings_tab( $sections ) {
$sections = array_merge( $sections, $this->settings_tabs );
return $sections;
}
/**
* Display coupon filter fields relating to brands.
*
* @since 9.4.0
* @return void
*/
public function add_coupon_brands_fields() {
global $post;
// Brands.
?>
<p class="form-field"><label for="product_brands"><?php esc_html_e( 'Product brands', 'woocommerce' ); ?></label>
<select id="product_brands" name="product_brands[]" style="width: 50%;" class="wc-enhanced-select" multiple="multiple" data-placeholder="<?php esc_attr_e( 'Any brand', 'woocommerce' ); ?>">
<?php
$category_ids = (array) get_post_meta( $post->ID, 'product_brands', true );
$categories = get_terms(
array(
'taxonomy' => 'product_brand',
'orderby' => 'name',
'hide_empty' => false,
)
);
if ( $categories ) {
foreach ( $categories as $cat ) {
echo '<option value="' . esc_attr( $cat->term_id ) . '"' . selected( in_array( $cat->term_id, $category_ids, true ), true, false ) . '>' . esc_html( $cat->name ) . '</option>';
}
}
?>
</select>
<?php
echo wc_help_tip( esc_html__( 'A product must be associated with this brand for the coupon to remain valid or, for "Product Discounts", products with these brands will be discounted.', 'woocommerce' ) );
// Exclude Brands.
?>
<p class="form-field"><label for="exclude_product_brands"><?php esc_html_e( 'Exclude brands', 'woocommerce' ); ?></label>
<select id="exclude_product_brands" name="exclude_product_brands[]" style="width: 50%;" class="wc-enhanced-select" multiple="multiple" data-placeholder="<?php esc_attr_e( 'No brands', 'woocommerce' ); ?>">
<?php
$category_ids = (array) get_post_meta( $post->ID, 'exclude_product_brands', true );
$categories = get_terms(
array(
'taxonomy' => 'product_brand',
'orderby' => 'name',
'hide_empty' => false,
)
);
if ( $categories ) {
foreach ( $categories as $cat ) {
echo '<option value="' . esc_attr( $cat->term_id ) . '"' . selected( in_array( $cat->term_id, $category_ids, true ), true, false ) . '>' . esc_html( $cat->name ) . '</option>';
}
}
?>
</select>
<?php
echo wc_help_tip( esc_html__( 'Product must not be associated with these brands for the coupon to remain valid or, for "Product Discounts", products associated with these brands will not be discounted.', 'woocommerce' ) );
}
/**
* Save coupon filter fields relating to brands.
*
* @since 9.4.0
* @param int $post_id Post ID.
* @return void
*/
public function save_coupon_brands( $post_id ) {
$product_brands = isset( $_POST['product_brands'] ) ? array_map( 'intval', $_POST['product_brands'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing
$exclude_product_brands = isset( $_POST['exclude_product_brands'] ) ? array_map( 'intval', $_POST['exclude_product_brands'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing
// Save.
update_post_meta( $post_id, 'product_brands', $product_brands );
update_post_meta( $post_id, 'exclude_product_brands', $exclude_product_brands );
}
/**
* Prepare form fields to be used in the various tabs.
*/
public function init_form_fields() {
/**
* Filter Brands settings.
*
* @since 9.4.0
*
* @param array $settings Brands settings.
*/
$this->settings = apply_filters(
'woocommerce_brands_settings_fields',
array(
array(
'name' => __( 'Brands Archives', 'woocommerce' ),
'type' => 'title',
'desc' => '',
'id' => 'brands_archives',
),
array(
'name' => __( 'Show description', 'woocommerce' ),
'desc' => __( 'Choose to show the brand description on the archive page. Turn this off if you intend to use the description widget instead. Please note: this is only for themes that do not show the description.', 'woocommerce' ),
'tip' => '',
'id' => 'wc_brands_show_description',
'css' => '',
'std' => 'yes',
'type' => 'checkbox',
),
array(
'type' => 'sectionend',
'id' => 'brands_archives',
),
)
);
}
/**
* Enqueue scripts.
*
* @return void
*/
public function scripts() {
$screen = get_current_screen();
$version = Constants::get_constant( 'WC_VERSION' );
$suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
if ( 'edit-product' === $screen->id ) {
wp_register_script(
'wc-brands-enhanced-select',
WC()->plugin_url() . '/assets/js/admin/wc-brands-enhanced-select' . $suffix . '.js',
array( 'jquery', 'selectWoo', 'wc-enhanced-select', 'wp-api' ),
$version,
true
);
wp_localize_script(
'wc-brands-enhanced-select',
'wc_brands_enhanced_select_params',
array( 'ajax_url' => get_rest_url() . 'brands/search' )
);
wp_enqueue_script( 'wc-brands-enhanced-select' );
}
if ( in_array( $screen->id, array( 'edit-product_brand' ), true ) ) {
wp_enqueue_media();
wp_enqueue_style( 'woocommerce_admin_styles' );
}
}
/**
* Enqueue styles.
*
* @return void
*/
public function styles() {
$version = Constants::get_constant( 'WC_VERSION' );
wp_enqueue_style( 'brands-admin-styles', WC()->plugin_url() . '/assets/css/brands-admin.css', array(), $version );
}
/**
* Admin settings function.
*/
public function admin_settings() {
woocommerce_admin_fields( $this->settings );
}
/**
* Save admin settings function.
*/
public function save_admin_settings() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['section'] ) && 'brands' === $_GET['section'] ) {
woocommerce_update_options( $this->settings );
}
}
/**
* Category thumbnails.
*/
public function add_thumbnail_field() {
global $woocommerce;
?>
<div class="form-field">
<label><?php esc_html_e( 'Thumbnail', 'woocommerce' ); ?></label>
<div id="product_cat_thumbnail" style="float:left;margin-right:10px;"><img src="<?php echo esc_url( wc_placeholder_img_src() ); ?>" width="60px" height="60px" /></div>
<div style="line-height:60px;">
<input type="hidden" id="product_cat_thumbnail_id" name="product_cat_thumbnail_id" />
<button type="button" class="upload_image_button button"><?php esc_html_e( 'Upload/Add image', 'woocommerce' ); ?></button>
<button type="button" class="remove_image_button button"><?php esc_html_e( 'Remove image', 'woocommerce' ); ?></button>
</div>
<script type="text/javascript">
jQuery(function(){
// Only show the "remove image" button when needed
if ( ! jQuery('#product_cat_thumbnail_id').val() ) {
jQuery('.remove_image_button').hide();
}
// Uploading files
var file_frame;
jQuery(document).on( 'click', '.upload_image_button', function( event ){
event.preventDefault();
// If the media frame already exists, reopen it.
if ( file_frame ) {
file_frame.open();
return;
}
// Create the media frame.
file_frame = wp.media.frames.downloadable_file = wp.media({
title: '<?php echo esc_js( __( 'Choose an image', 'woocommerce' ) ); ?>',
button: {
text: '<?php echo esc_js( __( 'Use image', 'woocommerce' ) ); ?>',
},
multiple: false
});
// When an image is selected, run a callback.
file_frame.on( 'select', function() {
attachment = file_frame.state().get('selection').first().toJSON();
jQuery('#product_cat_thumbnail_id').val( attachment.id );
jQuery('#product_cat_thumbnail img').attr('src', attachment.url );
jQuery('.remove_image_button').show();
});
// Finally, open the modal.
file_frame.open();
});
jQuery(document).on( 'click', '.remove_image_button', function( event ){
jQuery('#product_cat_thumbnail img').attr('src', '<?php echo esc_js( wc_placeholder_img_src() ); ?>');
jQuery('#product_cat_thumbnail_id').val('');
jQuery('.remove_image_button').hide();
return false;
});
});
</script>
<div class="clear"></div>
</div>
<?php
}
/**
* Edit thumbnail field row.
*
* @param WP_Term $term Current taxonomy term object.
*/
public function edit_thumbnail_field( $term ) {
global $woocommerce;
$image = '';
$thumbnail_id = get_term_meta( $term->term_id, 'thumbnail_id', true );
if ( $thumbnail_id ) {
$image = wp_get_attachment_url( $thumbnail_id );
}
if ( empty( $image ) ) {
$image = wc_placeholder_img_src();
}
?>
<tr class="form-field">
<th scope="row" valign="top"><label><?php esc_html_e( 'Thumbnail', 'woocommerce' ); ?></label></th>
<td>
<div id="product_cat_thumbnail" style="float:left;margin-right:10px;"><img src="<?php echo esc_url( $image ); ?>" width="60px" height="60px" /></div>
<div style="line-height:60px;">
<input type="hidden" id="product_cat_thumbnail_id" name="product_cat_thumbnail_id" value="<?php echo esc_attr( $thumbnail_id ); ?>" />
<button type="button" class="upload_image_button button"><?php esc_html_e( 'Upload/Add image', 'woocommerce' ); ?></button>
<button type="button" class="remove_image_button button"><?php esc_html_e( 'Remove image', 'woocommerce' ); ?></button>
</div>
<script type="text/javascript">
jQuery(function(){
// Only show the "remove image" button when needed
if ( ! jQuery('#product_cat_thumbnail_id').val() )
jQuery('.remove_image_button').hide();
// Uploading files
var file_frame;
jQuery(document).on( 'click', '.upload_image_button', function( event ){
event.preventDefault();
// If the media frame already exists, reopen it.
if ( file_frame ) {
file_frame.open();
return;
}
// Create the media frame.
file_frame = wp.media.frames.downloadable_file = wp.media({
title: '<?php echo esc_js( __( 'Choose an image', 'woocommerce' ) ); ?>',
button: {
text: '<?php echo esc_js( __( 'Use image', 'woocommerce' ) ); ?>',
},
multiple: false
});
// When an image is selected, run a callback.
file_frame.on( 'select', function() {
attachment = file_frame.state().get('selection').first().toJSON();
jQuery('#product_cat_thumbnail_id').val( attachment.id );
jQuery('#product_cat_thumbnail img').attr('src', attachment.url );
jQuery('.remove_image_button').show();
});
// Finally, open the modal.
file_frame.open();
});
jQuery(document).on( 'click', '.remove_image_button', function( event ){
jQuery('#product_cat_thumbnail img').attr('src', '<?php echo esc_js( wc_placeholder_img_src() ); ?>');
jQuery('#product_cat_thumbnail_id').val('');
jQuery('.remove_image_button').hide();
return false;
});
});
</script>
<div class="clear"></div>
</td>
</tr>
<?php
}
/**
* Saves thumbnail field.
*
* @param int $term_id Term ID.
*
* @return void
*/
public function thumbnail_field_save( $term_id ) {
if ( isset( $_POST['product_cat_thumbnail_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
update_term_meta( $term_id, 'thumbnail_id', absint( $_POST['product_cat_thumbnail_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
}
}
/**
* Description for brand page.
*/
public function taxonomy_description() {
echo wp_kses_post( wpautop( __( 'Brands be added and managed from this screen. You can optionally upload a brand image to display in brand widgets and on brand archives', 'woocommerce' ) ) );
}
/**
* Sort brands function.
*
* @param array $sortable Sortable array.
*/
public function sort_brands( $sortable ) {
$sortable[] = 'product_brand';
return $sortable;
}
/**
* Add brands column in second-to-last position.
*
* @since 9.4.0
* @param mixed $columns Columns.
* @return array
*/
public function product_columns( $columns ) {
if ( empty( $columns ) ) {
return $columns;
}
$column_index = 'taxonomy-product_brand';
$brands_column = $columns[ $column_index ];
unset( $columns[ $column_index ] );
return array_merge(
array_slice( $columns, 0, -2, true ),
array( $column_index => $brands_column ),
array_slice( $columns, -2, null, true )
);
}
/**
* Columns function.
*
* @param mixed $columns Columns.
*/
public function columns( $columns ) {
if ( empty( $columns ) ) {
return $columns;
}
$new_columns = array();
$new_columns['cb'] = $columns['cb'];
$new_columns['thumb'] = __( 'Image', 'woocommerce' );
unset( $columns['cb'] );
$columns = array_merge( $new_columns, $columns );
return $columns;
}
/**
* Column function.
*
* @param mixed $columns Columns.
* @param mixed $column Column.
* @param mixed $id ID.
*/
public function column( $columns, $column, $id ) {
if ( 'thumb' === $column ) {
global $woocommerce;
$image = '';
$thumbnail_id = get_term_meta( $id, 'thumbnail_id', true );
if ( $thumbnail_id ) {
$image = wp_get_attachment_url( $thumbnail_id );
}
if ( empty( $image ) ) {
$image = wc_placeholder_img_src();
}
$columns .= '<img src="' . $image . '" alt="Thumbnail" class="wp-post-image" height="48" width="48" />';
}
return $columns;
}
/**
* Renders either dropdown or a search field for brands depending on the threshold value of
* woocommerce_product_brand_filter_threshold filter.
*/
public function render_product_brand_filter() {
// phpcs:disable WordPress.Security.NonceVerification
$brands_count = (int) wp_count_terms( 'product_brand' );
$current_brand_slug = wc_clean( wp_unslash( $_GET['product_brand'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
/**
* Filter the brands threshold count.
*
* @since 9.4.0
*
* @param int $value Threshold.
*/
if ( $brands_count <= apply_filters( 'woocommerce_product_brand_filter_threshold', 100 ) ) {
wc_product_dropdown_categories(
array(
'pad_counts' => true,
'show_count' => true,
'orderby' => 'name',
'selected' => $current_brand_slug,
'show_option_none' => __( 'Filter by brand', 'woocommerce' ),
'option_none_value' => '',
'value_field' => 'slug',
'taxonomy' => 'product_brand',
'name' => 'product_brand',
'class' => 'dropdown_product_brand',
)
);
} else {
$current_brand = $current_brand_slug ? get_term_by( 'slug', $current_brand_slug, 'product_brand' ) : '';
$selected_option = '';
if ( $current_brand_slug && $current_brand ) {
$selected_option = '<option value="' . esc_attr( $current_brand_slug ) . '" selected="selected">' . esc_html( htmlspecialchars( wp_kses_post( $current_brand->name ) ) ) . '</option>';
}
$placeholder = esc_attr__( 'Filter by brand', 'woocommerce' );
?>
<select class="wc-brands-search" name="product_brand" data-placeholder="<?php echo $placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" data-allow_clear="true">
<?php echo $selected_option; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</select>
<?php
}
// phpcs:enable WordPress.Security.NonceVerification
}
/**
* Add brand base permalink setting.
*/
public function add_brand_base_setting() {
$screen = get_current_screen();
if ( ! $screen || 'options-permalink' !== $screen->id ) {
return;
}
add_settings_field(
'woocommerce_product_brand_slug',
__( 'Product brand base', 'woocommerce' ),
array( $this, 'product_brand_slug_input' ),
'permalink',
'optional'
);
$this->save_permalink_settings();
}
/**
* Add a slug input box.
*/
public function product_brand_slug_input() {
$permalink = get_option( 'woocommerce_brand_permalink', '' );
?>
<input name="woocommerce_product_brand_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $permalink ); ?>" placeholder="<?php echo esc_attr_x( 'brand', 'slug', 'woocommerce' ); ?>" />
<?php
}
/**
* Save permalnks settings.
*
* We need to save the options ourselves;
* settings api does not trigger save for the permalinks page.
*/
public function save_permalink_settings() {
if ( ! is_admin() ) {
return;
}
if ( isset( $_POST['permalink_structure'], $_POST['wc-permalinks-nonce'], $_POST['woocommerce_product_brand_slug'] ) && wp_verify_nonce( wp_unslash( $_POST['wc-permalinks-nonce'] ), 'wc-permalinks' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
update_option( 'woocommerce_brand_permalink', wc_sanitize_permalink( trim( wc_clean( wp_unslash( $_POST['woocommerce_product_brand_slug'] ) ) ) ) );
}
}
/**
* Validate the product base.
*
* Must have an additional slug, not just the brand as the base.
*
* @param array $value Value.
*/
public function validate_product_base( $value ) {
if ( '/%product_brand%/' === trailingslashit( $value['product_base'] ) ) {
$value['product_base'] = '/' . _x( 'product', 'slug', 'woocommerce' ) . $value['product_base'];
}
return $value;
}
/**
* Add csv column for importing/exporting.
*
* @param array $options Mapping options.
* @return array $options
*/
public function add_column_to_importer_exporter( $options ) {
$options['brand_ids'] = __( 'Brands', 'woocommerce' );
return $options;
}
/**
* Add default column mapping.
*
* @param array $mappings Mappings.
* @return array $mappings
*/
public function add_default_column_mapping( $mappings ) {
$new_mapping = array( __( 'Brands', 'woocommerce' ) => 'brand_ids' );
return array_merge( $mappings, $new_mapping );
}
/**
* Add brands to newly imported product.
*
* @param WC_Product $product Product being imported.
* @param array $data Raw CSV data.
*/
public function process_import( $product, $data ) {
if ( empty( $data['brand_ids'] ) ) {
return;
}
$brand_ids = array_map( 'intval', $this->parse_brands_field( $data['brand_ids'] ) );
wp_set_object_terms( $product->get_id(), $brand_ids, 'product_brand' );
}
/**
* Parse brands field from a CSV during import.
*
* Based on WC_Product_CSV_Importer::parse_categories_field()
*
* @param string $value Field value.
* @return array
*/
public function parse_brands_field( $value ) {
// Based on WC_Product_Importer::explode_values().
$values = str_replace( '\\,', '::separator::', explode( ',', $value ) );
$row_terms = array();
foreach ( $values as $row_value ) {
$row_terms[] = trim( str_replace( '::separator::', ',', $row_value ) );
}
$brands = array();
foreach ( $row_terms as $row_term ) {
$parent = null;
// WC Core uses '>', but for some reason it's already escaped at this point.
$_terms = array_map( 'trim', explode( '&gt;', $row_term ) );
$total = count( $_terms );
foreach ( $_terms as $index => $_term ) {
$term = term_exists( $_term, 'product_brand', $parent );
if ( is_array( $term ) ) {
$term_id = $term['term_id'];
} else {
$term = wp_insert_term( $_term, 'product_brand', array( 'parent' => intval( $parent ) ) );
if ( is_wp_error( $term ) ) {
break; // We cannot continue if the term cannot be inserted.
}
$term_id = $term['term_id'];
}
// Only requires assign the last category.
if ( ( 1 + $index ) === $total ) {
$brands[] = $term_id;
} else {
// Store parent to be able to insert or query brands based in parent ID.
$parent = $term_id;
}
}
}
return $brands;
}
/**
* Get brands column value for csv export.
*
* @param string $value What will be exported.
* @param WC_Product $product Product being exported.
* @return string Brands separated by commas and child brands as "parent > child".
*/
public function get_column_value_brand_ids( $value, $product ) {
$brand_ids = wp_parse_id_list( wp_get_post_terms( $product->get_id(), 'product_brand', array( 'fields' => 'ids' ) ) );
if ( ! count( $brand_ids ) ) {
return '';
}
// Based on WC_CSV_Exporter::format_term_ids().
$formatted_brands = array();
foreach ( $brand_ids as $brand_id ) {
$formatted_term = array();
$ancestor_ids = array_reverse( get_ancestors( $brand_id, 'product_brand' ) );
foreach ( $ancestor_ids as $ancestor_id ) {
$term = get_term( $ancestor_id, 'product_brand' );
if ( $term && ! is_wp_error( $term ) ) {
$formatted_term[] = $term->name;
}
}
$term = get_term( $brand_id, 'product_brand' );
if ( $term && ! is_wp_error( $term ) ) {
$formatted_term[] = $term->name;
}
$formatted_brands[] = implode( ' > ', $formatted_term );
}
// Based on WC_CSV_Exporter::implode_values().
$values_to_implode = array();
foreach ( $formatted_brands as $brand ) {
$brand = (string) is_scalar( $brand ) ? $brand : '';
$values_to_implode[] = str_replace( ',', '\\,', $brand );
}
return implode( ', ', $values_to_implode );
}
}
$GLOBALS['WC_Brands_Admin'] = new WC_Brands_Admin();

View File

@ -20,7 +20,7 @@ if ( ! defined( 'ABSPATH' ) ) {
<strong><?php echo esc_html( wc_attribute_label( $attribute->get_name() ) ); ?></strong>
<input type="hidden" name="attribute_names[<?php echo esc_attr( $i ); ?>]" value="<?php echo esc_attr( $attribute->get_name() ); ?>" />
<?php else : ?>
<input type="text" class="attribute_name" name="attribute_names[<?php echo esc_attr( $i ); ?>]" value="<?php echo esc_attr( $attribute->get_name() ); ?>" placeholder="<?php esc_attr_e( 'f.e. size or color', 'woocommerce' ); ?>" />
<input type="text" class="attribute_name" name="attribute_names[<?php echo esc_attr( $i ); ?>]" value="<?php echo esc_attr( $attribute->get_name() ); ?>" placeholder="<?php esc_attr_e( 'e.g. length or weight', 'woocommerce' ); ?>" />
<?php endif; ?>
<input type="hidden" name="attribute_position[<?php echo esc_attr( $i ); ?>]" class="attribute_position" value="<?php echo esc_attr( $attribute->get_position() ); ?>" />
</td>

View File

@ -0,0 +1,369 @@
<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName
declare( strict_types = 1);
use Automattic\WooCommerce\Blocks\Options;
//phpcs:disable Squiz.Classes.ClassFileName.NoMatch
/**
* BlockTemplateUtils class used for serving block templates from Woo Blocks.
* IMPORTANT: These methods have been duplicated from Gutenberg/lib/full-site-editing/block-templates.php as those functions are not for public usage.
*
* For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @version 9.4.0
*/
class BlockTemplateUtilsDuplicated {
/**
* Directory names for block templates
*
* Directory names conventions for block templates have changed with Gutenberg 12.1.0,
* however, for backwards-compatibility, we also keep the older conventions, prefixed
* with `DEPRECATED_`.
*
* @var array {
* @var string DEPRECATED_TEMPLATES Old directory name of the block templates directory.
* @var string DEPRECATED_TEMPLATE_PARTS Old directory name of the block template parts directory.
* @var string TEMPLATES_DIR_NAME Directory name of the block templates directory.
* @var string TEMPLATE_PARTS_DIR_NAME Directory name of the block template parts directory.
* }
*/
protected const DIRECTORY_NAMES = array(
'DEPRECATED_TEMPLATES' => 'block-templates',
'DEPRECATED_TEMPLATE_PARTS' => 'block-template-parts',
'TEMPLATES' => 'templates',
'TEMPLATE_PARTS' => 'parts',
);
/**
* WooCommerce plugin slug
*
* This is used to save templates to the DB which are stored against this value in the wp_terms table.
*
* @var string
*/
protected const PLUGIN_SLUG = 'woocommerce/woocommerce';
/**
* Returns an array containing the references of
* the passed blocks and their inner blocks.
*
* @param array $blocks array of blocks.
*
* @return array block references to the passed blocks and their inner blocks.
*/
public static function gutenberg_flatten_blocks( &$blocks ) {
$all_blocks = array();
$queue = array();
foreach ( $blocks as &$block ) {
$queue[] = &$block;
}
$queue_count = count( $queue );
while ( $queue_count > 0 ) {
$block = &$queue[0];
array_shift( $queue );
$all_blocks[] = &$block;
if ( ! empty( $block['innerBlocks'] ) ) {
foreach ( $block['innerBlocks'] as &$inner_block ) {
$queue[] = &$inner_block;
}
}
$queue_count = count( $queue );
}
return $all_blocks;
}
/**
* Parses wp_template content and injects the current theme's
* stylesheet as a theme attribute into each wp_template_part
*
* @param string $template_content serialized wp_template content.
*
* @return string Updated wp_template content.
*/
public static function gutenberg_inject_theme_attribute_in_content( $template_content ) {
$has_updated_content = false;
$new_content = '';
$template_blocks = parse_blocks( $template_content );
$blocks = self::gutenberg_flatten_blocks( $template_blocks );
foreach ( $blocks as &$block ) {
if (
'core/template-part' === $block['blockName'] &&
! isset( $block['attrs']['theme'] )
) {
$block['attrs']['theme'] = wp_get_theme()->get_stylesheet();
$has_updated_content = true;
}
}
if ( $has_updated_content ) {
foreach ( $template_blocks as &$block ) {
$new_content .= serialize_block( $block );
}
return $new_content;
}
return $template_content;
}
/**
* Build a unified template object based a post Object.
*
* @param \WP_Post $post Template post.
*
* @return \WP_Block_Template|\WP_Error Template.
*/
public static function gutenberg_build_template_result_from_post( $post ) {
$terms = get_the_terms( $post, 'wp_theme' );
if ( is_wp_error( $terms ) ) {
return $terms;
}
if ( ! $terms ) {
return new \WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'woocommerce' ) );
}
$theme = $terms[0]->name;
$has_theme_file = true;
$template = new \WP_Block_Template();
$template->wp_id = $post->ID;
$template->id = $theme . '//' . $post->post_name;
$template->theme = $theme;
$template->content = $post->post_content;
$template->slug = $post->post_name;
$template->source = 'custom';
$template->type = $post->post_type;
$template->description = $post->post_excerpt;
$template->title = $post->post_title;
$template->status = $post->post_status;
$template->has_theme_file = $has_theme_file;
$template->is_custom = false;
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
if ( 'wp_template_part' === $post->post_type ) {
$type_terms = get_the_terms( $post, 'wp_template_part_area' );
if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) {
$template->area = $type_terms[0]->name;
}
}
// We are checking 'woocommerce' to maintain legacy templates which are saved to the DB,
// prior to updating to use the correct slug.
// More information found here: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5423.
if ( self::PLUGIN_SLUG === $theme || 'woocommerce' === strtolower( $theme ) ) {
$template->origin = 'plugin';
}
return $template;
}
/**
* Build a unified template object based on a theme file.
*
* @param array|object $template_file Theme file.
* @param string $template_type wp_template or wp_template_part.
*
* @return \WP_Block_Template Template.
*/
public static function gutenberg_build_template_result_from_file( $template_file, $template_type ) {
$template_file = (object) $template_file;
// If the theme has an archive-products.html template but does not have product taxonomy templates
// then we will load in the archive-product.html template from the theme to use for product taxonomies on the frontend.
$template_is_from_theme = 'theme' === $template_file->source;
$theme_name = wp_get_theme()->get( 'TextDomain' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$template_content = file_get_contents( $template_file->path );
$template = new \WP_Block_Template();
$template->id = $template_is_from_theme ? $theme_name . '//' . $template_file->slug : self::PLUGIN_SLUG . '//' . $template_file->slug;
$template->theme = $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG;
$template->content = self::gutenberg_inject_theme_attribute_in_content( $template_content );
// Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
$template->source = $template_file->source ? $template_file->source : 'plugin';
$template->slug = $template_file->slug;
$template->type = $template_type;
$template->title = ! empty( $template_file->title ) ? $template_file->title : self::convert_slug_to_title( $template_file->slug );
$template->status = 'publish';
$template->has_theme_file = true;
$template->origin = $template_file->source;
$template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
$template->area = 'uncategorized';
return $template;
}
/**
* Build a new template object so that we can make Woo Blocks default templates available in the current theme should they not have any.
*
* @param string $template_file Block template file path.
* @param string $template_type wp_template or wp_template_part.
* @param string $template_slug Block template slug e.g. single-product.
* @param bool $template_is_from_theme If the block template file is being loaded from the current theme instead of Woo Blocks.
*
* @return object Block template object.
*/
public static function create_new_block_template_object( $template_file, $template_type, $template_slug, $template_is_from_theme = false ) {
$theme_name = wp_get_theme()->get( 'TextDomain' );
$new_template_item = array(
'slug' => $template_slug,
'id' => $template_is_from_theme ? $theme_name . '//' . $template_slug : self::PLUGIN_SLUG . '//' . $template_slug,
'path' => $template_file,
'type' => $template_type,
'theme' => $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG,
// Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
'source' => $template_is_from_theme ? 'theme' : 'plugin',
'title' => self::convert_slug_to_title( $template_slug ),
'description' => '',
'post_types' => array(), // Don't appear in any Edit Post template selector dropdown.
);
return (object) $new_template_item;
}
/**
* Converts template slugs into readable titles.
*
* @param string $template_slug The templates slug (e.g. single-product).
* @return string Human friendly title converted from the slug.
*/
public static function convert_slug_to_title( $template_slug ) {
switch ( $template_slug ) {
case 'single-product':
return __( 'Single Product', 'woocommerce' );
case 'archive-product':
return __( 'Product Archive', 'woocommerce' );
case 'taxonomy-product_cat':
return __( 'Product Category', 'woocommerce' );
case 'taxonomy-product_tag':
return __( 'Product Tag', 'woocommerce' );
default:
// Replace all hyphens and underscores with spaces.
return ucwords( preg_replace( '/[\-_]/', ' ', $template_slug ) );
}
}
/**
* Gets the first matching template part within themes directories
*
* Since [Gutenberg 12.1.0](https://github.com/WordPress/gutenberg/releases/tag/v12.1.0), the conventions for
* block templates and parts directory has changed from `block-templates` and `block-templates-parts`
* to `templates` and `parts` respectively.
*
* This function traverses all possible combinations of directory paths where a template or part
* could be located and returns the first one which is readable, prioritizing the new convention
* over the deprecated one, but maintaining that one for backwards compatibility.
*
* @param string $template_slug The slug of the template (i.e. without the file extension).
* @param string $template_type Either `wp_template` or `wp_template_part`.
*
* @return string|null The matched path or `null` if no match was found.
*/
public static function get_theme_template_path( $template_slug, $template_type = 'wp_template' ) {
$template_filename = $template_slug . '.html';
$possible_templates_dir = 'wp_template' === $template_type ? array(
self::DIRECTORY_NAMES['TEMPLATES'],
self::DIRECTORY_NAMES['DEPRECATED_TEMPLATES'],
) : array(
self::DIRECTORY_NAMES['TEMPLATE_PARTS'],
self::DIRECTORY_NAMES['DEPRECATED_TEMPLATE_PARTS'],
);
// Combine the possible root directory names with either the template directory
// or the stylesheet directory for child themes.
$possible_paths = array_reduce(
$possible_templates_dir,
function ( $carry, $item ) use ( $template_filename ) {
$filepath = DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . $template_filename;
$carry[] = get_template_directory() . $filepath;
$carry[] = get_stylesheet_directory() . $filepath;
return $carry;
},
array()
);
// Return the first matching.
foreach ( $possible_paths as $path ) {
if ( is_readable( $path ) ) {
return $path;
}
}
return null;
}
/**
* Check if the theme has a template. So we know if to load our own in or not.
*
* @param string $template_name name of the template file without .html extension e.g. 'single-product'.
* @return boolean
*/
public static function theme_has_template( $template_name ) {
return (bool) self::get_theme_template_path( $template_name, 'wp_template' );
}
/**
* Check if the theme has a template. So we know if to load our own in or not.
*
* @param string $template_name name of the template file without .html extension e.g. 'single-product'.
* @return boolean
*/
public static function theme_has_template_part( $template_name ) {
return (bool) self::get_theme_template_path( $template_name, 'wp_template_part' );
}
/**
* Checks to see if they are using a compatible version of WP, or if not they have a compatible version of the Gutenberg plugin installed.
*
* @return boolean
*/
public static function supports_block_templates() {
if (
( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) &&
( ! function_exists( 'gutenberg_supports_block_templates' ) || ! gutenberg_supports_block_templates() )
) {
return false;
}
return true;
}
/**
* Returns whether the blockified templates should be used or not.
*
* First, we need to make sure WordPress version is higher than 6.1 (lowest that supports Products block).
* Then, if the option is not stored on the db, we need to check if the current theme is a block one or not.
*
* @return boolean
*/
public static function should_use_blockified_product_grid_templates() {
$minimum_wp_version = '6.1';
if ( version_compare( $GLOBALS['wp_version'], $minimum_wp_version, '<' ) ) {
return false;
}
$use_blockified_templates = wc_string_to_bool( get_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE ) );
if ( false === $use_blockified_templates ) {
return function_exists( 'wc_current_theme_is_fse_theme' ) && wc_current_theme_is_fse_theme();
}
return $use_blockified_templates;
}
}

View File

@ -0,0 +1,156 @@
<?php
declare( strict_types = 1);
//phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps
/**
* Utils for compatibility with WooCommerce Full Site Editor Blocks
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @version 9.4.0
*/
class WC_Brands_Block_Templates {
/**
* Constructor.
*/
public function __construct() {
add_action( 'get_block_templates', array( $this, 'get_block_templates' ), 10, 3 );
add_filter( 'get_block_file_template', array( $this, 'get_block_file_template' ), 10, 3 );
add_filter( 'woocommerce_has_block_template', array( $this, 'has_block_template' ), 10, 2 );
}
/**
* Get the taxonomy-product_brand template from DB in case a user customized it in FSE
*
* @return WP_Post|null The taxonomy-product_brand
*/
private function get_product_brand_template_db() {
$posts = get_posts(
array(
'name' => 'taxonomy-product_brand',
'post_type' => 'wp_template',
'post_status' => 'publish',
'posts_per_page' => 1,
)
);
if ( count( $posts ) ) {
return $posts[0];
}
return null;
}
/**
* Fixes a bug regarding taxonomies and FSE.
* Without this, the system will always load archive-product.php version instead of taxonomy_product_brand.html
* it will show a deprecation error if that happens.
*
* Triggered by woocommerce_has_block_template filter
*
* @param bool $has_template True if the template is available.
* @param string $template_name The name of the template.
*
* @return bool True if the system is checking archive-product
*/
public function has_block_template( $has_template, $template_name ) {
if ( 'archive-product' === $template_name || 'taxonomy-product_brand' === $template_name ) {
$has_template = true;
}
return $has_template;
}
/**
* Get the block template for Taxonomy Product Brand. First it attempts to load the last version from DB
* Otherwise it loads the file based template.
*
* @param string $template_type The post_type for the template. Normally wp_template or wp_template_part.
*
* @return WP_Block_Template The taxonomy-product_brand template.
*/
private function get_product_brands_template( $template_type ) {
$template_db = $this->get_product_brand_template_db();
if ( $template_db ) {
return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_post( $template_db );
}
$template_path = BlockTemplateUtilsDuplicated::should_use_blockified_product_grid_templates()
? WC()->plugin_path() . '/templates/templates/blockified/taxonomy-product_brand.html'
: WC()->plugin_path() . '/templates/templates/taxonomy-product_brand.html';
$template_file = BlockTemplateUtilsDuplicated::create_new_block_template_object( $template_path, $template_type, 'taxonomy-product_brand', false );
return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_file( $template_file, $template_type );
}
/**
* Function to check if a template name is woocommerce/taxonomy-product_brand
*
* Notice depending on the version of WooCommerce this could be:
*
* woocommerce//taxonomy-product_brand
* woocommerce/woocommerce//taxonomy-product_brand
*
* @param String $id The string to check if contains the template name.
*
* @return bool True if the template is woocommerce/taxonomy-product_brand
*/
private function is_taxonomy_product_brand_template( $id ) {
return strpos( $id, 'woocommerce//taxonomy-product_brand' ) !== false;
}
/**
* Get the block template for Taxonomy Product Brand if requested.
* Triggered by get_block_file_template action
*
* @param WP_Block_Template|null $block_template The current Block Template loaded, if any.
* @param string $id The template id normally in the format theme-slug//template-slug.
* @param string $template_type The post_type for the template. Normally wp_template or wp_template_part.
*
* @return WP_Block_Template|null The taxonomy-product_brand template.
*/
public function get_block_file_template( $block_template, $id, $template_type ) {
if ( $this->is_taxonomy_product_brand_template( $id ) && is_null( $block_template ) ) {
$block_template = $this->get_product_brands_template( $template_type );
}
return $block_template;
}
/**
* Add the Block template in the template query results needed by FSE
* Triggered by get_block_templates action
*
* @param array $query_result The list of templates to render in the query.
* @param array $query The current query parameters.
* @param string $template_type The post_type for the template. Normally wp_template or wp_template_part.
*
* @return WP_Block_Template[] Array of the matched Block Templates to render.
*/
public function get_block_templates( $query_result, $query, $template_type ) {
// We don't want to run this if we are looking for template-parts. Like the header.
if ( 'wp_template' !== $template_type ) {
return $query_result;
}
$post_id = isset( $_REQUEST['postId'] ) ? wc_clean( wp_unslash( $_REQUEST['postId'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$slugs = $query['slug__in'] ?? array();
// Only add the template if asking for Product Brands.
if (
in_array( 'taxonomy-product_brand', $slugs, true ) ||
( ! $post_id && ! count( $slugs ) ) ||
( ! count( $slugs ) && $this->is_taxonomy_product_brand_template( $post_id ) )
) {
$query_result[] = $this->get_product_brands_template( $template_type );
}
return $query_result;
}
}
new WC_Brands_Block_Templates();

View File

@ -77,6 +77,11 @@ class WC_Autoloader {
return;
}
// If the class is already loaded from a merged package, prevent autoloader from loading it as well.
if ( \Automattic\WooCommerce\Packages::should_load_class( $class ) ) {
return;
}
$file = $this->get_file_name_from_class( $class );
$path = '';

View File

@ -0,0 +1,68 @@
<?php
//phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps
declare( strict_types = 1);
/**
* Brand settings manager.
*
* This class is responsible for setting and getting brand settings for a coupon.
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @version 9.4.0
*/
class WC_Brands_Brand_Settings_Manager {
/**
* Brand settings for a coupon.
*
* @var array
*/
private static $brand_settings = array();
/**
* Set brand settings for a coupon.
*
* @param WC_Coupon $coupon Coupon object.
*/
public static function set_brand_settings_on_coupon( $coupon ) {
$coupon_id = $coupon->get_id();
// Check if the brand settings are already set for this coupon.
if ( isset( self::$brand_settings[ $coupon_id ] ) ) {
return;
}
$included_brands = get_post_meta( $coupon_id, 'product_brands', true );
$included_brands = ! empty( $included_brands ) ? $included_brands : array();
$excluded_brands = get_post_meta( $coupon_id, 'exclude_product_brands', true );
$excluded_brands = ! empty( $excluded_brands ) ? $excluded_brands : array();
// Store these settings in the static array.
self::$brand_settings[ $coupon_id ] = array(
'included_brands' => $included_brands,
'excluded_brands' => $excluded_brands,
);
}
/**
* Get brand settings for a coupon.
*
* @param WC_Coupon $coupon Coupon object.
* @return array Brand settings (included and excluded brands).
*/
public static function get_brand_settings_on_coupon( $coupon ) {
$coupon_id = $coupon->get_id();
if ( isset( self::$brand_settings[ $coupon_id ] ) ) {
return self::$brand_settings[ $coupon_id ];
}
// Default return value if no settings are found.
return array(
'included_brands' => array(),
'excluded_brands' => array(),
);
}
}

View File

@ -0,0 +1,189 @@
<?php
declare( strict_types = 1);
/**
* WC_Brands_Coupons class.
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @version 9.4.0
*/
class WC_Brands_Coupons {
const E_WC_COUPON_EXCLUDED_BRANDS = 301;
/**
* Constructor
*/
public function __construct() {
// Coupon validation and error handling.
add_filter( 'woocommerce_coupon_is_valid', array( $this, 'is_coupon_valid' ), 10, 3 );
add_filter( 'woocommerce_coupon_is_valid_for_product', array( $this, 'is_valid_for_product' ), 10, 3 );
add_filter( 'woocommerce_coupon_error', array( $this, 'brand_exclusion_error' ), 10, 2 );
}
/**
* Validate the coupon based on included and/or excluded product brands.
*
* If one of the following conditions are met, an exception will be thrown and
* displayed as an error notice on the cart page:
*
* 1) Coupon has a brand requirement but no products in the cart have the brand.
* 2) All products in the cart match the brand exclusion rule.
* 3) For a cart discount, there is at least one product in cart that matches exclusion rule.
*
* @throws Exception Throws Exception for invalid coupons.
* @param bool $valid Whether the coupon is valid.
* @param WC_Coupon $coupon Coupon object.
* @param WC_Discounts $discounts Discounts object.
* @return bool $valid True if coupon is valid, otherwise Exception will be thrown.
*/
public function is_coupon_valid( $valid, $coupon, $discounts = null ) {
$this->set_brand_settings_on_coupon( $coupon );
// Only check if coupon has brand restrictions on it.
$brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon );
$brand_restrictions = ! empty( $brand_coupon_settings['included_brands'] ) || ! empty( $brand_coupon_settings['excluded_brands'] );
if ( ! $brand_restrictions ) {
return $valid;
}
$included_brands_match = false;
$excluded_brands_matches = 0;
$items = $discounts->get_items();
foreach ( $items as $item ) {
$product_brands = $this->get_product_brands( $this->get_product_id( $item->product ) );
if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) {
$included_brands_match = true;
}
if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) {
++$excluded_brands_matches;
}
}
// 1) Coupon has a brand requirement but no products in the cart have the brand.
if ( ! $included_brands_match && ! empty( $brand_coupon_settings['included_brands'] ) ) {
throw new Exception( WC_Coupon::E_WC_COUPON_NOT_APPLICABLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// 2) All products in the cart match brand exclusion rule.
if ( count( $items ) === $excluded_brands_matches ) {
throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// 3) For a cart discount, there is at least one product in cart that matches exclusion rule.
if ( $coupon->is_type( 'fixed_cart' ) && $excluded_brands_matches > 0 ) {
throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
return $valid;
}
/**
* Check if a coupon is valid for a product.
*
* This allows percentage and product discounts to apply to only
* the correct products in the cart.
*
* @param bool $valid Whether the product should get the coupon's discounts.
* @param WC_Product $product WC Product Object.
* @param WC_Coupon $coupon Coupon object.
* @return bool $valid
*/
public function is_valid_for_product( $valid, $product, $coupon ) {
if ( ! is_a( $product, 'WC_Product' ) ) {
return $valid;
}
$this->set_brand_settings_on_coupon( $coupon );
$product_id = $this->get_product_id( $product );
$product_brands = $this->get_product_brands( $product_id );
// Check if coupon has a brand requirement and if this product has that brand attached.
$brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon );
if ( ! empty( $brand_coupon_settings['included_brands'] ) && empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) {
return false;
}
// Check if coupon has a brand exclusion and if this product has that brand attached.
if ( ! empty( $brand_coupon_settings['excluded_brands'] ) && ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) {
return false;
}
return $valid;
}
/**
* Display a custom error message when a cart discount coupon does not validate
* because an excluded brand was found in the cart.
*
* @param string $err The error message.
* @param string $err_code The error code.
* @return string
*/
public function brand_exclusion_error( $err, $err_code ) {
if ( self::E_WC_COUPON_EXCLUDED_BRANDS !== $err_code ) {
return $err;
}
return __( 'Sorry, this coupon is not applicable to the brands of selected products.', 'woocommerce' );
}
/**
* Get a list of brands that are assigned to a specific product
*
* @param int $product_id Product id.
* @return array brands
*/
private function get_product_brands( $product_id ) {
return wp_get_post_terms( $product_id, 'product_brand', array( 'fields' => 'ids' ) );
}
/**
* Set brand settings as properties on coupon object. These properties are
* lists of included product brand IDs and list of excluded brand IDs.
*
* @param WC_Coupon $coupon Coupon object.
*
* @return void
*/
private function set_brand_settings_on_coupon( $coupon ) {
$brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon );
if ( ! empty( $brand_coupon_settings['included_brands'] ) && ! empty( $brand_coupon_settings['excluded_brands'] ) ) {
return;
}
$included_brands = get_post_meta( $coupon->get_id(), 'product_brands', true );
if ( empty( $included_brands ) ) {
$included_brands = array();
}
$excluded_brands = get_post_meta( $coupon->get_id(), 'exclude_product_brands', true );
if ( empty( $excluded_brands ) ) {
$excluded_brands = array();
}
// Store these for later to avoid multiple look-ups.
WC_Brands_Brand_Settings_Manager::set_brand_settings_on_coupon( $coupon );
}
/**
* Returns the product (or variant) ID.
*
* @param WC_Product $product WC Product Object.
* @return int Product ID
*/
private function get_product_id( $product ) {
return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
}
}
new WC_Brands_Coupons();

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,6 @@ use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub;
use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Internal\McStats;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil};
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
@ -384,8 +383,10 @@ final class WooCommerce {
unset( $error_copy['message'] );
$context = array(
'source' => 'fatal-errors',
'error' => $error_copy,
'source' => 'fatal-errors',
'error' => $error_copy,
// Indicate that this error should be logged remotely if remote logging is enabled.
'remote-logging' => true,
);
if ( false !== strpos( $message, 'Stack trace:' ) ) {
@ -407,12 +408,6 @@ final class WooCommerce {
$context
);
// Record fatal error stats.
$container = wc_get_container();
$mc_stats = $container->get( McStats::class );
$mc_stats->add( 'error', 'fatal-errors-during-shutdown' );
$mc_stats->do_server_side_stats();
/**
* Action triggered when there are errors during shutdown.
*

View File

@ -0,0 +1,40 @@
<?php
/**
* REST API Brands controller for WC 3.5+
*
* Handles requests to /products/brands endpoint.
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @package WooCommerce\RestApi
* @since 9.4.0
*/
declare( strict_types = 1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* REST API Brands controller class.
*
* @package WooCommerce\RestApi
* @extends WC_REST_Product_Categories_Controller
*/
class WC_REST_Product_Brands_V2_Controller extends WC_REST_Product_Categories_V2_Controller {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'products/brands';
/**
* Taxonomy.
*
* @var string
*/
protected $taxonomy = 'product_brand';
}

View File

@ -0,0 +1,39 @@
<?php
/**
* REST API Brands controller.
*
* Handles requests to /products/brands endpoint.
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @package WooCommerce\RestApi
* @since 9.4.0
*/
declare( strict_types = 1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* REST API Brands controller class.
*
* @package WooCommerce\RestApi
* @extends WC_REST_Product_Categories_Controller
*/
class WC_REST_Product_Brands_Controller extends WC_REST_Product_Categories_Controller {
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'products/brands';
/**
* Taxonomy.
*
* @var string
*/
protected $taxonomy = 'product_brand';
}

View File

@ -0,0 +1,141 @@
<?php
/**
* Brands Helper Functions
*
* Important: For internal use only by the Automattic\WooCommerce\Internal\Brands package.
*
* @package WooCommerce
* @version 9.4.0
*/
declare( strict_types = 1);
/**
* Helper function :: wc_get_brand_thumbnail_url function.
*
* @param int $brand_id Brand ID.
* @param string $size Thumbnail image size.
* @return string
*/
function wc_get_brand_thumbnail_url( $brand_id, $size = 'full' ) {
$thumbnail_id = get_term_meta( $brand_id, 'thumbnail_id', true );
if ( $thumbnail_id ) {
$thumb_src = wp_get_attachment_image_src( $thumbnail_id, $size );
}
return ! empty( $thumb_src ) ? current( $thumb_src ) : '';
}
/**
* Helper function :: wc_get_brand_thumbnail_image function.
*
* @since 9.4.0
*
* @param object $brand Brand term.
* @param string $size Thumbnail image size.
* @return string
*/
function wc_get_brand_thumbnail_image( $brand, $size = '' ) {
$thumbnail_id = get_term_meta( $brand->term_id, 'thumbnail_id', true );
if ( '' === $size || 'brand-thumb' === $size ) {
/**
* Filter the brand's thumbnail size.
*
* @since 9.4.0
*
* @param string $size Brand's thumbnail size.
*/
$size = apply_filters( 'woocommerce_brand_thumbnail_size', 'shop_catalog' );
}
if ( $thumbnail_id ) {
$image_src = wp_get_attachment_image_src( $thumbnail_id, $size );
$image_src = $image_src[0];
$dimensions = wc_get_image_size( $size );
$image_srcset = function_exists( 'wp_get_attachment_image_srcset' ) ? wp_get_attachment_image_srcset( $thumbnail_id, $size ) : false;
$image_sizes = function_exists( 'wp_get_attachment_image_sizes' ) ? wp_get_attachment_image_sizes( $thumbnail_id, $size ) : false;
} else {
$image_src = wc_placeholder_img_src();
$dimensions = wc_get_image_size( $size );
$image_srcset = false;
$image_sizes = false;
}
// Add responsive image markup if available.
if ( $image_srcset && $image_sizes ) {
$image = '<img src="' . esc_url( $image_src ) . '" alt="' . esc_attr( $brand->name ) . '" class="brand-thumbnail" width="' . esc_attr( $dimensions['width'] ) . '" height="' . esc_attr( $dimensions['height'] ) . '" srcset="' . esc_attr( $image_srcset ) . '" sizes="' . esc_attr( $image_sizes ) . '" />';
} else {
$image = '<img src="' . esc_url( $image_src ) . '" alt="' . esc_attr( $brand->name ) . '" class="brand-thumbnail" width="' . esc_attr( $dimensions['width'] ) . '" height="' . esc_attr( $dimensions['height'] ) . '" />';
}
return $image;
}
/**
* Retrieves product's brands.
*
* @param int $post_id Post ID (default: 0).
* @param string $sep Seperator (default: ').
* @param string $before Before item (default: '').
* @param string $after After item (default: '').
* @return array List of terms
*/
function wc_get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) {
global $post;
if ( ! $post_id ) {
$post_id = $post->ID;
}
return get_the_term_list( $post_id, 'product_brand', $before, $sep, $after );
}
/**
* Polyfills for backwards compatibility with the WooCommerce Brands plugin.
*/
if ( ! function_exists( 'get_brand_thumbnail_url' ) ) {
/**
* Polyfill for get_brand_thumbnail_image.
*
* @param int $brand_id Brand ID.
* @param string $size Thumbnail image size.
* @return string
*/
function get_brand_thumbnail_url( $brand_id, $size = 'full' ) {
return wc_get_brand_thumbnail_url( $brand_id, $size );
}
}
if ( ! function_exists( 'get_brand_thumbnail_image' ) ) {
/**
* Polyfill for get_brand_thumbnail_image.
*
* @param object $brand Brand term.
* @param string $size Thumbnail image size.
* @return string
*/
function get_brand_thumbnail_image( $brand, $size = '' ) {
return wc_get_brand_thumbnail_image( $brand, $size );
}
}
if ( ! function_exists( 'get_brands' ) ) {
/**
* Polyfill for get_brands.
*
* @param int $post_id Post ID (default: 0).
* @param string $sep Seperator (default: ').
* @param string $before Before item (default: '').
* @param string $after After item (default: '').
* @return array List of terms
*/
function get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) {
return wc_get_brands( $post_id, $sep, $before, $after );
}
}

View File

@ -1484,7 +1484,11 @@ function wc_transaction_query( $type = 'start', $force = false ) {
* @return string Url to cart page
*/
function wc_get_cart_url() {
if ( is_cart() && isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) {
// We don't use is_cart() here because that also checks for a defined constant. We are only interested in the page.
$page_id = wc_get_page_id( 'cart' );
$is_cart_page = ( $page_id && is_page( $page_id ) ) || wc_post_content_has_shortcode( 'woocommerce_cart' );
if ( $is_cart_page && isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) {
$protocol = is_ssl() ? 'https' : 'http';
$current_url = esc_url_raw( $protocol . '://' . wp_unslash( $_SERVER['HTTP_HOST'] ) . wp_unslash( $_SERVER['REQUEST_URI'] ) );
$cart_url = remove_query_arg( array( 'remove_item', 'add-to-cart', 'added-to-cart', 'order_again', '_wpnonce' ), $current_url );

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