Merge branch 'trunk' into add/chips-style-and-new-interactitity-implementation

This commit is contained in:
Tung Du 2024-09-18 09:50:38 +07:00 committed by GitHub
commit 67386dd979
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
155 changed files with 7396 additions and 966 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

35
.husky/post-checkout Executable file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
. "$(dirname "$0")/_/husky.sh"
# '1' is branch
CHECKOUT_TYPE=$3
redColoured='\033[0;31m'
whiteColoured='\033[0m'
if [ "$CHECKOUT_TYPE" = '1' ]; then
canUpdateDependencies='no'
# Prompt about pnpm versions mismatch when switching between branches.
currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v ) || echo 'n/a' )
targetPnpmVersion=$( grep packageManager package.json | sed -nr 's/.+packageManager.+pnpm@([[:digit:].]+).+/\1/p' )
if [ "$currentPnpmVersion" != "$targetPnpmVersion" ]; then
printf "${redColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. Here some hints how to solve this:\n"
printf "${redColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n"
printf "${redColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n"
else
canUpdateDependencies='yes'
fi
# Auto-refresh dependencies when switching between branches.
changedManifests=$( ( git diff --name-only HEAD ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' )
if [ -n "$changedManifests" ]; then
printf "${whiteColoured}It was a change in the following file(s) - refreshing dependencies:\n"
printf "${whiteColoured} %s\n" $changedManifests
if [ "$canUpdateDependencies" = 'yes' ]; then
pnpm install --frozen-lockfile
else
printf "${redColoured}Skipping dependencies refresh. Please actualize pnpm version and execute 'pnpm install --frozen-lockfile' manually.\n"
fi
fi
fi

View File

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

2
.npmrc
View File

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

View File

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

View File

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

View File

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

View File

@ -874,7 +874,7 @@
"menu_title": "Development environment setup",
"tags": "tutorial, setup",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/getting-started/development-environment.md",
"hash": "9e471d3f44a882fe61dcad9e5207d51b280a7220aae1bf6e4ae1fbdd68b7e3d4",
"hash": "bf5d77349ea64d1b8e19fe6b7472be35ed92406c5aafe677ce92363fb13f94d4",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/getting-started/development-environment.md",
"id": "9080572a3904349c44c565ca7e1bef1212c58757"
},
@ -1059,7 +1059,7 @@
"menu_title": "DOM Events",
"tags": "how-to",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md",
"hash": "59a4b49eb146774d33229bc60ab7d8f74381493f6e7089ca8f0e2d0eb433a7a4",
"hash": "85cffe1cc273621f16f7362b5efe28ede9689cee0a6e87d0d426014bacc24b05",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md",
"id": "c8d247b91472740075871e6b57a9583d893ac650"
}
@ -1229,7 +1229,7 @@
"menu_title": "Core critical flows",
"tags": "reference",
"edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/quality-and-best-practices/core-critical-flows.md",
"hash": "472f5a240abe907fec83a8a9f88af6699f2d994aa7ae87faa1716a087baa66db",
"hash": "34109195216ebcb5b23e741391b9f355ba861777a5533d4ef1e341472cb5209e",
"url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/quality-and-best-practices/core-critical-flows.md",
"id": "e561b46694dba223c38b87613ce4907e4e14333a"
},
@ -1804,5 +1804,5 @@
"categories": []
}
],
"hash": "77c102c35a45b0681e7b70def9d639d764e4e5068121c2ef4dd23477c0f8784c"
"hash": "47dc2a7e213e1e9d83e93a85dafdf8c7b539cc5474b4166eb6398b734d150ff3"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,16 @@
*/
import { store as blockEditorStore } from '@wordpress/block-editor';
import { addFilter } from '@wordpress/hooks';
import { select } from '@wordpress/data';
import { select, useSelect } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
import { isWpVersion } from '@woocommerce/settings';
import type { BlockEditProps, Block } from '@wordpress/blocks';
import { useEffect, useLayoutEffect, useState } from '@wordpress/element';
import {
useEffect,
useLayoutEffect,
useState,
useMemo,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import type { ProductResponseItem } from '@woocommerce/types';
import { getProduct } from '@woocommerce/editor-components/utils';
@ -193,7 +199,7 @@ export const getUsesReferencePreviewMessage = (
return '';
};
export const getProductCollectionUIStateInEditor = ( {
export const useProductCollectionUIState = ( {
location,
usesReference,
attributes,
@ -203,7 +209,31 @@ export const getProductCollectionUIStateInEditor = ( {
usesReference?: string[] | undefined;
attributes: ProductCollectionAttributes;
hasInnerBlocks: boolean;
} ): ProductCollectionUIStatesInEditor => {
} ) => {
// Fetch product to check if it's deleted.
// `product` will be undefined if it doesn't exist.
const productId = attributes.query?.productReference;
const { product, hasResolved } = useSelect(
( selectFunc ) => {
if ( ! productId ) {
return { product: null, hasResolved: true };
}
const { getEntityRecord, hasFinishedResolution } =
selectFunc( coreDataStore );
const selectorArgs = [ 'postType', 'product', productId ];
return {
product: getEntityRecord( ...selectorArgs ),
hasResolved: hasFinishedResolution(
'getEntityRecord',
selectorArgs
),
};
},
[ productId ]
);
const productCollectionUIStateInEditor = useMemo( () => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isCollectionSelected = !! attributes.collection;
@ -222,8 +252,23 @@ export const getProductCollectionUIStateInEditor = ( {
return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER;
}
// Case 2: Deleted product reference
if (
isCollectionSelected &&
isProductContextRequired &&
! isInRequiredLocation &&
isProductContextSelected
) {
const isProductDeleted =
productId &&
( product === undefined || product?.status === 'trash' );
if ( isProductDeleted ) {
return ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE;
}
}
/**
* Case 2: Preview mode - based on `usesReference` value
* Case 3: Preview mode - based on `usesReference` value
*/
if ( isInRequiredLocation ) {
/**
@ -249,13 +294,26 @@ export const getProductCollectionUIStateInEditor = ( {
}
/**
* Case 3: Collection chooser
* Case 4: Collection chooser
*/
if ( ! hasInnerBlocks && ! isCollectionSelected ) {
return ProductCollectionUIStatesInEditor.COLLECTION_PICKER;
}
return ProductCollectionUIStatesInEditor.VALID;
}, [
location.type,
location.sourceData?.termId,
location.sourceData?.productId,
usesReference,
attributes.collection,
productId,
product,
hasInnerBlocks,
attributes.query?.productReference,
] );
return { productCollectionUIStateInEditor, isLoading: ! hasResolved };
};
export const useSetPreviewState = ( {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,8 @@ import { useState } from '@wordpress/element';
import clsx from 'clsx';
import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
import type { ReactNode, ReactElement } from 'react';
import { Button } from '@ariakit/react';
import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
*/
@ -27,7 +28,11 @@ const Panel = ( {
initialOpen = false,
hasBorder = false,
title,
titleTag: TitleTag = 'div',
/**
* @deprecated The `titleTag` prop is deprecated and will be removed in a future version.
* Use the `title` prop to pass a custom React element instead.
*/
titleTag,
state,
}: PanelProps ): ReactElement => {
let [ isOpen, setIsOpen ] = useState< boolean >( initialOpen );
@ -36,14 +41,20 @@ const Panel = ( {
[ isOpen, setIsOpen ] = state;
}
if ( titleTag ) {
deprecated( "Panel component's titleTag prop", {
since: '9.4.0',
} );
}
return (
<div
className={ clsx( className, 'wc-block-components-panel', {
'has-border': hasBorder,
} ) }
>
<TitleTag>
<button
<Button
render={ <div /> }
aria-expanded={ isOpen }
className="wc-block-components-panel__button"
onClick={ () => setIsOpen( ! isOpen ) }
@ -54,8 +65,7 @@ const Panel = ( {
icon={ isOpen ? chevronUp : chevronDown }
/>
{ title }
</button>
</TitleTag>
</Button>
{ isOpen && (
<div className="wc-block-components-panel__content">
{ children }

View File

@ -14,31 +14,27 @@
}
.wc-block-components-panel__button {
@include reset-box();
box-sizing: border-box;
height: auto;
line-height: 1;
margin-top: em(6px);
padding-right: #{24px + $gap-smaller};
padding-top: em($gap-small - 6px);
padding-left: 0 !important;
position: relative;
text-align: left;
width: 100%;
word-break: break-word;
&[aria-expanded="true"] {
padding-bottom: $gap-smaller;
margin-bottom: $gap-smaller;
margin-bottom: $gap-smaller * 2;
}
&,
&:hover,
&:focus,
&:active {
@include reset-color();
@include reset-typography();
background: transparent;
box-shadow: none;
cursor: pointer;
padding-left: 0 !important;
}
> .wc-block-components-panel__button-icon {
@ -58,21 +54,3 @@
display: none;
}
}
// Extra classes for specificity.
.theme-twentytwentyone.theme-twentytwentyone.theme-twentytwentyone
.wc-block-components-panel__button {
background-color: inherit;
color: inherit;
}
.theme-twentytwenty .wc-block-components-panel__button,
.theme-twentyseventeen .wc-block-components-panel__button {
background: none transparent;
color: inherit;
&.wc-block-components-panel__button:hover,
&.wc-block-components-panel__button:focus {
background: none transparent;
}
}

View File

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

View File

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

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Adjust Mongolia postcode validation to be 5 digits or 5 digits followed by 4 digits.

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: tweak
Fix PHPCS warnings in OrdersTableQuery.php and ProductQuery.php

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: update
Expand the e2e suite we're running on WPCOM.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Harden styles for interactive elements in Checkout block to prevent style leakage.

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: minor
Type: add
Add use-wp-horizon feature flag to set calpyso_env to horizon

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: minor
Type: add
Add inspector controls to Product Search block #51247

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,4 @@
Significance: patch
Type: add
Adds a filter for third party tax plugins to indicate that they have completed the tax task

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
Improve performance of tax report export generation and fix tax_code for removed rates.

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,5 @@
Significance: patch
Type: fix
Comment: Fix a type mismatch in UpdateProducts.php

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Set customer email in reports if customer data is available

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Introduce error handling on the in-app my subscriptions page

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

@ -39,6 +39,7 @@
"launch-your-store": true,
"product-editor-template-system": false,
"blueprint": false,
"reactify-classic-payments-settings": false
"reactify-classic-payments-settings": false,
"use-wp-horizon": false
}
}

View File

@ -39,6 +39,7 @@
"launch-your-store": true,
"product-editor-template-system": false,
"blueprint": true,
"reactify-classic-payments-settings": false
"reactify-classic-payments-settings": false,
"use-wp-horizon": false
}
}

View File

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

View File

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

View File

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

View File

@ -173,6 +173,8 @@ jQuery( function( $ ) {
* Update cart page elements after add to cart events.
*/
AddToCartHandler.prototype.updateButton = function( e, fragments, cart_hash, $button ) {
// Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function.
// If there is no button we don't want to crash.
$button = typeof $button === 'undefined' ? false : $button;
if ( $button ) {
@ -222,6 +224,11 @@ jQuery( function( $ ) {
* Update cart live region message after add/remove cart events.
*/
AddToCartHandler.prototype.alertCartUpdated = function( e, fragments, cart_hash, $button ) {
// Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function.
// If there is no button we don't want to crash.
$button = typeof $button === 'undefined' ? false : $button;
if ( $button ) {
var message = $button.data( 'success_message' );
if ( !message ) {
@ -235,6 +242,7 @@ jQuery( function( $ ) {
.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

@ -215,114 +215,12 @@ class WC_Admin_Importers {
* Ajax callback for importing one batch of products from a CSV.
*/
public function do_ajax_product_import() {
global $wpdb;
check_ajax_referer( 'wc-product-import', 'security' );
if ( ! $this->import_allowed() || ! isset( $_POST['file'] ) ) { // PHPCS: input var ok.
if ( ! $this->import_allowed() ) {
wp_send_json_error( array( 'message' => __( 'Insufficient privileges to import products.', 'woocommerce' ) ) );
}
include_once WC_ABSPATH . 'includes/admin/importers/class-wc-product-csv-importer-controller.php';
include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
$file = wc_clean( wp_unslash( $_POST['file'] ) ); // PHPCS: input var ok.
$params = array(
'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok.
'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok.
'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok.
'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok.
'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '',
/**
* Batch size for the product import process.
*
* @param int $size Batch size.
*
* @since 3.1.0
*/
'lines' => apply_filters( 'woocommerce_product_import_batch_size', 30 ),
'parse' => true,
);
// Log failures.
if ( 0 !== $params['start_pos'] ) {
$error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) );
} else {
$error_log = array();
}
$importer = WC_Product_CSV_Importer_Controller::get_importer( $file, $params );
$results = $importer->import();
$percent_complete = $importer->get_percent_complete();
$error_log = array_merge( $error_log, $results['failed'], $results['skipped'] );
update_user_option( get_current_user_id(), 'product_import_error_log', $error_log );
if ( 100 === $percent_complete ) {
// @codingStandardsIgnoreStart.
$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) );
$wpdb->delete( $wpdb->posts, array(
'post_type' => 'product',
'post_status' => 'importing',
) );
$wpdb->delete( $wpdb->posts, array(
'post_type' => 'product_variation',
'post_status' => 'importing',
) );
// @codingStandardsIgnoreEnd.
// Clean up orphaned data.
$wpdb->query(
"
DELETE {$wpdb->posts}.* FROM {$wpdb->posts}
LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent
WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation'
"
);
$wpdb->query(
"
DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta}
LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id
WHERE wp.ID IS NULL
"
);
// @codingStandardsIgnoreStart.
$wpdb->query( "
DELETE tr.* FROM {$wpdb->term_relationships} tr
LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id
LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE wp.ID IS NULL
AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' )
" );
// @codingStandardsIgnoreEnd.
// Send success.
wp_send_json_success(
array(
'position' => 'done',
'percentage' => 100,
'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ),
'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
)
);
} else {
wp_send_json_success(
array(
'position' => $importer->get_file_position(),
'percentage' => $percent_complete,
'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
)
);
}
WC_Product_CSV_Importer_Controller::dispatch_ajax();
}
/**

View File

@ -103,6 +103,56 @@ class WC_Product_CSV_Importer_Controller {
return wc_is_file_valid_csv( $file, $check_path );
}
/**
* Runs before controller actions to check that the file used during the import is valid.
*
* @since 9.3.0
*
* @param string $path Path to test.
*
* @throws \Exception When file validation fails.
*/
protected static function check_file_path( string $path ): void {
$is_valid_file = false;
if ( ! empty( $path ) ) {
$path = realpath( $path );
$is_valid_file = false !== $path;
}
// File must be readable.
$is_valid_file = $is_valid_file && is_readable( $path );
// Check that file is within an allowed location.
if ( $is_valid_file ) {
$in_valid_location = false;
$valid_locations = array();
$valid_locations[] = ABSPATH;
$upload_dir = wp_get_upload_dir();
if ( false === $upload_dir['error'] ) {
$valid_locations[] = $upload_dir['basedir'];
}
foreach ( $valid_locations as $valid_location ) {
if ( 0 === stripos( $path, trailingslashit( realpath( $valid_location ) ) ) ) {
$in_valid_location = true;
break;
}
}
$is_valid_file = $in_valid_location;
}
if ( ! $is_valid_file ) {
throw new \Exception( esc_html__( 'File path provided for import is invalid.', 'woocommerce' ) );
}
if ( ! self::is_file_valid_csv( $path ) ) {
throw new \Exception( esc_html__( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) );
}
}
/**
* Get all the valid filetypes for a CSV file.
*
@ -263,17 +313,151 @@ class WC_Product_CSV_Importer_Controller {
* Dispatch current step and show correct view.
*/
public function dispatch() {
$output = '';
try {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) {
if ( is_callable( $this->steps[ $this->step ]['handler'] ) ) {
call_user_func( $this->steps[ $this->step ]['handler'], $this );
}
}
ob_start();
if ( is_callable( $this->steps[ $this->step ]['view'] ) ) {
call_user_func( $this->steps[ $this->step ]['view'], $this );
}
$output = ob_get_clean();
} catch ( \Exception $e ) {
$this->add_error( $e->getMessage() );
}
$this->output_header();
$this->output_steps();
$this->output_errors();
call_user_func( $this->steps[ $this->step ]['view'], $this );
echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output is HTML we've generated ourselves.
$this->output_footer();
}
/**
* Processes AJAX requests related to a product CSV import.
*
* @since 9.3.0
*/
public static function dispatch_ajax() {
global $wpdb;
check_ajax_referer( 'wc-product-import', 'security' );
try {
$file = wc_clean( wp_unslash( $_POST['file'] ?? '' ) ); // PHPCS: input var ok.
self::check_file_path( $file );
$params = array(
'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok.
'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok.
'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok.
'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok.
'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '',
/**
* Batch size for the product import process.
*
* @param int $size Batch size.
*
* @since 3.1.0
*/
'lines' => apply_filters( 'woocommerce_product_import_batch_size', 1 ),
'parse' => true,
);
// Log failures.
if ( 0 !== $params['start_pos'] ) {
$error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) );
} else {
$error_log = array();
}
include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
$importer = self::get_importer( $file, $params );
$results = $importer->import();
$percent_complete = $importer->get_percent_complete();
$error_log = array_merge( $error_log, $results['failed'], $results['skipped'] );
update_user_option( get_current_user_id(), 'product_import_error_log', $error_log );
if ( 100 === $percent_complete ) {
// @codingStandardsIgnoreStart.
$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) );
$wpdb->delete( $wpdb->posts, array(
'post_type' => 'product',
'post_status' => 'importing',
) );
$wpdb->delete( $wpdb->posts, array(
'post_type' => 'product_variation',
'post_status' => 'importing',
) );
// @codingStandardsIgnoreEnd.
// Clean up orphaned data.
$wpdb->query(
"
DELETE {$wpdb->posts}.* FROM {$wpdb->posts}
LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent
WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation'
"
);
$wpdb->query(
"
DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta}
LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id
WHERE wp.ID IS NULL
"
);
// @codingStandardsIgnoreStart.
$wpdb->query( "
DELETE tr.* FROM {$wpdb->term_relationships} tr
LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id
LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE wp.ID IS NULL
AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' )
" );
// @codingStandardsIgnoreEnd.
// Send success.
wp_send_json_success(
array(
'position' => 'done',
'percentage' => 100,
'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ),
'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
)
);
} else {
wp_send_json_success(
array(
'position' => $importer->get_file_position(),
'percentage' => $percent_complete,
'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0,
'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0,
'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0,
'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0,
'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0,
)
);
}
} catch ( \Exception $e ) {
wp_send_json_error( array( 'message' => $e->getMessage() ) );
}
}
/**
* Output information about the uploading process.
*/
@ -314,60 +498,20 @@ class WC_Product_CSV_Importer_Controller {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in WC_Product_CSV_Importer_Controller::upload_form_handler()
$file_url = isset( $_POST['file_url'] ) ? wc_clean( wp_unslash( $_POST['file_url'] ) ) : '';
if ( empty( $file_url ) ) {
if ( ! isset( $_FILES['import'] ) ) {
return new WP_Error( 'woocommerce_product_csv_importer_upload_file_empty', __( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) );
try {
if ( ! empty( $file_url ) ) {
$path = ABSPATH . $file_url;
self::check_file_path( $path );
} else {
$csv_import_util = wc_get_container()->get( Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper::class );
$upload = $csv_import_util->handle_csv_upload( 'product', 'import', self::get_valid_csv_filetypes() );
$path = $upload['file'];
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
if ( ! self::is_file_valid_csv( wc_clean( wp_unslash( $_FILES['import']['name'] ) ), false ) ) {
return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) );
return $path;
} catch ( \Exception $e ) {
return new \WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', $e->getMessage() );
}
$overrides = array(
'test_form' => false,
'mimes' => self::get_valid_csv_filetypes(),
);
$import = $_FILES['import']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$upload = wp_handle_upload( $import, $overrides );
if ( isset( $upload['error'] ) ) {
return new WP_Error( 'woocommerce_product_csv_importer_upload_error', $upload['error'] );
}
// Construct the object array.
$object = array(
'post_title' => basename( $upload['file'] ),
'post_content' => $upload['url'],
'post_mime_type' => $upload['type'],
'guid' => $upload['url'],
'context' => 'import',
'post_status' => 'private',
);
// Save the data.
$id = wp_insert_attachment( $object, $upload['file'] );
/*
* Schedule a cleanup for one day from now in case of failed
* import or missing wp_import_cleanup() call.
*/
wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( $id ) );
return $upload['file'];
} elseif (
( 0 === stripos( realpath( ABSPATH . $file_url ), ABSPATH ) ) &&
file_exists( ABSPATH . $file_url )
) {
if ( ! self::is_file_valid_csv( ABSPATH . $file_url ) ) {
return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) );
}
return ABSPATH . $file_url;
}
// phpcs:enable
return new WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', __( 'Please upload or provide the link to a valid CSV file.', 'woocommerce' ) );
}
/**
@ -375,6 +519,8 @@ class WC_Product_CSV_Importer_Controller {
*/
protected function mapping_form() {
check_admin_referer( 'woocommerce-csv-importer' );
self::check_file_path( $this->file );
$args = array(
'lines' => 1,
'delimiter' => $this->delimiter,
@ -412,18 +558,7 @@ class WC_Product_CSV_Importer_Controller {
// Displaying this page triggers Ajax action to run the import with a valid nonce,
// therefore this page needs to be nonce protected as well.
check_admin_referer( 'woocommerce-csv-importer' );
if ( ! self::is_file_valid_csv( $this->file ) ) {
$this->add_error( __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) );
$this->output_errors();
return;
}
if ( ! is_file( $this->file ) ) {
$this->add_error( __( 'The file does not exist, please try again.', 'woocommerce' ) );
$this->output_errors();
return;
}
self::check_file_path( $this->file );
if ( ! empty( $_POST['map_from'] ) && ! empty( $_POST['map_to'] ) ) {
$mapping_from = wc_clean( wp_unslash( $_POST['map_from'] ) );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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