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

View File

@ -1,6 +1,8 @@
name: Storybook GitHub Pages name: Storybook GitHub Pages
on: on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch: workflow_dispatch:
permissions: 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 #!/usr/bin/env bash
. "$(dirname "$0")/_/husky.sh" . "$(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 '' ) 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 if [ -n "$changedManifests" ]; then
printf "It was a change in the following file(s) - refreshing dependencies:\n" printf "It was a change in the following file(s) - refreshing dependencies:\n"

View File

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

2
.npmrc
View File

@ -1,3 +1,5 @@
; adding this as npm 7 automatically installs peer dependencies but pnpm does not ; adding this as npm 7 automatically installs peer dependencies but pnpm does not
auto-install-peers=true auto-install-peers=true
strict-peer-dependencies=false 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", "menu_title": "DOM Events",
"tags": "how-to", "tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md", "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", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md",
"id": "c8d247b91472740075871e6b57a9583d893ac650" "id": "c8d247b91472740075871e6b57a9583d893ac650"
} }
@ -1229,7 +1229,7 @@
"menu_title": "Core critical flows", "menu_title": "Core critical flows",
"tags": "reference", "tags": "reference",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/quality-and-best-practices/core-critical-flows.md", "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", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/quality-and-best-practices/core-critical-flows.md",
"id": "e561b46694dba223c38b87613ce4907e4e14333a" "id": "e561b46694dba223c38b87613ce4907e4e14333a"
}, },
@ -1804,5 +1804,5 @@
"categories": [] "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). 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 | | 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. | | `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 ```javascript
window.document.addEventListener( 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). 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 | | 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. | | `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 | | `productId` | number | | Product ID |
### `wc-blocks_viewed_product` Example usage ### Example usage
```javascript ```javascript
window.document.addEventListener( window.document.addEventListener(

View File

@ -147,13 +147,14 @@ These flows will continually evolve as the platform evolves with flows updated,
### Merchant - Settings ### Merchant - Settings
| User Type | Flow Area | Flow Name | Test File | | User Type | Flow Area | Flow Name | Test File |
| --------- | --------- | -------------------------------------- | ---------------------------------------- | | --------- | --------- |----------------------------------------|------------------------------------------|
| Merchant | Settings | Update General Settings | merchant/settings-general.spec.js | | Merchant | Settings | Update General Settings | merchant/settings-general.spec.js |
| Merchant | Settings | Add Tax Rates | merchant/settings-tax.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 Zones | merchant/create-shipping-zones.spec.js |
| Merchant | Settings | Add Shipping Classes | merchant/create-shipping-classes.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 | Enable local pickup for checkout block | merchant/settings-shipping.spec.js |
| Merchant | Settings | Update payment settings | admin-tasks/payment.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 ### 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 { NAMESPACE } from '../constants';
import { setNotes, setNotesQuery, setError } from './actions'; import { setNotes, setNotesQuery, setError } from './actions';
import { NoteQuery, Note } from './types'; import { NoteQuery, Note } from './types';
import { checkUserCapability } from '../utils';
export function* getNotes( query: NoteQuery = {} ) { export function* getNotes( query: NoteQuery = {} ) {
const url = addQueryArgs( `${ NAMESPACE }/admin/notes`, query ); const url = addQueryArgs( `${ NAMESPACE }/admin/notes`, query );
try { try {
yield checkUserCapability( 'manage_woocommerce' );
const notes: Note[] = yield apiFetch( { const notes: Note[] = yield apiFetch( {
path: url, path: url,
} ); } );

View File

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

View File

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

View File

@ -2,14 +2,15 @@
* External dependencies * External dependencies
*/ */
import { addQueryArgs } from '@wordpress/url'; import { addQueryArgs } from '@wordpress/url';
import { apiFetch } from '@wordpress/data-controls'; import { apiFetch, select } from '@wordpress/data-controls';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { BaseQueryParams } from './types/query-params'; import { BaseQueryParams } from './types/query-params';
import { fetchWithHeaders } from './controls'; import { fetchWithHeaders } from './controls';
import { USER_STORE_NAME } from './user';
import { WCUser } from './user/types';
function replacer( _: string, value: unknown ) { function replacer( _: string, value: unknown ) {
if ( value ) { if ( value ) {
if ( Array.isArray( value ) ) { if ( Array.isArray( value ) ) {
@ -100,3 +101,20 @@ export function* request< Query extends BaseQueryParams, DataType >(
return { items: response.data, totalCount }; 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: visible:
( isEmbedded || ! isHomescreen ) && ( isEmbedded || ! isHomescreen ) &&
! isPerformingSetupTask() && ! isPerformingSetupTask() &&
! isProductScreen(), ! isProductScreen() &&
currentUserCan( 'manage_woocommerce' ),
}; };
const feedback = { const feedback = {

View File

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

View File

@ -100,7 +100,10 @@ function log_remote_event() {
time(), time(),
'critical', 'critical',
'Test PHP event from WC Beta Tester', 'Test PHP event from WC Beta Tester',
array( 'source' => 'wc-beta-tester' ) array(
'source' => 'wc-beta-tester',
'remote-logging' => true,
)
); );
if ( $result ) { 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; setBillingAddress: ( data: Partial< BillingAddress > ) => void;
setEmail: ( value: string ) => void; setEmail: ( value: string ) => void;
useShippingAsBilling: boolean; useShippingAsBilling: boolean;
editingBillingAddress: boolean;
editingShippingAddress: boolean;
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void; setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
setEditingBillingAddress: ( isEditing: boolean ) => void;
setEditingShippingAddress: ( isEditing: boolean ) => void;
defaultFields: AddressFields; defaultFields: AddressFields;
showShippingFields: boolean; showShippingFields: boolean;
showBillingFields: boolean; showBillingFields: boolean;
@ -40,15 +44,25 @@ interface CheckoutAddress {
*/ */
export const useCheckoutAddress = (): CheckoutAddress => { export const useCheckoutAddress = (): CheckoutAddress => {
const { needsShipping } = useShippingData(); const { needsShipping } = useShippingData();
const { useShippingAsBilling, prefersCollection } = useSelect( const {
( select ) => ( { useShippingAsBilling,
useShippingAsBilling: prefersCollection,
select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(), editingBillingAddress,
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(), editingShippingAddress,
} ) } = useSelect( ( select ) => ( {
); useShippingAsBilling:
const { __internalSetUseShippingAsBilling } = select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(),
useDispatch( CHECKOUT_STORE_KEY ); 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 { const {
billingAddress, billingAddress,
setBillingAddress, setBillingAddress,
@ -77,6 +91,10 @@ export const useCheckoutAddress = (): CheckoutAddress => {
defaultFields, defaultFields,
useShippingAsBilling, useShippingAsBilling,
setUseShippingAsBilling: __internalSetUseShippingAsBilling, setUseShippingAsBilling: __internalSetUseShippingAsBilling,
editingBillingAddress,
editingShippingAddress,
setEditingBillingAddress,
setEditingShippingAddress,
needsShipping, needsShipping,
showShippingFields: showShippingFields:
! forcedBillingAddress && needsShipping && ! prefersCollection, ! forcedBillingAddress && needsShipping && ! prefersCollection,

View File

@ -7,14 +7,12 @@ import {
useCheckoutAddress, useCheckoutAddress,
useEditorContext, useEditorContext,
noticeContexts, noticeContexts,
useShippingData,
} from '@woocommerce/base-context'; } from '@woocommerce/base-context';
import Noninteractive from '@woocommerce/base-components/noninteractive'; import Noninteractive from '@woocommerce/base-components/noninteractive';
import type { ShippingAddress, FormFieldsConfig } from '@woocommerce/settings'; import type { ShippingAddress, FormFieldsConfig } from '@woocommerce/settings';
import { StoreNoticesContainer } from '@woocommerce/blocks-components'; import { StoreNoticesContainer } from '@woocommerce/blocks-components';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
import { CART_STORE_KEY } from '@woocommerce/block-data'; import { CART_STORE_KEY } from '@woocommerce/block-data';
import isShallowEqual from '@wordpress/is-shallow-equal';
/** /**
* Internal dependencies * Internal dependencies
@ -36,14 +34,9 @@ const Block = ( {
showPhoneField: boolean; showPhoneField: boolean;
requirePhoneField: boolean; requirePhoneField: boolean;
} ): JSX.Element => { } ): JSX.Element => {
const { const { billingAddress, setShippingAddress, useBillingAsShipping } =
shippingAddress, useCheckoutAddress();
billingAddress,
setShippingAddress,
useBillingAsShipping,
} = useCheckoutAddress();
const { isEditor } = useEditorContext(); const { isEditor } = useEditorContext();
const { needsShipping } = useShippingData();
// Syncs shipping address with billing address if "Force shipping to the customer billing address" is enabled. // Syncs shipping address with billing address if "Force shipping to the customer billing address" is enabled.
useEffectOnce( () => { 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 ( return (
<> <>
<StoreNoticesContainer context={ noticeContext } /> <StoreNoticesContainer context={ noticeContext } />
@ -121,7 +101,6 @@ const Block = ( {
{ cartDataLoaded ? ( { cartDataLoaded ? (
<CustomerAddress <CustomerAddress
addressFieldsConfig={ addressFieldsConfig } addressFieldsConfig={ addressFieldsConfig }
defaultEditing={ defaultEditingAddress }
/> />
) : null } ) : null }
</WrapperComponent> </WrapperComponent>

View File

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

View File

@ -47,6 +47,7 @@ const Block = ( {
billingAddress, billingAddress,
useShippingAsBilling, useShippingAsBilling,
setUseShippingAsBilling, setUseShippingAsBilling,
setEditingBillingAddress,
} = useCheckoutAddress(); } = useCheckoutAddress();
const { isEditor } = useEditorContext(); const { isEditor } = useEditorContext();
const isGuest = getSetting( 'currentUserId' ) === 0; const isGuest = getSetting( 'currentUserId' ) === 0;
@ -116,10 +117,6 @@ const Block = ( {
const noticeContext = useShippingAsBilling const noticeContext = useShippingAsBilling
? [ noticeContexts.SHIPPING_ADDRESS, noticeContexts.BILLING_ADDRESS ] ? [ noticeContexts.SHIPPING_ADDRESS, noticeContexts.BILLING_ADDRESS ]
: [ noticeContexts.SHIPPING_ADDRESS ]; : [ noticeContexts.SHIPPING_ADDRESS ];
const hasAddress = !! (
shippingAddress.address_1 &&
( shippingAddress.first_name || shippingAddress.last_name )
);
const { cartDataLoaded } = useSelect( ( select ) => { const { cartDataLoaded } = useSelect( ( select ) => {
const store = select( CART_STORE_KEY ); 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 ( return (
<> <>
<StoreNoticesContainer context={ noticeContext } /> <StoreNoticesContainer context={ noticeContext } />
@ -138,7 +132,6 @@ const Block = ( {
{ cartDataLoaded ? ( { cartDataLoaded ? (
<CustomerAddress <CustomerAddress
addressFieldsConfig={ addressFieldsConfig } addressFieldsConfig={ addressFieldsConfig }
defaultEditing={ defaultEditingAddress }
/> />
) : null } ) : null }
</WrapperComponent> </WrapperComponent>
@ -151,6 +144,7 @@ const Block = ( {
if ( checked ) { if ( checked ) {
syncBillingWithShipping(); syncBillingWithShipping();
} else { } else {
setEditingBillingAddress( true );
clearBillingAddress( billingAddress ); clearBillingAddress( billingAddress );
} }
} } } }

View File

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

View File

@ -21,15 +21,36 @@ import {
import type { ProductCollectionEditComponentProps } from '../types'; import type { ProductCollectionEditComponentProps } from '../types';
import { getCollectionByName } from '../collections'; import { getCollectionByName } from '../collections';
const ProductPicker = ( props: ProductCollectionEditComponentProps ) => { const ProductPicker = (
props: ProductCollectionEditComponentProps & {
isDeletedProductReference: boolean;
}
) => {
const blockProps = useBlockProps(); const blockProps = useBlockProps();
const attributes = props.attributes; const { attributes, isDeletedProductReference } = props;
const collection = getCollectionByName( attributes.collection ); const collection = getCollectionByName( attributes.collection );
if ( ! 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 ( return (
<div { ...blockProps }> <div { ...blockProps }>
<Placeholder className="wc-blocks-product-collection__editor-product-picker"> <Placeholder className="wc-blocks-product-collection__editor-product-picker">
@ -38,21 +59,7 @@ const ProductPicker = ( props: ProductCollectionEditComponentProps ) => {
icon={ info } icon={ info }
className="wc-blocks-product-collection__info-icon" className="wc-blocks-product-collection__info-icon"
/> />
<Text> <Text>{ infoText }</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>
</HStack> </HStack>
<ProductControl <ProductControl
selected={ selected={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,16 @@
*/ */
import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as blockEditorStore } from '@wordpress/block-editor';
import { addFilter } from '@wordpress/hooks'; 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 { isWpVersion } from '@woocommerce/settings';
import type { BlockEditProps, Block } from '@wordpress/blocks'; 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 { __ } from '@wordpress/i18n';
import type { ProductResponseItem } from '@woocommerce/types'; import type { ProductResponseItem } from '@woocommerce/types';
import { getProduct } from '@woocommerce/editor-components/utils'; import { getProduct } from '@woocommerce/editor-components/utils';
@ -193,7 +199,7 @@ export const getUsesReferencePreviewMessage = (
return ''; return '';
}; };
export const getProductCollectionUIStateInEditor = ( { export const useProductCollectionUIState = ( {
location, location,
usesReference, usesReference,
attributes, attributes,
@ -203,59 +209,111 @@ export const getProductCollectionUIStateInEditor = ( {
usesReference?: string[] | undefined; usesReference?: string[] | undefined;
attributes: ProductCollectionAttributes; attributes: ProductCollectionAttributes;
hasInnerBlocks: boolean; hasInnerBlocks: boolean;
} ): ProductCollectionUIStatesInEditor => { } ) => {
const isInRequiredLocation = usesReference?.includes( location.type ); // Fetch product to check if it's deleted.
const isCollectionSelected = !! attributes.collection; // `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 };
}
/** const { getEntityRecord, hasFinishedResolution } =
* Case 1: Product context picker selectFunc( coreDataStore );
*/ const selectorArgs = [ 'postType', 'product', productId ];
const isProductContextRequired = usesReference?.includes( 'product' ); return {
const isProductContextSelected = product: getEntityRecord( ...selectorArgs ),
( attributes.query?.productReference ?? null ) !== null; hasResolved: hasFinishedResolution(
if ( 'getEntityRecord',
isCollectionSelected && selectorArgs
isProductContextRequired && ),
! isInRequiredLocation && };
! isProductContextSelected },
) { [ productId ]
return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER; );
}
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: * Case 1: Product context picker
* 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 = const isProductContextRequired = usesReference?.includes( 'product' );
location.type === LocationType.Archive && const isProductContextSelected =
( location.sourceData?.termId ?? null ) !== null; ( attributes.query?.productReference ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
if ( if (
! isArchiveLocationWithTermId && isCollectionSelected &&
! isProductLocationWithProductId isProductContextRequired &&
! isInRequiredLocation &&
! isProductContextSelected
) { ) {
return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW; return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER;
} }
}
/** // Case 2: Deleted product reference
* Case 3: Collection chooser if (
*/ isCollectionSelected &&
if ( ! hasInnerBlocks && ! isCollectionSelected ) { isProductContextRequired &&
return ProductCollectionUIStatesInEditor.COLLECTION_PICKER; ! 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 = ( { export const useSetPreviewState = ( {

View File

@ -2,47 +2,12 @@
* External dependencies * External dependencies
*/ */
import { import {
getContext as getContextFn, getContext,
store, store,
navigate as navigateFn, navigate as navigateFn,
} from '@woocommerce/interactivity'; } from '@woocommerce/interactivity';
import { getSetting } from '@woocommerce/settings'; 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 isBlockTheme = getSetting< boolean >( 'isBlockTheme' );
const isProductArchive = getSetting< boolean >( 'isProductArchive' ); const isProductArchive = getSetting< boolean >( 'isProductArchive' );
const needsRefresh = getSetting< boolean >( const needsRefresh = getSetting< boolean >(
@ -50,6 +15,28 @@ const needsRefresh = getSetting< boolean >(
false 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 = {} ) { export function navigate( href: string, options = {} ) {
/** /**
* We may need to reset the current page when changing filters. * 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 ); 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 * Internal dependencies
*/ */
import { navigate } from '../../frontend'; import { ProductFiltersContext } from '../../frontend';
type ActiveFiltersContext = {
queryId: number;
params: string[];
};
store( 'woocommerce/product-filter-active', { store( 'woocommerce/product-filter-active', {
actions: { actions: {
clearAll: () => { clearAll: () => {
const { params } = getContext< ActiveFiltersContext >(); const productFiltersContext = getContext< ProductFiltersContext >(
const url = new URL( window.location.href ); 'woocommerce/product-filters'
const { searchParams } = url; );
productFiltersContext.params = {};
params.forEach( ( param ) => searchParams.delete( param ) );
navigate( url.href );
}, },
}, },
} ); } );

View File

@ -1,14 +1,12 @@
/** /**
* External dependencies * External dependencies
*/ */
import { store, getContext } from '@woocommerce/interactivity'; import { store, getContext, getElement } from '@woocommerce/interactivity';
import { DropdownContext } from '@woocommerce/interactivity-components/dropdown';
import { HTMLElementEvent } from '@woocommerce/types';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { navigate } from '../../frontend'; import { ProductFiltersContext } from '../../frontend';
type AttributeFilterContext = { type AttributeFilterContext = {
attributeSlug: string; attributeSlug: string;
@ -16,102 +14,72 @@ type AttributeFilterContext = {
selectType: 'single' | 'multiple'; 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', { store( 'woocommerce/product-filter-attribute', {
actions: { actions: {
navigate: () => { toggleFilter: () => {
const dropdownContext = getContext< DropdownContext >( const { ref } = getElement();
'woocommerce/interactivity-dropdown' const targetAttribute =
); ref.getAttribute( 'data-attribute-value' ) ?? 'value';
const context = getContext< AttributeFilterContext >(); const termSlug = ref.getAttribute( targetAttribute );
const filters = dropdownContext.selectedItems
.map( ( item ) => item.value )
.filter( nonNullable );
navigate( if ( ! termSlug ) return;
getUrl( filters, context.attributeSlug, context.queryType )
);
},
updateProducts: ( event: HTMLElementEvent< HTMLInputElement > ) => {
if ( ! event.target.value ) return;
const context = getContext< AttributeFilterContext >(); const { attributeSlug, queryType } =
getContext< AttributeFilterContext >();
let selectedTerms = getSelectedTermsFromUrl( const productFiltersContext = getContext< ProductFiltersContext >(
context.attributeSlug 'woocommerce/product-filters'
); );
if ( if (
event.target.checked && ! (
! selectedTerms.includes( event.target.value ) `filter_${ attributeSlug }` in productFiltersContext.params
)
) { ) {
if ( context.selectType === 'multiple' ) productFiltersContext.params = {
selectedTerms.push( event.target.value ); ...productFiltersContext.params,
if ( context.selectType === 'single' ) [ `filter_${ attributeSlug }` ]: termSlug,
selectedTerms = [ event.target.value ]; [ `query_type_${ attributeSlug }` ]: queryType,
} else { };
selectedTerms = selectedTerms.filter( return;
( value ) => value !== event.target.value
);
} }
navigate( const selectedTerms =
getUrl( productFiltersContext.params[
selectedTerms, `filter_${ attributeSlug }`
context.attributeSlug, ].split( ',' );
context.queryType if ( selectedTerms.includes( termSlug ) ) {
) const remainingSelectedTerms = selectedTerms.filter(
); ( term ) => term !== termSlug
}, );
removeFilter: () => { if ( remainingSelectedTerms.length > 0 ) {
const { attributeSlug, queryType, value } = productFiltersContext.params[
getContext< ActiveAttributeFilterContext >(); `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 ); productFiltersContext.params = updatedParams;
}
navigate( getUrl( selectedTerms, attributeSlug, queryType ) ); } else {
productFiltersContext.params[ `filter_${ attributeSlug }` ] =
selectedTerms.concat( termSlug ).join( ',' );
}
}, },
clearFilters: () => { clearFilters: () => {
const { attributeSlug, queryType } = const { attributeSlug } = getContext< AttributeFilterContext >();
getContext< ActiveAttributeFilterContext >(); 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 './style.scss';
import './editor.scss'; import './editor.scss';
import { EditProps } from './types'; import { EditProps } from './types';
import { getColorClasses, getColorVars } from './utils';
const Edit = ( props: EditProps ): JSX.Element => { const Edit = ( props: EditProps ): JSX.Element => {
const { const {
@ -51,21 +52,9 @@ const Edit = ( props: EditProps ): JSX.Element => {
const blockProps = useBlockProps( { const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-checkbox-list', { className: clsx( 'wc-block-product-filter-checkbox-list', {
'is-loading': isLoading, 'is-loading': isLoading,
'has-option-element-border-color': ...getColorClasses( attributes ),
optionElementBorder.color || customOptionElementBorder,
'has-option-element-selected-color':
optionElementSelected.color || customOptionElementSelected,
'has-option-element-color':
optionElement.color || customOptionElement,
} ), } ),
style: { style: getColorVars( attributes ),
'--wc-product-filter-checkbox-list-option-element-border':
optionElementBorder.color || customOptionElementBorder,
'--wc-product-filter-checkbox-list-option-element-selected':
optionElementSelected.color || customOptionElementSelected,
'--wc-product-filter-checkbox-list-option-element':
optionElement.color || customOptionElement,
},
} ); } );
const loadingState = useMemo( () => { const loadingState = useMemo( () => {
@ -131,9 +120,9 @@ const Edit = ( props: EditProps ): JSX.Element => {
) ) } ) ) }
</ul> </ul>
{ ! isLoading && isLongList && ( { ! isLoading && isLongList && (
<span className="wc-block-product-filter-checkbox-list__show-more"> <button className="wc-block-product-filter-checkbox-list__show-more">
<small>{ __( 'Show more…', 'woocommerce' ) }</small> { __( 'Show more…', 'woocommerce' ) }
</span> </button>
) } ) }
</div> </div>
<InspectorControls group="color"> <InspectorControls group="color">

View File

@ -11,10 +11,12 @@ import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json'; import metadata from './block.json';
import Edit from './edit'; import Edit from './edit';
import './style.scss'; import './style.scss';
import Save from './save';
if ( isExperimentalBlocksEnabled() ) { if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, { registerBlockType( metadata, {
edit: Edit, edit: Edit,
icon: productFilterOptions, 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; padding: 0;
} }
.wc-block-product-filter-checkbox-list__item.hidden {
display: none;
}
:where(.wc-block-product-filter-checkbox-list__label) { :where(.wc-block-product-filter-checkbox-list__label) {
align-items: center; align-items: center;
display: flex; display: flex;
@ -34,6 +29,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
border-radius: 2px; border-radius: 2px;
pointer-events: none;
.has-option-element-color & { .has-option-element-color & {
display: none; display: none;
@ -51,6 +47,7 @@
margin: 0; margin: 0;
width: 1em; width: 1em;
background: var(--wc-product-filter-checkbox-list-option-element, transparent); 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 { .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); 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) { :where(.wc-block-product-filter-checkbox-list__show-more) {
cursor: pointer;
text-decoration: underline; text-decoration: underline;
} appearance: none;
background: transparent;
.wc-block-product-filter-checkbox-list__show-more.hidden { border: none;
display: 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": {}, "supports": {},
"usesContext": [ "usesContext": [
"filterData", "filterData"
"isParentSelected"
], ],
"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 * 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 * Internal dependencies
*/ */
import './style.scss'; import { EditProps } from './types';
import './editor.scss';
import { getColorClasses, getColorVars } from './utils';
const Edit = () => { const Edit = ( props: EditProps ): JSX.Element => {
return <div { ...useBlockProps() }>These are chips.</div>; 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 metadata from './block.json';
import Edit from './edit'; import Edit from './edit';
import Save from './save';
import './style.scss'; import './style.scss';
if ( isExperimentalBlocksEnabled() ) { if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, { registerBlockType( metadata, {
edit: Edit, edit: Edit,
icon: productFilterOptions, 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) { :where(.wc-block-product-filter-chips__items) {
// WIP 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 * Internal dependencies
*/ */
import { FilterBlockContext } from '../../types';
export type Color = {
slug?: string;
name?: string;
class?: string;
color: string;
};
export type BlockAttributes = { export type BlockAttributes = {
className: string; 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 { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element'; import { useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
import ErrorPlaceholder, {
ErrorObject,
} from '@woocommerce/editor-components/error-placeholder';
import { __ } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
@ -132,14 +136,16 @@ export const Edit = ( {
useEffect( () => { useEffect( () => {
const mode = getMode( currentTemplateId, templateType ); const mode = getMode( currentTemplateId, templateType );
const newProductGalleryClientId =
attributes.productGalleryClientId || clientId;
setAttributes( { setAttributes( {
...attributes, ...attributes,
mode, mode,
productGalleryClientId: clientId, productGalleryClientId: newProductGalleryClientId,
} ); } );
// Move the Thumbnails block to the correct above or below the Large Image based on the thumbnailsPosition attribute. // 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, setAttributes,
attributes, attributes,
@ -148,6 +154,18 @@ export const Edit = ( {
templateType, 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 ( return (
<div { ...blockProps }> <div { ...blockProps }>
<InspectorControls> <InspectorControls>

View File

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

View File

@ -17,4 +17,6 @@ export const ACTION_TYPES = {
SET_REDIRECT_URL: 'SET_REDIRECT_URL', SET_REDIRECT_URL: 'SET_REDIRECT_URL',
SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT', SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT',
SET_USE_SHIPPING_AS_BILLING: 'SET_USE_SHIPPING_AS_BILLING', 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; } as const;

View File

@ -118,6 +118,30 @@ export const __internalSetUseShippingAsBilling = (
useShippingAsBilling, 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 * Whether an account should be created for the user while checking out
* *
@ -182,6 +206,8 @@ export type CheckoutAction =
| typeof __internalSetCustomerId | typeof __internalSetCustomerId
| typeof __internalSetCustomerPassword | typeof __internalSetCustomerPassword
| typeof __internalSetUseShippingAsBilling | typeof __internalSetUseShippingAsBilling
| typeof setEditingBillingAddress
| typeof setEditingShippingAddress
| typeof __internalSetShouldCreateAccount | typeof __internalSetShouldCreateAccount
| typeof __internalSetOrderNotes | typeof __internalSetOrderNotes
| typeof setPrefersCollection | typeof setPrefersCollection

View File

@ -23,8 +23,28 @@ export type CheckoutState = {
shouldCreateAccount: boolean; // Should a user account be created? shouldCreateAccount: boolean; // Should a user account be created?
status: STATUS; // Status of the checkout status: STATUS; // Status of the checkout
useShippingAsBilling: boolean; // Should the billing form be hidden and inherit the shipping address? 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 = { export const defaultState: CheckoutState = {
additionalFields: checkoutData.additional_fields || {}, additionalFields: checkoutData.additional_fields || {},
calculatingCount: 0, calculatingCount: 0,
@ -38,8 +58,7 @@ export const defaultState: CheckoutState = {
redirectUrl: '', redirectUrl: '',
shouldCreateAccount: false, shouldCreateAccount: false,
status: STATUS.IDLE, status: STATUS.IDLE,
useShippingAsBilling: isSameAddress( useShippingAsBilling: billingMatchesShipping,
checkoutData.billing_address, editingBillingAddress: ! hasBillingAddress,
checkoutData.shipping_address editingShippingAddress: ! hasShippingAddress,
),
}; };

View File

@ -130,6 +130,20 @@ const reducer = ( state = defaultState, action: CheckoutAction ) => {
} }
break; 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: case types.SET_SHOULD_CREATE_ACCOUNT:
if ( if (
action.shouldCreateAccount !== undefined && action.shouldCreateAccount !== undefined &&

View File

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

View File

@ -1,5 +1,7 @@
# Checkout Store (`wc/store/checkout`) <!-- omit in toc --> # Checkout Store (`wc/store/checkout`) <!-- omit in toc -->
<!-- markdownlint-disable MD024 -->
> 💡 What's the difference between the Cart Store and the Checkout Store? > 💡 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. > 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(); 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 ### hasError
Returns true if an error occurred, and false otherwise. Returns true if an error occurred, and false otherwise.
@ -293,7 +325,6 @@ const store = select( CHECKOUT_STORE_KEY );
const isCalculating = store.isCalculating(); const isCalculating = store.isCalculating();
``` ```
### prefersCollection ### prefersCollection
Returns true if the customer prefers to collect their order, and false otherwise. 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 ); 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 --> <!-- FEEDBACK -->
--- ---

View File

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

View File

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

View File

@ -356,4 +356,84 @@ test.describe( 'Product Collection registration', () => {
await expect( previewButtonLocator ).toBeHidden(); 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. * Update cart page elements after add to cart events.
*/ */
AddToCartHandler.prototype.updateButton = function( e, fragments, cart_hash, $button ) { 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; $button = typeof $button === 'undefined' ? false : $button;
if ( $button ) { if ( $button ) {
@ -222,19 +224,25 @@ jQuery( function( $ ) {
* Update cart live region message after add/remove cart events. * Update cart live region message after add/remove cart events.
*/ */
AddToCartHandler.prototype.alertCartUpdated = function( e, fragments, cart_hash, $button ) { 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 ) { if ( $button ) {
return; 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> <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() ); ?>" /> <input type="hidden" name="attribute_names[<?php echo esc_attr( $i ); ?>]" value="<?php echo esc_attr( $attribute->get_name() ); ?>" />
<?php else : ?> <?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; ?> <?php endif; ?>
<input type="hidden" name="attribute_position[<?php echo esc_attr( $i ); ?>]" class="attribute_position" value="<?php echo esc_attr( $attribute->get_position() ); ?>" /> <input type="hidden" name="attribute_position[<?php echo esc_attr( $i ); ?>]" class="attribute_position" value="<?php echo esc_attr( $attribute->get_position() ); ?>" />
</td> </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; 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 ); $file = $this->get_file_name_from_class( $class );
$path = ''; $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\LegacyRestApiStub;
use Automattic\WooCommerce\Internal\Utilities\WebhookUtil; use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
use Automattic\WooCommerce\Internal\Admin\Marketplace; use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Internal\McStats;
use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil}; use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil};
use Automattic\WooCommerce\Internal\Logging\RemoteLogger; use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
@ -384,8 +383,10 @@ final class WooCommerce {
unset( $error_copy['message'] ); unset( $error_copy['message'] );
$context = array( $context = array(
'source' => 'fatal-errors', 'source' => 'fatal-errors',
'error' => $error_copy, '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:' ) ) { if ( false !== strpos( $message, 'Stack trace:' ) ) {
@ -407,12 +408,6 @@ final class WooCommerce {
$context $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. * 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 * @return string Url to cart page
*/ */
function wc_get_cart_url() { 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'; $protocol = is_ssl() ? 'https' : 'http';
$current_url = esc_url_raw( $protocol . '://' . wp_unslash( $_SERVER['HTTP_HOST'] ) . wp_unslash( $_SERVER['REQUEST_URI'] ) ); $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 ); $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