Merge branch 'trunk' into feature/34548-multichannel-marketing-backend

This commit is contained in:
Nima 2022-12-28 13:39:42 +00:00
commit a9e8dd0e1c
281 changed files with 6207 additions and 2129 deletions

View File

@ -1,28 +1,28 @@
name: Cherry Pick Tool name: Cherry Pick Tool
on: on:
issues: issues:
types: [milestoned, labeled] types: [milestoned, labeled]
pull_request: pull_request:
types: [closed] types: [closed]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release_branch: release_branch:
description: Provide the release branch you want to cherry pick into. Example release/6.9 description: Provide the release branch you want to cherry pick into. Example release/6.9
default: '' default: ''
required: true required: true
pull_requests: pull_requests:
description: The pull request number. description: The pull request number.
default: '' default: ''
required: true required: true
skipSlackPing: skipSlackPing:
description: "Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)" description: 'Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)'
type: boolean type: boolean
required: false required: false
default: false default: false
slackChannelOverride: slackChannelOverride:
description: "Slack Channel Override: The channel ID to send the Slack ping about the code freeze violation" description: 'Slack Channel Override: The channel ID to send the Slack ping about the code freeze violation'
required: false required: false
default: '' default: ''
env: env:
GIT_COMMITTER_NAME: 'WooCommerce Bot' GIT_COMMITTER_NAME: 'WooCommerce Bot'
@ -31,320 +31,321 @@ env:
GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com' GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com'
jobs: jobs:
verify: verify:
name: Verify name: Verify
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
outputs: outputs:
run: ${{ steps.check.outputs.run }} run: ${{ steps.check.outputs.run }}
steps: steps:
- name: check - name: check
id: check id: check
uses: actions/github-script@v6 uses: actions/github-script@v6
with: with:
script: | script: |
let run = false; let run = false;
const isManualTrigger = context.payload.inputs && context.payload.inputs.release_branch && context.payload.inputs.release_branch != null; const isManualTrigger = context.payload.inputs && context.payload.inputs.release_branch && context.payload.inputs.release_branch != null;
const isMergedMilestonedIssue = context.payload.issue && context.payload.issue.pull_request != null && context.payload.issue.pull_request.merged_at != null && context.payload.issue.milestone != null;
const isMergedMilestonedPR = context.payload.pull_request && context.payload.pull_request != null && context.payload.pull_request.merged == true && context.payload.pull_request.milestone != null;
const isBot = context.payload.pull_request && ( context.payload.pull_request.user.login == 'github-actions[bot]' || context.payload.pull_request.user.type == 'Bot' ); const isMergedMilestonedIssue = context.payload.issue && context.payload.issue.pull_request != null && context.payload.issue.pull_request.merged_at != null && context.payload.issue.milestone != null;
if ( !isBot && ( isManualTrigger || isMergedMilestonedIssue || isMergedMilestonedPR ) ) { const isMergedMilestonedPR = context.payload.pull_request && context.payload.pull_request != null && context.payload.pull_request.merged == true && context.payload.pull_request.milestone != null;
console.log( "::set-output name=run::true" );
} else {
console.log( "::set-output name=run::false" );
}
prep:
name: Prep inputs
runs-on: ubuntu-20.04
needs: verify
if: needs.verify.outputs.run == 'true'
outputs:
release: ${{ steps.prep-inputs.outputs.release }}
pr: ${{ steps.prep-inputs.outputs.pr }}
version: ${{ steps.prep-inputs.outputs.version }}
steps:
- name: Prep inputs
id: prep-inputs
uses: actions/github-script@v6
with:
script: |
const event = ${{ toJSON( github.event ) }}
// Means this workflow was triggered manually. const isBot = context.payload.pull_request && ( context.payload.pull_request.user.login == 'github-actions[bot]' || context.payload.pull_request.user.type == 'Bot' );
if ( event.inputs && event.inputs.release_branch ) {
const releaseBranch = '${{ inputs.release_branch }}'
const version = releaseBranch.replace( 'release/', '' );
console.log( "::set-output name=version::" + version ) if ( !isBot && ( isManualTrigger || isMergedMilestonedIssue || isMergedMilestonedPR ) ) {
console.log( "::set-output name=release::${{ inputs.release_branch }}" ) core.setOutput( 'run', 'true' );
} else if ( event.action === 'milestoned' ) { } else {
const version = '${{ github.event.issue.milestone.title }}' core.setOutput( 'run', 'false' );
const release = version.substring( 0, 3 ) }
prep:
name: Prep inputs
runs-on: ubuntu-20.04
needs: verify
if: needs.verify.outputs.run == 'true'
outputs:
release: ${{ steps.prep-inputs.outputs.release }}
pr: ${{ steps.prep-inputs.outputs.pr }}
version: ${{ steps.prep-inputs.outputs.version }}
steps:
- name: Prep inputs
id: prep-inputs
uses: actions/github-script@v6
with:
script: |
const event = ${{ toJSON( github.event ) }}
console.log( "::set-output name=version::" + version ) // Means this workflow was triggered manually.
console.log( "::set-output name=release::release/" + release ) if ( event.inputs && event.inputs.release_branch ) {
} else { const releaseBranch = '${{ inputs.release_branch }}'
const version = '${{ github.event.pull_request.milestone.title }}' const version = releaseBranch.replace( 'release/', '' )
const release = version.substring( 0, 3 )
console.log( "::set-output name=version::" + version ) core.setOutput( 'version', version )
console.log( "::set-output name=release::release/" + release ) core.setOutput( 'release', releaseBranch )
} } else if ( event.action === 'milestoned' ) {
const version = '${{ github.event.issue.milestone.title }}'
const release = version.substring( 0, 3 )
// Means this workflow was triggered manually. core.setOutput( 'version', version )
if ( event.inputs && event.inputs.pull_requests ) { core.setOutput( 'release', `release/${release}` )
console.log( "::set-output name=pr::${{ inputs.pull_requests }}" ) } else {
} else if ( event.action === 'milestoned' ) { const version = '${{ github.event.pull_request.milestone.title }}'
console.log( "::set-output name=pr::${{ github.event.issue.number }}" ) const release = version.substring( 0, 3 )
} else {
console.log( "::set-output name=pr::${{ github.event.pull_request.number }}" )
}
check-release-branch-exists:
name: Check for existence of release branch
runs-on: ubuntu-20.04
needs: prep
steps:
- name: Check for release branch
id: release-breanch-check
uses: actions/github-script@v6
with:
script: |
// This will throw an error for non-200 responses, which prevents subsequent jobs from completing, as desired.
await github.request( 'GET /repos/{owner}/{repo}/branches/{branch}', {
owner: context.repo.owner,
repo: context.repo.repo,
branch: '${{ needs.prep.outputs.release }}',
} );
cherry-pick-run:
name: Run cherry pick tool
runs-on: ubuntu-20.04
needs: [ prep, check-release-branch-exists ]
if: success()
steps:
- name: Checkout release branch
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Git fetch the release branch core.setOutput( 'version', version )
run: git fetch origin ${{ needs.prep.outputs.release }} core.setOutput( 'release', `release/${release}` )
- name: Checkout release branch
run: git checkout ${{ needs.prep.outputs.release }}
- name: Create a cherry pick branch based on release branch
run: git checkout -b cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }}
- name: Get commit sha from PR
id: commit-sha
uses: actions/github-script@v6
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: '${{ needs.prep.outputs.pr }}'
})
console.log( `::set-output name=sha::${ pr.data.merge_commit_sha }` )
- name: Cherry pick
run: |
git cherry-pick ${{ steps.commit-sha.outputs.sha }}
- name: Generate changelog
id: changelog
uses: actions/github-script@v6
with:
script: |
const fs = require( 'node:fs' );
const changelogsToBeDeleted = []
let changelogTxt = '';
const commit = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: '${{ steps.commit-sha.outputs.sha }}'
})
for ( const file of commit.data.files ) {
if ( file.filename.match( 'plugins/woocommerce/changelog/' ) ) {
if ( changelogsToBeDeleted.indexOf( file.filename ) === -1 ) {
changelogsToBeDeleted.push( file.filename );
}
let changelogEntry = '';
let changelogEntryType = '';
fs.readFile( './' + file.filename, 'utf-8', function( err, data ) {
if ( err ) {
console.error( err );
}
const changelogEntryArr = data.split( "\n" );
changelogEntryType = data.match( /Type: (.+)/i );
changelogEntryType = changelogEntryType[ 1 ].charAt( 0 ).toUpperCase() + changelogEntryType[ 1 ].slice( 1 );
changelogEntry = changelogEntryArr.filter( el => {
return el !== null && typeof el !== 'undefined' && el !== '';
} );
changelogEntry = changelogEntry[ changelogEntry.length - 1 ];
// Check if changelogEntry is what we want.
if ( changelogEntry.length < 1 ) {
changelogEntry = false;
}
if ( changelogEntry.match( /significance:/i ) ) {
changelogEntry = false;
}
if ( changelogEntry.match( /type:/i ) ) {
changelogEntry = false;
}
if ( changelogEntry.match( /comment:/i ) ) {
changelogEntry = false;
}
if ( ! changelogEntry ) {
return;
}
fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) {
if ( err ) {
console.error( err );
}
changelogTxt = data.split( "\n" );
let isInRange = false;
let newChangelogTxt = [];
for ( const line of changelogTxt ) {
if ( isInRange === false && line === '== Changelog ==' ) {
isInRange = true;
} }
if ( isInRange === true && line.match( /\*\*WooCommerce Blocks/ ) ) { // Means this workflow was triggered manually.
isInRange = false; if ( event.inputs && event.inputs.pull_requests ) {
core.setOutput( 'pr', '${{ inputs.pull_requests }}' )
} else if ( event.action === 'milestoned' ) {
core.setOutput( 'pr', '${{ github.event.issue.number }}' )
} else {
core.setOutput( 'pr', '${{ github.event.pull_request.number }}' )
}
check-release-branch-exists:
name: Check for existence of release branch
runs-on: ubuntu-20.04
needs: prep
steps:
- name: Check for release branch
id: release-breanch-check
uses: actions/github-script@v6
with:
script: |
// This will throw an error for non-200 responses, which prevents subsequent jobs from completing, as desired.
await github.request( 'GET /repos/{owner}/{repo}/branches/{branch}', {
owner: context.repo.owner,
repo: context.repo.repo,
branch: '${{ needs.prep.outputs.release }}',
} );
cherry-pick-run:
name: Run cherry pick tool
runs-on: ubuntu-20.04
needs: [prep, check-release-branch-exists]
if: success()
steps:
- name: Checkout release branch
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Git fetch the release branch
run: git fetch origin ${{ needs.prep.outputs.release }}
- name: Checkout release branch
run: git checkout ${{ needs.prep.outputs.release }}
- name: Create a cherry pick branch based on release branch
run: git checkout -b cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }}
- name: Get commit sha from PR
id: commit-sha
uses: actions/github-script@v6
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: '${{ needs.prep.outputs.pr }}'
})
core.setOutput( 'sha', pr.data.merge_commit_sha )
- name: Cherry pick
run: |
git cherry-pick ${{ steps.commit-sha.outputs.sha }} -m1
- name: Generate changelog
id: changelog
uses: actions/github-script@v6
with:
script: |
const fs = require( 'node:fs' );
const changelogsToBeDeleted = []
let changelogTxt = '';
const commit = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: '${{ steps.commit-sha.outputs.sha }}'
})
for ( const file of commit.data.files ) {
if ( file.filename.match( 'plugins/woocommerce/changelog/' ) ) {
if ( changelogsToBeDeleted.indexOf( file.filename ) === -1 ) {
changelogsToBeDeleted.push( file.filename );
}
let changelogEntry = '';
let changelogEntryType = '';
fs.readFile( './' + file.filename, 'utf-8', function( err, data ) {
if ( err ) {
console.error( err );
}
const changelogEntryArr = data.split( "\n" );
changelogEntryType = data.match( /Type: (.+)/i );
changelogEntryType = changelogEntryType[ 1 ].charAt( 0 ).toUpperCase() + changelogEntryType[ 1 ].slice( 1 );
changelogEntry = changelogEntryArr.filter( el => {
return el !== null && typeof el !== 'undefined' && el !== '';
} );
changelogEntry = changelogEntry[ changelogEntry.length - 1 ];
// Check if changelogEntry is what we want.
if ( changelogEntry.length < 1 ) {
changelogEntry = false;
}
if ( changelogEntry.match( /significance:/i ) ) {
changelogEntry = false;
}
if ( changelogEntry.match( /type:/i ) ) {
changelogEntry = false;
}
if ( changelogEntry.match( /comment:/i ) ) {
changelogEntry = false;
}
if ( ! changelogEntry ) {
return;
}
fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) {
if ( err ) {
console.error( err );
}
changelogTxt = data.split( "\n" );
let isInRange = false;
let newChangelogTxt = [];
for ( const line of changelogTxt ) {
if ( isInRange === false && line === '== Changelog ==' ) {
isInRange = true;
}
if ( isInRange === true && line.match( /\*\*WooCommerce Blocks/ ) ) {
isInRange = false;
}
// Find the first match of the entry "Type".
if ( isInRange && line.match( `\\* ${changelogEntryType} -` ) ) {
newChangelogTxt.push( '* ' + changelogEntryType + ' - ' + changelogEntry + ` [#${{ needs.prep.outputs.pr }}](https://github.com/woocommerce/woocommerce/pull/${{ needs.prep.outputs.pr }})` );
newChangelogTxt.push( line );
isInRange = false;
continue;
}
newChangelogTxt.push( line );
}
fs.writeFile( './plugins/woocommerce/readme.txt', newChangelogTxt.join( "\n" ), err => {
if ( err ) {
console.error( `Unable to generate the changelog entry for PR ${{ needs.prep.outputs.pr }}` );
}
} );
} );
} );
}
} }
// Find the first match of the entry "Type". core.setOutput( 'changelogsToBeDeleted', changelogsToBeDeleted.join( ' ' ) )
if ( isInRange && line.match( `\\* ${changelogEntryType} -` ) ) {
newChangelogTxt.push( '* ' + changelogEntryType + ' - ' + changelogEntry + ` [#${{ needs.prep.outputs.pr }}](https://github.com/woocommerce/woocommerce/pull/${{ needs.prep.outputs.pr }})` );
newChangelogTxt.push( line );
isInRange = false;
continue;
}
newChangelogTxt.push( line ); - name: Delete changelog files from cherry pick branch
} if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }}
fs.writeFile( './plugins/woocommerce/readme.txt', newChangelogTxt.join( "\n" ), err => { - name: Commit changes for cherry pick
if ( err ) { if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
console.error( `Unable to generate the changelog entry for PR ${{ needs.prep.outputs.pr }}` ); run: git commit --no-verify -am "Prep for cherry pick ${{ needs.prep.outputs.pr }}"
}
} );
} );
} );
}
}
console.log( `::set-output name=changelogsToBeDeleted::${ changelogsToBeDeleted }` ) - name: Push cherry pick branch up
run: git push origin cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }}
- name: Delete changelog files from cherry pick branch - name: Create the PR for cherry pick branch
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null id: cherry-pick-pr
run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }} uses: actions/github-script@v6
with:
script: |
let cherryPickPRBody = "This PR cherry-picks the following PRs into the release branch:\n";
- name: Commit changes for cherry pick cherryPickPRBody = `${cherryPickPRBody}` + `* #${{ needs.prep.outputs.pr }}` + "\n";
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
run: git commit --no-verify -am "Prep for cherry pick ${{ needs.prep.outputs.pr }}"
- name: Push cherry pick branch up const pr = await github.rest.pulls.create({
run: git push origin cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }} owner: context.repo.owner,
repo: context.repo.repo,
title: "Cherry pick ${{ needs.prep.outputs.pr }} into ${{ needs.prep.outputs.release }}",
head: "cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }}",
base: "${{ needs.prep.outputs.release }}",
body: cherryPickPRBody
})
- name: Create the PR for cherry pick branch core.setOutput( 'cherry-pick-pr', pr.data.html_url )
id: cherry-pick-pr
uses: actions/github-script@v6
with:
script: |
let cherryPickPRBody = "This PR cherry-picks the following PRs into the release branch:\n";
cherryPickPRBody = `${cherryPickPRBody}` + `* #${{ needs.prep.outputs.pr }}` + "\n"; - name: Checkout trunk branch
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
run: git checkout trunk
const pr = await github.rest.pulls.create({ - name: Create a branch based on trunk branch
owner: context.repo.owner, if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
repo: context.repo.repo, run: git checkout -b delete-changelogs/${{ needs.prep.outputs.pr }}
title: "Cherry pick ${{ needs.prep.outputs.pr }} into ${{ needs.prep.outputs.release }}",
head: "cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }}",
base: "${{ needs.prep.outputs.release }}",
body: cherryPickPRBody
})
console.log( `::set-output name=cherry-pick-pr::${ pr.data.html_url }` ) - name: Delete changelogs from trunk
- name: Checkout trunk branch if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }}
run: git checkout trunk
- name: Create a branch based on trunk branch - name: Commit changes for deletion
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
run: git checkout -b delete-changelogs/${{ needs.prep.outputs.pr }} run: git commit --no-verify -am "Delete changelog files for ${{ needs.prep.outputs.pr }}"
- name: Delete changelogs from trunk - name: Push deletion branch up
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }} run: git push origin delete-changelogs/${{ needs.prep.outputs.pr }}
- name: Commit changes for deletion - name: Create the PR for deletion branch
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null id: deletion-pr
run: git commit --no-verify -am "Delete changelog files for ${{ needs.prep.outputs.pr }}" if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
uses: actions/github-script@v6
with:
script: |
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "Delete changelog files based on PR ${{ needs.prep.outputs.pr }}",
head: "delete-changelogs/${{ needs.prep.outputs.pr }}",
base: "trunk",
body: "Delete changelog files based on PR #${{ needs.prep.outputs.pr }}"
})
- name: Push deletion branch up core.setOutput( 'deletion-pr', pr.data.html_url )
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
run: git push origin delete-changelogs/${{ needs.prep.outputs.pr }}
- name: Create the PR for deletion branch - name: Notify Slack on failure
id: deletion-pr if: ${{ failure() && inputs.skipSlackPing != true }}
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null uses: archive/github-actions-slack@v2.0.0
uses: actions/github-script@v6 with:
with: slack-bot-user-oauth-access-token: ${{ secrets.CODE_FREEZE_BOT_TOKEN }}
script: | slack-channel: ${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}
const pr = await github.rest.pulls.create({ slack-text: |
owner: context.repo.owner, :warning-8c: Code freeze violation. PR(s) created that breaks the Code Freeze for '${{ needs.prep.outputs.release }}' :ice_cube:
repo: context.repo.repo,
title: "Delete changelog files based on PR ${{ needs.prep.outputs.pr }}",
head: "delete-changelogs/${{ needs.prep.outputs.pr }}",
base: "trunk",
body: "Delete changelog files based on PR #${{ needs.prep.outputs.pr }}"
})
console.log( `::set-output name=deletion-pr::${ pr.data.html_url }` ) An attempt to cherry pick PR(s) into outgoing release '${{ needs.prep.outputs.release }}' has failed. This could be due to a merge conflict or something else that requires manual attention. Please check: https://github.com/woocommerce/woocommerce/pull/${{ needs.prep.outputs.pr }}
- name: Notify Slack on failure - name: Notify Slack on success
if: ${{ failure() && inputs.skipSlackPing != true }} if: ${{ success() && inputs.skipSlackPing != true }}
uses: archive/github-actions-slack@v2.0.0 uses: archive/github-actions-slack@v2.0.0
with: with:
slack-bot-user-oauth-access-token: ${{ secrets.CODE_FREEZE_BOT_TOKEN }} slack-bot-user-oauth-access-token: ${{ secrets.CODE_FREEZE_BOT_TOKEN }}
slack-channel: ${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }} slack-channel: ${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}
slack-text: | slack-text: |
:warning-8c: Code freeze violation. PR(s) created that breaks the Code Freeze for '${{ needs.prep.outputs.release }}' :ice_cube: :warning-8c: Code freeze violation. PR(s) created that breaks the Code Freeze for '${{ needs.prep.outputs.release }}' :ice_cube:
An attempt to cherry pick PR(s) into outgoing release '${{ needs.prep.outputs.release }}' has failed. This could be due to a merge conflict or something else that requires manual attention. Please check: https://github.com/woocommerce/woocommerce/pull/${{ needs.prep.outputs.pr }} Release lead please review:
- name: Notify Slack on success ${{ steps.cherry-pick-pr.outputs.cherry-pick-pr }}
if: ${{ success() && inputs.skipSlackPing != true }} ${{ steps.deletion-pr.outputs.deletion-pr }}
uses: archive/github-actions-slack@v2.0.0
with:
slack-bot-user-oauth-access-token: ${{ secrets.CODE_FREEZE_BOT_TOKEN }}
slack-channel: ${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}
slack-text: |
:warning-8c: Code freeze violation. PR(s) created that breaks the Code Freeze for '${{ needs.prep.outputs.release }}' :ice_cube:
Release lead please review:
${{ steps.cherry-pick-pr.outputs.cherry-pick-pr }}
${{ steps.deletion-pr.outputs.deletion-pr }}

View File

@ -1,38 +1,40 @@
name: Add Community Label name: Add Community Label
on: on:
pull_request_target: pull_request_target:
types: [opened] types: [opened]
issues: issues:
types: [opened] types: [opened]
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
verify: verify:
name: Verify name: Verify
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93 uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
- name: Install Octokit - name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action run: npm --prefix .github/workflows/scripts install @octokit/action
- name: Check if user is a community contributor - name: Install Actions Core
id: check run: npm --prefix .github/workflows/scripts install @actions/core
run: node .github/workflows/scripts/is-community-contributor.js
env: - name: Check if user is a community contributor
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} id: check
run: node .github/workflows/scripts/is-community-contributor.js
- name: "If community PR, assign a reviewer" env:
if: github.event.pull_request && steps.check.outputs.is-community == 'yes' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: shufo/auto-assign-reviewer-by-files@f5f3db9ef06bd72ab6978996988c6462cbdaabf6
with: - name: 'If community PR, assign a reviewer'
config: ".github/project-community-pr-assigner.yml" if: github.event.pull_request && steps.check.outputs.is-community == 'yes'
token: ${{ secrets.PR_ASSIGN_TOKEN }} uses: shufo/auto-assign-reviewer-by-files@f5f3db9ef06bd72ab6978996988c6462cbdaabf6
with:
config: '.github/project-community-pr-assigner.yml'
token: ${{ secrets.PR_ASSIGN_TOKEN }}

View File

@ -1,7 +1,7 @@
name: Run post release processes name: Run post release processes
on: on:
release: release:
types: [released] types: [released]
env: env:
GIT_COMMITTER_NAME: 'WooCommerce Bot' GIT_COMMITTER_NAME: 'WooCommerce Bot'
@ -10,119 +10,119 @@ env:
GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com' GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com'
jobs: jobs:
changelog-version-update: changelog-version-update:
name: Update changelog and version name: Update changelog and version
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Git fetch trunk branch - name: Git fetch trunk branch
run: git fetch origin trunk run: git fetch origin trunk
- name: Copy readme.txt to vm root - name: Copy readme.txt to vm root
run: cp ./plugins/woocommerce/readme.txt ../../readme.txt run: cp ./plugins/woocommerce/readme.txt ../../readme.txt
- name: Switch to trunk branch - name: Switch to trunk branch
run: git checkout trunk run: git checkout trunk
- name: Create a new branch based on trunk - name: Create a new branch based on trunk
run: git checkout -b prep/post-release-tasks-${{ github.event.release.tag_name }} run: git checkout -b prep/post-release-tasks-${{ github.event.release.tag_name }}
- name: Check if we need to continue processing - name: Check if we need to continue processing
uses: actions/github-script@v6 uses: actions/github-script@v6
id: check id: check
with: with:
script: | script: |
const fs = require( 'node:fs' ); const fs = require( 'node:fs' );
const version = ${{ toJSON( github.event.release.tag_name ) }} const version = ${{ toJSON( github.event.release.tag_name ) }}
fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) { fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) {
if ( err ) { if ( err ) {
console.error( err ); console.error( err );
} }
const regex = /Stable\stag:\s(\d+\.\d+\.\d+)/; const regex = /Stable\stag:\s(\d+\.\d+\.\d+)/;
const stableVersion = data.match( regex )[1]; const stableVersion = data.match( regex )[1];
// If the release version is less than stable version we can bail. // If the release version is less than stable version we can bail.
if ( version.localeCompare( stableVersion, undefined, { numeric: true, sensitivity: 'base' } ) == -1 ) { if ( version.localeCompare( stableVersion, undefined, { numeric: true, sensitivity: 'base' } ) == -1 ) {
console.log( 'Release version is less than stable version. No automated action taken. A manual process is required.' ); console.log( 'Release version is less than stable version. No automated action taken. A manual process is required.' );
console.log( `::set-output name=continue::false` ) core.setOutput( 'continue', 'false' )
return; return;
} else { } else {
console.log( `::set-output name=continue::true` ) core.setOutput( 'continue', 'true' )
} }
} ) } )
- name: Update changelog.txt entries - name: Update changelog.txt entries
uses: actions/github-script@v6 uses: actions/github-script@v6
id: update-entries id: update-entries
if: steps.check.outputs.continue == 'true' if: steps.check.outputs.continue == 'true'
with: with:
script: | script: |
const fs = require( 'node:fs' ); const fs = require( 'node:fs' );
const version = ${{ toJSON( github.event.release.tag_name ) }} const version = ${{ toJSON( github.event.release.tag_name ) }}
// Read the saved readme.txt file from earlier. // Read the saved readme.txt file from earlier.
fs.readFile( '../../readme.txt', 'utf-8', function( err, readme ) { fs.readFile( '../../readme.txt', 'utf-8', function( err, readme ) {
if ( err ) { if ( err ) {
console.log( `::set-output name=continue::false` ) core.setOutput( 'continue', 'false' );
console.error( err ); console.error( err );
} }
const regex = /(== Changelog ==[\s\S]+)\s{2}\[See changelog for all versions\]\(https:\/\/raw\.githubusercontent\.com\/woocommerce\/woocommerce\/trunk\/changelog\.txt\)\./; const regex = /(== Changelog ==[\s\S]+)\s{2}\[See changelog for all versions\]\(https:\/\/raw\.githubusercontent\.com\/woocommerce\/woocommerce\/trunk\/changelog\.txt\)\./;
const entries = readme.match( regex )[1]; const entries = readme.match( regex )[1];
fs.readFile( './changelog.txt', 'utf-8', function( err, changelog ) { fs.readFile( './changelog.txt', 'utf-8', function( err, changelog ) {
if ( err ) { if ( err ) {
console.log( `::set-output name=continue::false` ) core.setOutput( 'continue', 'false' );
console.error( err ); console.error( err );
} }
const regex = /== Changelog ==/; const regex = /== Changelog ==/;
const updatedChangelog = changelog.replace( regex, entries ); const updatedChangelog = changelog.replace( regex, entries );
fs.writeFile( './changelog.txt', updatedChangelog, err => { fs.writeFile( './changelog.txt', updatedChangelog, err => {
if ( err ) { if ( err ) {
console.log( `::set-output name=continue::false` ) core.setOutput( 'continue', 'false' );
console.error( 'Unable to update changelog entries in changelog.txt' ); console.error( 'Unable to update changelog entries in changelog.txt' );
} }
console.log( `::set-output name=continue::true` ) core.setOutput( 'continue', 'true' );
} ) } )
} ) } )
} ) } )
- name: Commit changes - name: Commit changes
if: steps.update-entries.outputs.continue == 'true' if: steps.update-entries.outputs.continue == 'true'
run: git commit -am "Prep trunk post release ${{ github.event.release.tag_name }}" run: git commit -am "Prep trunk post release ${{ github.event.release.tag_name }}"
- name: Push branch up - name: Push branch up
if: steps.update-entries.outputs.continue == 'true' if: steps.update-entries.outputs.continue == 'true'
run: git push origin prep/post-release-tasks-${{ github.event.release.tag_name }} run: git push origin prep/post-release-tasks-${{ github.event.release.tag_name }}
- name: Create the PR - name: Create the PR
if: steps.update-entries.outputs.continue == 'true' if: steps.update-entries.outputs.continue == 'true'
uses: actions/github-script@v6 uses: actions/github-script@v6
with: with:
script: | script: |
const body = "This PR updates the changelog.txt entries based on the latest release: ${{ github.event.release.tag_name }}" const body = "This PR updates the changelog.txt entries based on the latest release: ${{ github.event.release.tag_name }}"
const pr = await github.rest.pulls.create({ const pr = await github.rest.pulls.create({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
title: "Update changelog.txt from release ${{ github.event.release.tag_name }}", title: "Update changelog.txt from release ${{ github.event.release.tag_name }}",
head: "prep/post-release-tasks-${{ github.event.release.tag_name }}", head: "prep/post-release-tasks-${{ github.event.release.tag_name }}",
base: "trunk", base: "trunk",
body: body body: body
}) })
const prCreated = await github.rest.pulls.requestReviewers({ const prCreated = await github.rest.pulls.requestReviewers({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
pull_number: pr.data.number, pull_number: pr.data.number,
reviewers: ["${{ github.event.release.author.login }}"] reviewers: ["${{ github.event.release.author.login }}"]
}) })

View File

@ -12,8 +12,8 @@ jobs:
name: Runs E2E tests. name: Runs E2E tests.
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
env: env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-report ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-report
outputs: outputs:
E2E_GRAND_TOTAL: ${{ steps.count_e2e_total.outputs.E2E_GRAND_TOTAL }} E2E_GRAND_TOTAL: ${{ steps.count_e2e_total.outputs.E2E_GRAND_TOTAL }}
steps: steps:
@ -80,8 +80,8 @@ jobs:
name: Runs API tests. name: Runs API tests.
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
env: env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-report
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3

View File

@ -1,7 +1,7 @@
name: 'Release: Code freeze' name: 'Release: Code freeze'
on: on:
schedule: schedule:
- cron: '0 16 * * 4' # Run at 1600 UTC on Thursdays. - cron: '0 23 * * 1' # Run at 2300 UTC on Mondays.
workflow_dispatch: workflow_dispatch:
inputs: inputs:
timeOverride: timeOverride:
@ -42,12 +42,12 @@ jobs:
$now = strtotime( getenv( 'TIME_OVERRIDE' ) ); $now = strtotime( getenv( 'TIME_OVERRIDE' ) );
} }
// Code freeze comes 26 days prior to release day. // Code freeze comes 22 days prior to release day.
$release_time = strtotime( '+26 days', $now ); $release_time = strtotime( '+22 days', $now );
$release_day_of_week = date( 'l', $release_time ); $release_day_of_week = date( 'l', $release_time );
$release_day_of_month = (int) date( 'j', $release_time ); $release_day_of_month = (int) date( 'j', $release_time );
// If 26 days from now isn't the second Tuesday, then it's not code freeze day. // If 22 days from now isn't the second Tuesday, then it's not code freeze day.
if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) { if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=1\n", FILE_APPEND ); file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=1\n", FILE_APPEND );
} else { } else {
@ -163,7 +163,7 @@ jobs:
workflow_id: 'release-changelog.yml', workflow_id: 'release-changelog.yml',
ref: 'trunk', ref: 'trunk',
inputs: { inputs: {
releaseVersion: "release/${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}", releaseVersion: "${{ needs.maybe-create-next-milestone-and-release-branch.outputs.release_version }}",
releaseBranch: "${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}" releaseBranch: "${{ needs.maybe-create-next-milestone-and-release-branch.outputs.branch }}"
} }
}) })

View File

@ -1,50 +1,62 @@
// Note you'll need to install this dependency as part of your workflow. // Note you'll need to install these dependencies as part of your workflow.
const { Octokit } = require('@octokit/action'); const { Octokit } = require( '@octokit/action' );
const core = require( '@actions/core' );
// Note that this script assumes you set GITHUB_TOKEN in env, if you don't // Note that this script assumes you set GITHUB_TOKEN in env, if you don't
// this won't work. // this won't work.
const octokit = new Octokit(); const octokit = new Octokit();
const getIssueAuthor = (payload) => { const getIssueAuthor = ( payload ) => {
return payload?.issue?.user?.login || payload?.pull_request?.user?.login || null; return (
} payload?.issue?.user?.login ||
payload?.pull_request?.user?.login ||
null
);
};
const isCommunityContributor = async (owner, repo, username) => { const isCommunityContributor = async ( owner, repo, username ) => {
if (username) { if ( username ) {
const {data: {permission}} = await octokit.rest.repos.getCollaboratorPermissionLevel({ const {
data: { permission },
} = await octokit.rest.repos.getCollaboratorPermissionLevel( {
owner, owner,
repo, repo,
username, username,
}); } );
return permission === 'read' || permission === 'none'; return permission === 'read' || permission === 'none';
} }
return false; return false;
} };
const addLabel = async(label, owner, repo, issueNumber) => { const addLabel = async ( label, owner, repo, issueNumber ) => {
await octokit.rest.issues.addLabels({ await octokit.rest.issues.addLabels( {
owner, owner,
repo, repo,
issue_number: issueNumber, issue_number: issueNumber,
labels: [label], labels: [ label ],
}); } );
} };
const applyLabelToCommunityContributor = async () => { const applyLabelToCommunityContributor = async () => {
const eventPayload = require(process.env.GITHUB_EVENT_PATH); const eventPayload = require( process.env.GITHUB_EVENT_PATH );
const username = getIssueAuthor(eventPayload); const username = getIssueAuthor( eventPayload );
const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); const [ owner, repo ] = process.env.GITHUB_REPOSITORY.split( '/' );
const { number } = eventPayload?.issue || eventPayload?.pull_request; const { number } = eventPayload?.issue || eventPayload?.pull_request;
const isCommunityUser = await isCommunityContributor(owner, repo, username); const isCommunityUser = await isCommunityContributor(
console.log( '::set-output name=is-community::%s', isCommunityUser ? 'yes' : 'no' ); owner,
repo,
if (isCommunityUser) { username
console.log('Adding community contributor label'); );
await addLabel('type: community contribution', owner, repo, number);
core.setOutput( 'is-community', isCommunityUser ? 'yes' : 'no' );
if ( isCommunityUser ) {
console.log( 'Adding community contributor label' );
await addLabel( 'type: community contribution', owner, repo, number );
} }
} };
applyLabelToCommunityContributor(); applyLabelToCommunityContributor();

View File

@ -23,14 +23,14 @@ function set_output( $name, $value ) {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "{$name}={$value}" . PHP_EOL, FILE_APPEND ); file_put_contents( getenv( 'GITHUB_OUTPUT' ), "{$name}={$value}" . PHP_EOL, FILE_APPEND );
} }
// Code freeze comes 26 days prior to release day. // Code freeze comes 22 days prior to release day.
$release_time = strtotime( '+26 days', $now ); $release_time = strtotime( '+22 days', $now );
$release_day_of_week = date( 'l', $release_time ); $release_day_of_week = date( 'l', $release_time );
$release_day_of_month = (int) date( 'j', $release_time ); $release_day_of_month = (int) date( 'j', $release_time );
// If 26 days from now isn't the second Tuesday, then it's not code freeze day. // If 22 days from now isn't the second Tuesday, then it's not code freeze day.
if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) { if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) {
echo 'Info: Today is not the Thursday of the code freeze.' . PHP_EOL; echo 'Info: Today is not the Monday of the code freeze.' . PHP_EOL;
exit( 1 ); exit( 1 );
} }

View File

@ -55,7 +55,8 @@ jobs:
working-directory: plugins/woocommerce working-directory: plugins/woocommerce
env: env:
E2E_MAX_FAILURES: 25 E2E_MAX_FAILURES: 25
run: pnpm exec playwright test --config=tests/e2e-pw/daily.playwright.config.js RESET_SITE: true
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js
- name: Generate Playwright E2E Test report. - name: Generate Playwright E2E Test report.
if: success() || failure() if: success() || failure()
@ -79,8 +80,8 @@ jobs:
needs: [e2e-tests] needs: [e2e-tests]
if: success() || failure() if: success() || failure()
env: env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-report
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
@ -163,7 +164,7 @@ jobs:
A_PW: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }} A_PW: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }}
C_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }} C_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }}
C_PW: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }} C_PW: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }}
P_ID: 274 P_ID: 22733
run: | run: |
./k6 run plugins/woocommerce/tests/performance/tests/gh-action-daily-ext-requests.js ./k6 run plugins/woocommerce/tests/performance/tests/gh-action-daily-ext-requests.js
@ -219,7 +220,7 @@ jobs:
working-directory: plugins/woocommerce working-directory: plugins/woocommerce
env: env:
E2E_MAX_FAILURES: 15 E2E_MAX_FAILURES: 15
run: pnpm exec playwright test --config=tests/e2e-pw/daily.playwright.config.js run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js
- name: Generate E2E Test report. - name: Generate E2E Test report.
if: success() || failure() if: success() || failure()

11
.gitignore vendored
View File

@ -72,8 +72,8 @@ yarn.lock
# Editors # Editors
nbproject/private/ nbproject/private/
# Test Results # E2E and API Test Results
test-results.json test-results
# Admin Feature config # Admin Feature config
plugins/woocommerce/includes/react-admin/feature-config.php plugins/woocommerce/includes/react-admin/feature-config.php
@ -89,13 +89,6 @@ allure-results
changes.json changes.json
.env .env
# Playwright output & working files
/plugins/woocommerce/tests/e2e-pw/output
/plugins/woocommerce/tests/e2e-pw/report
/plugins/woocommerce/tests/e2e-pw/storage
/plugins/woocommerce/tests/e2e-pw/test-results.json
/plugins/woocommerce/tests/api-core-tests/output
# Turborepo # Turborepo
.turbo .turbo

View File

@ -1,5 +1,254 @@
== Changelog == == Changelog ==
= 7.2.2 2022-12-21 =
** WooCommerce**
* Fix - Corrects a hard-coded reference to the WP post meta table within the HPOS Migration Helper, that would fail on some sites. [#36100](https://github.com/woocommerce/woocommerce/pull/36100)
= 7.2.1 2022-12-16 =
**WooCommerce**
* Update - Include taxes migration in MigrationHelper::migrate_country_states [#35967](https://github.com/woocommerce/woocommerce/pull/35967)
= 7.2.0 2022-12-14 =
**WooCommerce**
* Fix - Corrects a hard-coded reference to the WP post meta table within the HPOS Migration Helper, that would fail on some sites. [#36100](https://github.com/woocommerce/woocommerce/pull/36100)
* Fix - Drop usage of WP 5.9 function in the product quantity selector template. [#36054](https://github.com/woocommerce/woocommerce/pull/36054)
* Fix - Add a data migration for changed New Zealand and Ukraine state codes [#35669](https://github.com/woocommerce/woocommerce/pull/35669)
* Fix - Fix error in onboarding wizard when plugin is activated but includes unexpected output. [#35866](https://github.com/woocommerce/woocommerce/pull/35866)
* Fix - Increased margin so that overflow modal content doesn't clip header [#35780](https://github.com/woocommerce/woocommerce/pull/35780)
* Fix - Added default additional content to emails via filter woocommerce_email_additional_content_. [#35195](https://github.com/woocommerce/woocommerce/pull/35195)
* Fix - Corrects the currency symbol for Libyan Dinar (LYD). [#35395](https://github.com/woocommerce/woocommerce/pull/35395)
* Fix - Fix 'Invalid payment method' error upon double click on Delete button of Payment methods table [#30884](https://github.com/woocommerce/woocommerce/pull/30884)
* Fix - Fix bg color that was not covering the full page [#35476](https://github.com/woocommerce/woocommerce/pull/35476)
* Fix - Fix class name for class FirstDownlaodableProduct [#35383](https://github.com/woocommerce/woocommerce/pull/35383)
* Fix - Fixed "Unsupported operand types" error. [#34327](https://github.com/woocommerce/woocommerce/pull/34327)
* Fix - Fix inconsistent return type of class WC_Shipping_Rate->get_shipping_tax() [#35453](https://github.com/woocommerce/woocommerce/pull/35453)
* Fix - Fix invalid wcadmin_install_plugin_error event props [#35411](https://github.com/woocommerce/woocommerce/pull/35411)
* Fix - Fix JS error when the business step is accessed directly via URL without completing the previous steps [#35045](https://github.com/woocommerce/woocommerce/pull/35045)
* Fix - fix popper position for in-app marketplace tour [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Fix - Fix WooCommerce icons not loading in the site editor. [#35532](https://github.com/woocommerce/woocommerce/pull/35532)
* Fix - FQCN for WP_Error in PHPDoc. [#35305](https://github.com/woocommerce/woocommerce/pull/35305)
* Fix - Make the user search metabox for orders show the same information for the loaded user and for search results [#35244](https://github.com/woocommerce/woocommerce/pull/35244)
* Fix - Override filter_meta_data method, since it should be a no-op anyway. [#35192](https://github.com/woocommerce/woocommerce/pull/35192)
* Fix - Remove the direct dependency on `$_POST` when validating checkout data. [#35329](https://github.com/woocommerce/woocommerce/pull/35329)
* Fix - Revert change that auto collapses the product short description field. [#35213](https://github.com/woocommerce/woocommerce/pull/35213)
* Fix - Skip flaky settings API test [#35338](https://github.com/woocommerce/woocommerce/pull/35338)
* Fix - Update Playwright from 1.26.1 to 1.27.1 [#35106](https://github.com/woocommerce/woocommerce/pull/35106)
* Fix - When the minimum and maximum quantity are identical, render the quantity input and set it to disabled. [#34282](https://github.com/woocommerce/woocommerce/pull/34282)
* Add - Add "Empty Trash" functionality to HPOS list table. [#35489](https://github.com/woocommerce/woocommerce/pull/35489)
* Add - Add add attribute modal to the attribute field in the new product management MVP [#34999](https://github.com/woocommerce/woocommerce/pull/34999)
* Add - Add add new option for the category dropdown within the product MVP [#35132](https://github.com/woocommerce/woocommerce/pull/35132)
* Add - Add contextual product more menu [#35447](https://github.com/woocommerce/woocommerce/pull/35447)
* Add - Added a guided tour for WooCommerce Extensions page [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - Added npm script for Playwright API Core Tests [#35283](https://github.com/woocommerce/woocommerce/pull/35283)
* Add - Added states for Senegal. [#35199](https://github.com/woocommerce/woocommerce/pull/35199)
* Add - Added the "Tour the WooCommerce Marketplace" task to onboarding tasks list [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - Added Ukrainian subdivisions. [#35493](https://github.com/woocommerce/woocommerce/pull/35493)
* Add - Adding attribute edit modal for new product screen. [#35269](https://github.com/woocommerce/woocommerce/pull/35269)
* Add - Add manual stock management section to product management experience [#35047](https://github.com/woocommerce/woocommerce/pull/35047)
* Add - Add new Category dropdown field to the new Product Management screen. [#34400](https://github.com/woocommerce/woocommerce/pull/34400)
* Add - add new track events for in-app marketplace tour [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - Add option and modal to create new attribute terms within MVP attribute modal. [#35131](https://github.com/woocommerce/woocommerce/pull/35131)
* Add - Add placeholder to description field [#35286](https://github.com/woocommerce/woocommerce/pull/35286)
* Add - Add playwright api-core-tests for data crud operations [#35347](https://github.com/woocommerce/woocommerce/pull/35347)
* Add - Add playwright api-core-tests for payment gateways crud operations [#35279](https://github.com/woocommerce/woocommerce/pull/35279)
* Add - Add playwright api-core-tests for product reviews crud operations [#35163](https://github.com/woocommerce/woocommerce/pull/35163)
* Add - Add playwright api-core-tests for product variations crud operations [#35355](https://github.com/woocommerce/woocommerce/pull/35355)
* Add - Add playwright api-core-tests for reports crud operations [#35388](https://github.com/woocommerce/woocommerce/pull/35388)
* Add - Add playwright api-core-tests for settingss crud operations [#35253](https://github.com/woocommerce/woocommerce/pull/35253)
* Add - Add playwright api-core-tests for system status crud operations [#35254](https://github.com/woocommerce/woocommerce/pull/35254)
* Add - Add playwright api-core-tests for webhooks crud operations [#35292](https://github.com/woocommerce/woocommerce/pull/35292)
* Add - Add Product description title in old editor for clarification. [#35154](https://github.com/woocommerce/woocommerce/pull/35154)
* Add - Add product inventory advanced section [#35164](https://github.com/woocommerce/woocommerce/pull/35164)
* Add - Add product management description to new product management experience [#34961](https://github.com/woocommerce/woocommerce/pull/34961)
* Add - Add product state badge to product form header [#35460](https://github.com/woocommerce/woocommerce/pull/35460)
* Add - Add product title to header when available [#35431](https://github.com/woocommerce/woocommerce/pull/35431)
* Add - Add scheduled sale support to new product edit page. [#34538](https://github.com/woocommerce/woocommerce/pull/34538)
* Add - Adds new Inbox Note to provide more information about WooCommerce Payments to users who dismiss the WCPay promo but say that they want more information in the exit survey. [#35581](https://github.com/woocommerce/woocommerce/pull/35581)
* Add - Add summary to new product page experience [#35201](https://github.com/woocommerce/woocommerce/pull/35201)
* Add - Include order datastore information in status report. [#35487](https://github.com/woocommerce/woocommerce/pull/35487)
* Add - Make it possible to add custom bulk action handling to the admin order list screen (when HPOS is enabled). [#35442](https://github.com/woocommerce/woocommerce/pull/35442)
* Add - Set In-App Marketplace Tour as completed on tour close [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - When custom order tables are not authoritative, admin UI requests will be redirected to the matching legacy order screen as appropriate. [#35463](https://github.com/woocommerce/woocommerce/pull/35463)
* Update - Woo Blocks 8.9.2 [#35805](https://github.com/woocommerce/woocommerce/pull/35805)
* Update - Comment: Update WooCommerce Blocks to 8.7.2 [#35101](https://github.com/woocommerce/woocommerce/pull/35101)
* Update - Comment: Update WooCommerce Blocks to 8.7.3 [#35219](https://github.com/woocommerce/woocommerce/pull/35219)
* Update - Comment: Update WooCommerce Blocks to 8.9.1 [#35564](https://github.com/woocommerce/woocommerce/pull/35564)
* Update - CustomOrdersTableController::custom_orders_table_usage_is_enabled returns now false if the HPOS feature is disabled [#35597](https://github.com/woocommerce/woocommerce/pull/35597)
* Update - Disable inventory stock toggle when product stock management is disabled [#35059](https://github.com/woocommerce/woocommerce/pull/35059)
* Update - Improve the loading time of WooCommerce setup widget for large databases [#35334](https://github.com/woocommerce/woocommerce/pull/35334)
* Update - Permit showing a guided tour for WooCommerce Extensions page on desktops only [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Update - Remove adding and managing products note [#35319](https://github.com/woocommerce/woocommerce/pull/35319)
* Update - Remove first downloadable product note [#35318](https://github.com/woocommerce/woocommerce/pull/35318)
* Update - Remove InsightFirstProductAndPayment note [#35309](https://github.com/woocommerce/woocommerce/pull/35309)
* Update - Remove insight on first sale note [#35341](https://github.com/woocommerce/woocommerce/pull/35341)
* Update - Remove manage store activity note [#35320](https://github.com/woocommerce/woocommerce/pull/35320)
* Update - Remove Popover.Slot usage and make use of exported SelectControlMenuSlot. [#35353](https://github.com/woocommerce/woocommerce/pull/35353)
* Update - Remove update store details note [#35322](https://github.com/woocommerce/woocommerce/pull/35322)
* Update - Update Array checks in playwright api-core-tests as some of the existing tests would produce false positives [#35462](https://github.com/woocommerce/woocommerce/pull/35462)
* Update - Update playwright api-core-tests for shipping crud operations [#35332](https://github.com/woocommerce/woocommerce/pull/35332)
* Update - Update playwright api-core-tests to execute for both base test environment and base JN environment with WooCommerce [#35522](https://github.com/woocommerce/woocommerce/pull/35522)
* Update - Update products task list UI [#35611](https://github.com/woocommerce/woocommerce/pull/35611)
* Update - Update ShippingLabelBanner add_meta_box action to only trigger on shop_order pages and remove deprecated function call. [#35212](https://github.com/woocommerce/woocommerce/pull/35212)
* Update - Update WooCommerce Blocks to 8.9.0 [#35521](https://github.com/woocommerce/woocommerce/pull/35521)
* Dev - Add variation price shortcut [#34948](https://github.com/woocommerce/woocommerce/pull/34948)
* Dev - Cleanup and deprecate unused Task properties and methods [#35450](https://github.com/woocommerce/woocommerce/pull/35450)
* Dev - Enable Playwright tests on Daily Smoke Test workflow and upload its Allure reports to S3 bucket. [#35114](https://github.com/woocommerce/woocommerce/pull/35114)
* Dev - Move product action buttons to header menu [#35214](https://github.com/woocommerce/woocommerce/pull/35214)
* Dev - Revert the changes introduced in PR #35282 [#35337](https://github.com/woocommerce/woocommerce/pull/35337)
* Dev - Show a dismissible snackbar if the server responds with an error [#35160](https://github.com/woocommerce/woocommerce/pull/35160)
* Dev - Update api-core-tests readme for consistency with new command and updates to other commands too. [#35303](https://github.com/woocommerce/woocommerce/pull/35303)
* Dev - Updated the COT plugin URL now that this feature can be enabled in a different way. [#34990](https://github.com/woocommerce/woocommerce/pull/34990)
* Dev - Update the list of tags for WC plugin on .org [#35573](https://github.com/woocommerce/woocommerce/pull/35573)
* Dev - Update unit test install script for db sockets. [#35152](https://github.com/woocommerce/woocommerce/pull/35152)
* Dev - Use plugins/woocommerce/tests/e2e-pw folder for saving test outputs [#35206](https://github.com/woocommerce/woocommerce/pull/35206)
* Dev - Uses the globa-setup.js to setup permalinks structure [#35282](https://github.com/woocommerce/woocommerce/pull/35282)
* Tweak - Move HPOS hook woocommerce_before_delete_order before deleting order. [#35517](https://github.com/woocommerce/woocommerce/pull/35517)
* Tweak - Adds new filter `woocommerce_get_customer_payment_tokens_limit` to set limit on number of payment methods fetched within the My Account page. [#29850](https://github.com/woocommerce/woocommerce/pull/29850)
* Tweak - Add source parameter for calls to the subscriptions endpoint on WooCommerce.com [#35051](https://github.com/woocommerce/woocommerce/pull/35051)
* Tweak - Fix @version header in form-login.php [#35479](https://github.com/woocommerce/woocommerce/pull/35479)
* Tweak - Move HPOS hook woocommerce_before_delete_order before deleting order. [#35517](https://github.com/woocommerce/woocommerce/pull/35517)
* Tweak - typo fix [#35111](https://github.com/woocommerce/woocommerce/pull/35111)
* Tweak - Unwrap product page input props and pass via getInputProps [#35034](https://github.com/woocommerce/woocommerce/pull/35034)
* Tweak - Updates the currency symbol used for the Azerbaijani manat. [#30605](https://github.com/woocommerce/woocommerce/pull/30605)
* Tweak - Use new Tooltip component instead of EnrichedLabel [#35024](https://github.com/woocommerce/woocommerce/pull/35024)
* Enhancement - Change the product info section title to Product Details [#35255](https://github.com/woocommerce/woocommerce/pull/35255)
* Enhancement - Fix the display of letter descenders in the shipping class dropdown menu [#35258](https://github.com/woocommerce/woocommerce/pull/35258)
* Enhancement - Improve the communication around required and optional [#35266](https://github.com/woocommerce/woocommerce/pull/35266)
* Enhancement - Increase the spacing between the shipping box illustration and the dimensions fields [#35259](https://github.com/woocommerce/woocommerce/pull/35259)
* Enhancement - Optimize query usage in the Onboarding tasks [#35065](https://github.com/woocommerce/woocommerce/pull/35065)
* Enhancement - Remove some placeholder values [#35267](https://github.com/woocommerce/woocommerce/pull/35267)
* Enhancement - Replace the trash can icon in the attribute list [#35133](https://github.com/woocommerce/woocommerce/pull/35133)
* Enhancement - Select the current new added shipping class [#35123](https://github.com/woocommerce/woocommerce/pull/35123)
* Enhancement - Tweaks the PR template for GitHub pull requests [#34597](https://github.com/woocommerce/woocommerce/pull/34597)
= 7.2.1 2022-12-16 =
**WooCommerce**
* Update - Include taxes migration in MigrationHelper::migrate_country_states [#35967](https://github.com/woocommerce/woocommerce/pull/35967)
= 7.2.0 2022-12-14 =
**WooCommerce**
* Fix - Drop usage of WP 5.9 function in the product quantity selector template. [#36054](https://github.com/woocommerce/woocommerce/pull/36054)
* Fix - Add a data migration for changed New Zealand and Ukraine state codes [#35669](https://github.com/woocommerce/woocommerce/pull/35669)
* Fix - Fix error in onboarding wizard when plugin is activated but includes unexpected output. [#35866](https://github.com/woocommerce/woocommerce/pull/35866)
* Fix - Increased margin so that overflow modal content doesn't clip header [#35780](https://github.com/woocommerce/woocommerce/pull/35780)
* Fix - Added default additional content to emails via filter woocommerce_email_additional_content_. [#35195](https://github.com/woocommerce/woocommerce/pull/35195)
* Fix - Corrects the currency symbol for Libyan Dinar (LYD). [#35395](https://github.com/woocommerce/woocommerce/pull/35395)
* Fix - Fix 'Invalid payment method' error upon double click on Delete button of Payment methods table [#30884](https://github.com/woocommerce/woocommerce/pull/30884)
* Fix - Fix bg color that was not covering the full page [#35476](https://github.com/woocommerce/woocommerce/pull/35476)
* Fix - Fix class name for class FirstDownlaodableProduct [#35383](https://github.com/woocommerce/woocommerce/pull/35383)
* Fix - Fixed "Unsupported operand types" error. [#34327](https://github.com/woocommerce/woocommerce/pull/34327)
* Fix - Fix inconsistent return type of class WC_Shipping_Rate->get_shipping_tax() [#35453](https://github.com/woocommerce/woocommerce/pull/35453)
* Fix - Fix invalid wcadmin_install_plugin_error event props [#35411](https://github.com/woocommerce/woocommerce/pull/35411)
* Fix - Fix JS error when the business step is accessed directly via URL without completing the previous steps [#35045](https://github.com/woocommerce/woocommerce/pull/35045)
* Fix - fix popper position for in-app marketplace tour [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Fix - Fix WooCommerce icons not loading in the site editor. [#35532](https://github.com/woocommerce/woocommerce/pull/35532)
* Fix - FQCN for WP_Error in PHPDoc. [#35305](https://github.com/woocommerce/woocommerce/pull/35305)
* Fix - Make the user search metabox for orders show the same information for the loaded user and for search results [#35244](https://github.com/woocommerce/woocommerce/pull/35244)
* Fix - Override filter_meta_data method, since it should be a no-op anyway. [#35192](https://github.com/woocommerce/woocommerce/pull/35192)
* Fix - Remove the direct dependency on `$_POST` when validating checkout data. [#35329](https://github.com/woocommerce/woocommerce/pull/35329)
* Fix - Revert change that auto collapses the product short description field. [#35213](https://github.com/woocommerce/woocommerce/pull/35213)
* Fix - Skip flaky settings API test [#35338](https://github.com/woocommerce/woocommerce/pull/35338)
* Fix - Update Playwright from 1.26.1 to 1.27.1 [#35106](https://github.com/woocommerce/woocommerce/pull/35106)
* Fix - When the minimum and maximum quantity are identical, render the quantity input and set it to disabled. [#34282](https://github.com/woocommerce/woocommerce/pull/34282)
* Add - Add "Empty Trash" functionality to HPOS list table. [#35489](https://github.com/woocommerce/woocommerce/pull/35489)
* Add - Add add attribute modal to the attribute field in the new product management MVP [#34999](https://github.com/woocommerce/woocommerce/pull/34999)
* Add - Add add new option for the category dropdown within the product MVP [#35132](https://github.com/woocommerce/woocommerce/pull/35132)
* Add - Add contextual product more menu [#35447](https://github.com/woocommerce/woocommerce/pull/35447)
* Add - Added a guided tour for WooCommerce Extensions page [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - Added npm script for Playwright API Core Tests [#35283](https://github.com/woocommerce/woocommerce/pull/35283)
* Add - Added states for Senegal. [#35199](https://github.com/woocommerce/woocommerce/pull/35199)
* Add - Added the "Tour the WooCommerce Marketplace" task to onboarding tasks list [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - Added Ukrainian subdivisions. [#35493](https://github.com/woocommerce/woocommerce/pull/35493)
* Add - Adding attribute edit modal for new product screen. [#35269](https://github.com/woocommerce/woocommerce/pull/35269)
* Add - Add manual stock management section to product management experience [#35047](https://github.com/woocommerce/woocommerce/pull/35047)
* Add - Add new Category dropdown field to the new Product Management screen. [#34400](https://github.com/woocommerce/woocommerce/pull/34400)
* Add - add new track events for in-app marketplace tour [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - Add option and modal to create new attribute terms within MVP attribute modal. [#35131](https://github.com/woocommerce/woocommerce/pull/35131)
* Add - Add placeholder to description field [#35286](https://github.com/woocommerce/woocommerce/pull/35286)
* Add - Add playwright api-core-tests for data crud operations [#35347](https://github.com/woocommerce/woocommerce/pull/35347)
* Add - Add playwright api-core-tests for payment gateways crud operations [#35279](https://github.com/woocommerce/woocommerce/pull/35279)
* Add - Add playwright api-core-tests for product reviews crud operations [#35163](https://github.com/woocommerce/woocommerce/pull/35163)
* Add - Add playwright api-core-tests for product variations crud operations [#35355](https://github.com/woocommerce/woocommerce/pull/35355)
* Add - Add playwright api-core-tests for reports crud operations [#35388](https://github.com/woocommerce/woocommerce/pull/35388)
* Add - Add playwright api-core-tests for settingss crud operations [#35253](https://github.com/woocommerce/woocommerce/pull/35253)
* Add - Add playwright api-core-tests for system status crud operations [#35254](https://github.com/woocommerce/woocommerce/pull/35254)
* Add - Add playwright api-core-tests for webhooks crud operations [#35292](https://github.com/woocommerce/woocommerce/pull/35292)
* Add - Add Product description title in old editor for clarification. [#35154](https://github.com/woocommerce/woocommerce/pull/35154)
* Add - Add product inventory advanced section [#35164](https://github.com/woocommerce/woocommerce/pull/35164)
* Add - Add product management description to new product management experience [#34961](https://github.com/woocommerce/woocommerce/pull/34961)
* Add - Add product state badge to product form header [#35460](https://github.com/woocommerce/woocommerce/pull/35460)
* Add - Add product title to header when available [#35431](https://github.com/woocommerce/woocommerce/pull/35431)
* Add - Add scheduled sale support to new product edit page. [#34538](https://github.com/woocommerce/woocommerce/pull/34538)
* Add - Adds new Inbox Note to provide more information about WooCommerce Payments to users who dismiss the WCPay promo but say that they want more information in the exit survey. [#35581](https://github.com/woocommerce/woocommerce/pull/35581)
* Add - Add summary to new product page experience [#35201](https://github.com/woocommerce/woocommerce/pull/35201)
* Add - Include order datastore information in status report. [#35487](https://github.com/woocommerce/woocommerce/pull/35487)
* Add - Make it possible to add custom bulk action handling to the admin order list screen (when HPOS is enabled). [#35442](https://github.com/woocommerce/woocommerce/pull/35442)
* Add - Set In-App Marketplace Tour as completed on tour close [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Add - When custom order tables are not authoritative, admin UI requests will be redirected to the matching legacy order screen as appropriate. [#35463](https://github.com/woocommerce/woocommerce/pull/35463)
* Update - Woo Blocks 8.9.2 [#35805](https://github.com/woocommerce/woocommerce/pull/35805)
* Update - Comment: Update WooCommerce Blocks to 8.7.2 [#35101](https://github.com/woocommerce/woocommerce/pull/35101)
* Update - Comment: Update WooCommerce Blocks to 8.7.3 [#35219](https://github.com/woocommerce/woocommerce/pull/35219)
* Update - Comment: Update WooCommerce Blocks to 8.9.1 [#35564](https://github.com/woocommerce/woocommerce/pull/35564)
* Update - CustomOrdersTableController::custom_orders_table_usage_is_enabled returns now false if the HPOS feature is disabled [#35597](https://github.com/woocommerce/woocommerce/pull/35597)
* Update - Disable inventory stock toggle when product stock management is disabled [#35059](https://github.com/woocommerce/woocommerce/pull/35059)
* Update - Improve the loading time of WooCommerce setup widget for large databases [#35334](https://github.com/woocommerce/woocommerce/pull/35334)
* Update - Permit showing a guided tour for WooCommerce Extensions page on desktops only [#35278](https://github.com/woocommerce/woocommerce/pull/35278)
* Update - Remove adding and managing products note [#35319](https://github.com/woocommerce/woocommerce/pull/35319)
* Update - Remove first downloadable product note [#35318](https://github.com/woocommerce/woocommerce/pull/35318)
* Update - Remove InsightFirstProductAndPayment note [#35309](https://github.com/woocommerce/woocommerce/pull/35309)
* Update - Remove insight on first sale note [#35341](https://github.com/woocommerce/woocommerce/pull/35341)
* Update - Remove manage store activity note [#35320](https://github.com/woocommerce/woocommerce/pull/35320)
* Update - Remove Popover.Slot usage and make use of exported SelectControlMenuSlot. [#35353](https://github.com/woocommerce/woocommerce/pull/35353)
* Update - Remove update store details note [#35322](https://github.com/woocommerce/woocommerce/pull/35322)
* Update - Update Array checks in playwright api-core-tests as some of the existing tests would produce false positives [#35462](https://github.com/woocommerce/woocommerce/pull/35462)
* Update - Update playwright api-core-tests for shipping crud operations [#35332](https://github.com/woocommerce/woocommerce/pull/35332)
* Update - Update playwright api-core-tests to execute for both base test environment and base JN environment with WooCommerce [#35522](https://github.com/woocommerce/woocommerce/pull/35522)
* Update - Update products task list UI [#35611](https://github.com/woocommerce/woocommerce/pull/35611)
* Update - Update ShippingLabelBanner add_meta_box action to only trigger on shop_order pages and remove deprecated function call. [#35212](https://github.com/woocommerce/woocommerce/pull/35212)
* Update - Update WooCommerce Blocks to 8.9.0 [#35521](https://github.com/woocommerce/woocommerce/pull/35521)
* Dev - Add variation price shortcut [#34948](https://github.com/woocommerce/woocommerce/pull/34948)
* Dev - Cleanup and deprecate unused Task properties and methods [#35450](https://github.com/woocommerce/woocommerce/pull/35450)
* Dev - Enable Playwright tests on Daily Smoke Test workflow and upload its Allure reports to S3 bucket. [#35114](https://github.com/woocommerce/woocommerce/pull/35114)
* Dev - Move product action buttons to header menu [#35214](https://github.com/woocommerce/woocommerce/pull/35214)
* Dev - Revert the changes introduced in PR #35282 [#35337](https://github.com/woocommerce/woocommerce/pull/35337)
* Dev - Show a dismissible snackbar if the server responds with an error [#35160](https://github.com/woocommerce/woocommerce/pull/35160)
* Dev - Update api-core-tests readme for consistency with new command and updates to other commands too. [#35303](https://github.com/woocommerce/woocommerce/pull/35303)
* Dev - Updated the COT plugin URL now that this feature can be enabled in a different way. [#34990](https://github.com/woocommerce/woocommerce/pull/34990)
* Dev - Update the list of tags for WC plugin on .org [#35573](https://github.com/woocommerce/woocommerce/pull/35573)
* Dev - Update unit test install script for db sockets. [#35152](https://github.com/woocommerce/woocommerce/pull/35152)
* Dev - Use plugins/woocommerce/tests/e2e-pw folder for saving test outputs [#35206](https://github.com/woocommerce/woocommerce/pull/35206)
* Dev - Uses the globa-setup.js to setup permalinks structure [#35282](https://github.com/woocommerce/woocommerce/pull/35282)
* Tweak - Move HPOS hook woocommerce_before_delete_order before deleting order. [#35517](https://github.com/woocommerce/woocommerce/pull/35517)
* Tweak - Adds new filter `woocommerce_get_customer_payment_tokens_limit` to set limit on number of payment methods fetched within the My Account page. [#29850](https://github.com/woocommerce/woocommerce/pull/29850)
* Tweak - Add source parameter for calls to the subscriptions endpoint on WooCommerce.com [#35051](https://github.com/woocommerce/woocommerce/pull/35051)
* Tweak - Fix @version header in form-login.php [#35479](https://github.com/woocommerce/woocommerce/pull/35479)
* Tweak - Move HPOS hook woocommerce_before_delete_order before deleting order. [#35517](https://github.com/woocommerce/woocommerce/pull/35517)
* Tweak - typo fix [#35111](https://github.com/woocommerce/woocommerce/pull/35111)
* Tweak - Unwrap product page input props and pass via getInputProps [#35034](https://github.com/woocommerce/woocommerce/pull/35034)
* Tweak - Updates the currency symbol used for the Azerbaijani manat. [#30605](https://github.com/woocommerce/woocommerce/pull/30605)
* Tweak - Use new Tooltip component instead of EnrichedLabel [#35024](https://github.com/woocommerce/woocommerce/pull/35024)
* Enhancement - Change the product info section title to Product Details [#35255](https://github.com/woocommerce/woocommerce/pull/35255)
* Enhancement - Fix the display of letter descenders in the shipping class dropdown menu [#35258](https://github.com/woocommerce/woocommerce/pull/35258)
* Enhancement - Improve the communication around required and optional [#35266](https://github.com/woocommerce/woocommerce/pull/35266)
* Enhancement - Increase the spacing between the shipping box illustration and the dimensions fields [#35259](https://github.com/woocommerce/woocommerce/pull/35259)
* Enhancement - Optimize query usage in the Onboarding tasks [#35065](https://github.com/woocommerce/woocommerce/pull/35065)
* Enhancement - Remove some placeholder values [#35267](https://github.com/woocommerce/woocommerce/pull/35267)
* Enhancement - Replace the trash can icon in the attribute list [#35133](https://github.com/woocommerce/woocommerce/pull/35133)
* Enhancement - Select the current new added shipping class [#35123](https://github.com/woocommerce/woocommerce/pull/35123)
* Enhancement - Tweaks the PR template for GitHub pull requests [#34597](https://github.com/woocommerce/woocommerce/pull/34597)
= 7.1.1 2022-12-07 = = 7.1.1 2022-12-07 =
**WooCommerce** **WooCommerce**

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding isHidden option for primary button in TourKit component.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add className prop to Sortable

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Add noDataLabel property into table.js component to allow No Data label customization.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Make Table component accept className prop.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add className prop to ListItem.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add aria-label for simple select dropdown

View File

@ -12,19 +12,21 @@ import { SortableHandle } from '../sortable';
export type ListItemProps = { export type ListItemProps = {
children: JSX.Element | JSX.Element[] | string; children: JSX.Element | JSX.Element[] | string;
className?: string;
onDragStart?: DragEventHandler< HTMLDivElement >; onDragStart?: DragEventHandler< HTMLDivElement >;
onDragEnd?: DragEventHandler< HTMLDivElement >; onDragEnd?: DragEventHandler< HTMLDivElement >;
}; };
export const ListItem = ( { export const ListItem = ( {
children, children,
className,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
}: ListItemProps ) => { }: ListItemProps ) => {
const isDraggable = onDragEnd && onDragStart; const isDraggable = onDragEnd && onDragStart;
return ( return (
<div className={ classnames( 'woocommerce-list-item' ) }> <div className={ classnames( 'woocommerce-list-item', className ) }>
{ isDraggable && <SortableHandle /> } { isDraggable && <SortableHandle /> }
{ children } { children }
</div> </div>

View File

@ -154,6 +154,7 @@ class Control extends Component {
: null : null
} }
disabled={ disabled } disabled={ disabled }
aria-label={ this.props.ariaLabel ?? this.props.label }
/> />
); );
} }

View File

@ -241,6 +241,7 @@ Name | Type | Default | Description
`rows` | Array | `null` | (required) An array of arrays of display/value object pairs `rows` | Array | `null` | (required) An array of arrays of display/value object pairs
`rowHeader` | One of type: number, bool | `0` | Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col is checkboxes, for example). Set to false to disable row headers `rowHeader` | One of type: number, bool | `0` | Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col is checkboxes, for example). Set to false to disable row headers
`rowKey` | Function(row, index): string | `null` | Function used to get the row key. `rowKey` | Function(row, index): string | `null` | Function used to get the row key.
`emptyMessage` | String | `undefined` | Customize the message to show when there are no rows in the table.
### `headers` structure ### `headers` structure

View File

@ -153,6 +153,7 @@ class TableCard extends Component {
title, title,
totalRows, totalRows,
rowKey, rowKey,
emptyMessage,
} = this.props; } = this.props;
const { showCols } = this.state; const { showCols } = this.state;
const allHeaders = this.props.headers; const allHeaders = this.props.headers;
@ -237,6 +238,7 @@ class TableCard extends Component {
query={ query } query={ query }
onSort={ onSort || onQueryChange( 'sort' ) } onSort={ onSort || onQueryChange( 'sort' ) }
rowKey={ rowKey } rowKey={ rowKey }
emptyMessage={ emptyMessage }
/> />
) } ) }
</CardBody> </CardBody>
@ -361,6 +363,10 @@ TableCard.propTypes = {
* This uses the index if not defined. * This uses the index if not defined.
*/ */
rowKey: PropTypes.func, rowKey: PropTypes.func,
/**
* Customize the message to show when there are no rows in the table.
*/
emptyMessage: PropTypes.string,
}; };
TableCard.defaultProps = { TableCard.defaultProps = {
@ -372,6 +378,7 @@ TableCard.defaultProps = {
rowHeader: 0, rowHeader: 0,
rows: [], rows: [],
showMenu: true, showMenu: true,
emptyMessage: undefined,
}; };
export default TableCard; export default TableCard;

View File

@ -25,7 +25,7 @@ class TablePlaceholder extends Component {
return ( return (
<Table <Table
ariaHidden={ true } ariaHidden={ true }
classNames="is-loading" className="is-loading"
rows={ rows } rows={ rows }
{ ...tableProps } { ...tableProps }
/> />

View File

@ -20,6 +20,18 @@ export const Basic = () => (
</Card> </Card>
); );
export const NoDataCustomMessage = () => (
<Card size={ null }>
<Table
caption="Revenue last week"
rows={ [] }
headers={ headers }
rowKey={ ( row ) => row[ 0 ].value }
emptyMessage="Custom empty message"
/>
</Card>
);
export default { export default {
title: 'WooCommerce Admin/components/Table', title: 'WooCommerce Admin/components/Table',
component: Table, component: Table,

View File

@ -14,6 +14,7 @@ import { find, get, noop } from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withInstanceId } from '@wordpress/compose'; import { withInstanceId } from '@wordpress/compose';
import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
import deprecated from '@wordpress/deprecated';
const ASC = 'asc'; const ASC = 'asc';
const DESC = 'desc'; const DESC = 'desc';
@ -140,18 +141,35 @@ class Table extends Component {
const { const {
ariaHidden, ariaHidden,
caption, caption,
className,
classNames, classNames,
headers, headers,
instanceId, instanceId,
query, query,
rowHeader, rowHeader,
rows, rows,
emptyMessage,
} = this.props; } = this.props;
const { isScrollableRight, isScrollableLeft, tabIndex } = this.state; const { isScrollableRight, isScrollableLeft, tabIndex } = this.state;
const classes = classnames( 'woocommerce-table__table', classNames, {
'is-scrollable-right': isScrollableRight, if ( classNames ) {
'is-scrollable-left': isScrollableLeft, deprecated( `Table component's classNames prop`, {
} ); since: '11.1.0',
version: '12.0.0',
alternative: 'className',
plugin: '@woocommerce/components',
} );
}
const classes = classnames(
'woocommerce-table__table',
classNames,
className,
{
'is-scrollable-right': isScrollableRight,
'is-scrollable-left': isScrollableLeft,
}
);
const sortedBy = const sortedBy =
query.orderby || query.orderby ||
get( find( headers, { defaultSort: true } ), 'key', false ); get( find( headers, { defaultSort: true } ), 'key', false );
@ -344,10 +362,11 @@ class Table extends Component {
className="woocommerce-table__empty-item" className="woocommerce-table__empty-item"
colSpan={ headers.length } colSpan={ headers.length }
> >
{ __( { emptyMessage ??
'No data to display', __(
'woocommerce' 'No data to display',
) } 'woocommerce'
) }
</td> </td>
</tr> </tr>
) } ) }
@ -454,6 +473,10 @@ Table.propTypes = {
* Defaults to index. * Defaults to index.
*/ */
rowKey: PropTypes.func, rowKey: PropTypes.func,
/**
* Customize the message to show when there are no rows in the table.
*/
emptyMessage: PropTypes.string,
}; };
Table.defaultProps = { Table.defaultProps = {
@ -462,6 +485,7 @@ Table.defaultProps = {
onSort: noop, onSort: noop,
query: {}, query: {},
rowHeader: 0, rowHeader: 0,
emptyMessage: undefined,
}; };
export default withInstanceId( Table ); export default withInstanceId( Table );

View File

@ -10,6 +10,7 @@ import { createElement } from '@wordpress/element';
* Internal dependencies * Internal dependencies
*/ */
import TableCard from '../index'; import TableCard from '../index';
import Table from '../table';
import mockHeaders from './data/table-mock-headers'; import mockHeaders from './data/table-mock-headers';
import mockData from './data/table-mock-data'; import mockData from './data/table-mock-data';
import mockSummary from './data/table-mock-summary'; import mockSummary from './data/table-mock-summary';
@ -171,4 +172,69 @@ describe( 'TableCard', () => {
'is-left-aligned' 'is-left-aligned'
); );
} ); } );
it( 'should render the default "No data to display" when there are no data and emptyMessage is unset', () => {
render(
<TableCard
title="My table"
headers={ mockHeaders }
isLoading={ false }
rows={ [] }
rowsPerPage={ 5 }
/>
);
expect(
screen.queryByText( 'No data to display' )
).toBeInTheDocument();
} );
it( 'should render the custom label set in emptyMessage when there are no data.', () => {
const emptyMessage = 'My no data label';
render(
<TableCard
title="My table"
headers={ mockHeaders }
isLoading={ false }
rows={ [] }
rowsPerPage={ 5 }
emptyMessage={ emptyMessage }
/>
);
expect( screen.queryByText( emptyMessage ) ).toBeInTheDocument();
} );
} );
describe( 'Table', () => {
it( 'should accept className prop and renders it in the HTML output', () => {
render(
<Table
className="class-111"
caption="Table with className"
headers={ mockHeaders }
rows={ mockData }
/>
);
const el = screen.getByLabelText( 'Table with className' );
expect( el ).toHaveClass( 'class-111' );
} );
it( 'should still work with classNames prop and renders it in the HTML output, for backward compatibility reason', () => {
render(
<Table
classNames="class-222"
caption="Table with classNames"
headers={ mockHeaders }
rows={ mockData }
/>
);
const el = screen.getByLabelText( 'Table with classNames' );
expect( el ).toHaveClass( 'class-222' );
} );
} ); } );

View File

@ -25,7 +25,7 @@ const StepNavigation: React.FunctionComponent< Props > = ( {
const isFirstStep = currentStepIndex === 0; const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1; const isLastStep = currentStepIndex === steps.length - 1;
const { primaryButton = { text: '', isDisabled: false } } = const { primaryButton = { text: '', isDisabled: false, isHidden: false } } =
steps[ currentStepIndex ].meta; steps[ currentStepIndex ].meta;
const NextButton = ( const NextButton = (
@ -80,6 +80,10 @@ const StepNavigation: React.FunctionComponent< Props > = ( {
); );
}; };
if ( primaryButton.isHidden ) {
return null;
}
return ( return (
<div className="woocommerce-tour-kit-step-navigation"> <div className="woocommerce-tour-kit-step-navigation">
<div className="woocommerce-tour-kit-step-navigation__step"> <div className="woocommerce-tour-kit-step-navigation__step">

View File

@ -22,6 +22,7 @@ export interface WooStep extends Step {
text?: string; text?: string;
/** Disable the button or not. Default to False */ /** Disable the button or not. Default to False */
isDisabled?: boolean; isDisabled?: boolean;
isHidden?: boolean;
}; };
}; };
/** Auto apply the focus state for the element. Default to null */ /** Auto apply the focus state for the element. Default to null */

View File

@ -77,7 +77,6 @@ if ( ! class_exists( '{{slugSnakeCase}}' ) ) :
/** /**
* Cloning is forbidden. * Cloning is forbidden.
*
*/ */
public function __clone() { public function __clone() {
wc_doing_it_wrong( __FUNCTION__, __( 'Cloning is forbidden.', '{{slugSnakeCase}}' ), $this->version ); wc_doing_it_wrong( __FUNCTION__, __( 'Cloning is forbidden.', '{{slugSnakeCase}}' ), $this->version );
@ -85,7 +84,6 @@ if ( ! class_exists( '{{slugSnakeCase}}' ) ) :
/** /**
* Unserializing instances of this class is forbidden. * Unserializing instances of this class is forbidden.
*
*/ */
public function __wakeup() { public function __wakeup() {
wc_doing_it_wrong( __FUNCTION__, __( 'Unserializing instances of this class is forbidden.', '{{slugSnakeCase}}' ), $this->version ); wc_doing_it_wrong( __FUNCTION__, __( 'Unserializing instances of this class is forbidden.', '{{slugSnakeCase}}' ), $this->version );

View File

@ -0,0 +1,3 @@
vendor
node_modules
.turbo

View File

@ -0,0 +1,13 @@
# Changelog
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.1](https://www.npmjs.com/package/@woocommerce/create-woo-extension/v/1.0.1) - 2022-12-20
- Patch - Fix install scripts [#34385]
## [1.0.0](https://www.npmjs.com/package/@woocommerce/create-woo-extension/v/1.0.0) - 2022-12-15
- Patch - Add WC validation [#35947]
[See legacy changelogs for previous versions](https://github.com/woocommerce/woocommerce/blob/68581955106947918d2b17607a01bdfdf22288a9/packages/js/create-woo-extension/CHANGELOG.md).

View File

@ -13,7 +13,7 @@ A boilerplate for modern WooCommerce development. This project adds a React page
``` ```
npm install npm install
npm build npm run build
wp-env start wp-env start
``` ```

View File

@ -1,4 +0,0 @@
Significance: patch
Type: dev
Add WC validation

View File

@ -0,0 +1,3 @@
Significance: patch
Type: dev
Comment: Just some PHP clean up to adhere to coding standards

View File

@ -10,8 +10,8 @@ module.exports = {
], ],
namespace: 'extension', namespace: 'extension',
license: 'GPL-3.0+', license: 'GPL-3.0+',
}, customScripts: {
customScripts: { postinstall: 'composer install',
postinstall: 'composer install', },
}, },
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@woocommerce/create-woo-extension", "name": "@woocommerce/create-woo-extension",
"version": "1.0.0", "version": "1.0.1",
"description": "A template to be used with `@wordpress/create-block` to create a WooCommerce extension.", "description": "A template to be used with `@wordpress/create-block` to create a WooCommerce extension.",
"main": "index.js", "main": "index.js",
"engines": { "engines": {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Extend product variations data store with generate variations actions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix IdQuery selector for getItem selector in CRUD data stores

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Update IdQuery type on get item selectors

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix up updateItem query in CRUD data store

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add batchUpdate to product variations datastore

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add image to product variation and export types

View File

@ -18,6 +18,12 @@ createCrudDataStore( {
resourceName: 'MyThing', resourceName: 'MyThing',
pluralResourceName: 'MyThings', pluralResourceName: 'MyThings',
namespace: '/my/rest/namespace', namespace: '/my/rest/namespace',
storeConfig: {
actions: additionalActions,
selectors: additionalSelectors,
resolvers: additionalResolvers,
controls: additionalControls,
}
} ); } );
``` ```
@ -55,7 +61,7 @@ If the default settings are not adequate for your needs, you can always create y
```js ```js
import { createSelectors } from '../crud/selectors'; import { createSelectors } from '../crud/selectors';
import { createResolvers } from '../crud/selectors'; import { createResolvers } from '../crud/resolvers';
import { createActions } from '../crud/actions'; import { createActions } from '../crud/actions';
import { registerStore, combineReducers } from '@wordpress/data'; import { registerStore, combineReducers } from '@wordpress/data';

View File

@ -192,10 +192,11 @@ export const createDispatchActions = ( {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
path: getRestPath( path: getRestPath(
`${ namespace }/${ id }`, `${ namespace }/${ id }`,
cleanQuery( query, namespace ), {},
urlParameters urlParameters
), ),
method: 'PUT', method: 'PUT',
data: query,
} ); } );
yield updateItemSuccess( key, item ); yield updateItemSuccess( key, item );

View File

@ -1,7 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { registerStore } from '@wordpress/data'; import { combineReducers, registerStore, StoreConfig } from '@wordpress/data';
import { Reducer } from 'redux'; import { Reducer } from 'redux';
/** /**
@ -9,7 +9,7 @@ import { Reducer } from 'redux';
*/ */
import { createSelectors } from './selectors'; import { createSelectors } from './selectors';
import { createDispatchActions } from './actions'; import { createDispatchActions } from './actions';
import controls from '../controls'; import defaultControls from '../controls';
import { createResolvers } from './resolvers'; import { createResolvers } from './resolvers';
import { createReducer, ResourceState } from './reducer'; import { createReducer, ResourceState } from './reducer';
@ -18,6 +18,7 @@ type CrudDataStore = {
resourceName: string; resourceName: string;
pluralResourceName: string; pluralResourceName: string;
namespace: string; namespace: string;
storeConfig?: Partial< StoreConfig< ResourceState > >;
}; };
export const createCrudDataStore = ( { export const createCrudDataStore = ( {
@ -25,29 +26,44 @@ export const createCrudDataStore = ( {
resourceName, resourceName,
namespace, namespace,
pluralResourceName, pluralResourceName,
storeConfig = {},
}: CrudDataStore ) => { }: CrudDataStore ) => {
const reducer = createReducer(); const crudReducer = createReducer();
const actions = createDispatchActions( {
const crudActions = createDispatchActions( {
resourceName, resourceName,
namespace, namespace,
} ); } );
const resolvers = createResolvers( { const crudResolvers = createResolvers( {
storeName, storeName,
resourceName, resourceName,
pluralResourceName, pluralResourceName,
namespace, namespace,
} ); } );
const selectors = createSelectors( { const crudSelectors = createSelectors( {
resourceName, resourceName,
pluralResourceName, pluralResourceName,
namespace, namespace,
} ); } );
const {
reducer,
actions = {},
selectors = {},
resolvers = {},
controls = {},
} = storeConfig;
registerStore( storeName, { registerStore( storeName, {
reducer: reducer as Reducer< ResourceState >, reducer: reducer
actions, ? ( combineReducers( {
selectors, crudReducer,
resolvers, reducer,
controls, } ) as Reducer )
: ( crudReducer as Reducer< ResourceState > ),
actions: { ...crudActions, ...actions },
selectors: { ...crudSelectors, ...selectors },
resolvers: { ...crudResolvers, ...resolvers },
controls: { ...defaultControls, ...controls },
} ); } );
}; };

View File

@ -76,7 +76,7 @@ export type CrudSelectors<
'': WPDataSelector< typeof getItem >; '': WPDataSelector< typeof getItem >;
}, },
ResourceName, ResourceName,
IdType, IdQuery,
ItemType ItemType
> & > &
MapSelectors< MapSelectors<
@ -86,7 +86,7 @@ export type CrudSelectors<
UpdateError: WPDataSelector< typeof getItemUpdateError >; UpdateError: WPDataSelector< typeof getItemUpdateError >;
}, },
ResourceName, ResourceName,
IdType, IdQuery,
unknown unknown
> & > &
MapSelectors< MapSelectors<

View File

@ -77,7 +77,11 @@ export * from './countries/types';
export * from './onboarding/types'; export * from './onboarding/types';
export * from './plugins/types'; export * from './plugins/types';
export * from './products/types'; export * from './products/types';
export { ProductVariation } from './product-variations/types'; export type {
ProductVariation,
ProductVariationAttribute,
ProductVariationImage,
} from './product-variations/types';
export { export {
QueryProductAttribute, QueryProductAttribute,
ProductAttributeSelectors, ProductAttributeSelectors,

View File

@ -0,0 +1,6 @@
export enum TYPES {
GENERATE_VARIATIONS_ERROR = 'GENERATE_VARIATIONS_ERROR',
BATCH_UPDATE_VARIATIONS_ERROR = 'BATCH_UPDATE_VARIATIONS_ERROR',
}
export default TYPES;

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { getUrlParameters, getRestPath, parseId } from '../crud/utils';
import TYPES from './action-types';
import { IdQuery, IdType, Item } from '../crud/types';
import { WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants';
import type { BatchUpdateRequest, BatchUpdateResponse } from './types';
export function generateProductVariationsError( key: IdType, error: unknown ) {
return {
type: TYPES.GENERATE_VARIATIONS_ERROR as const,
key,
error,
errorType: 'GENERATE_VARIATIONS',
};
}
export const generateProductVariations = function* ( idQuery: IdQuery ) {
const urlParameters = getUrlParameters(
WC_PRODUCT_VARIATIONS_NAMESPACE,
idQuery
);
try {
const result: Item = yield apiFetch( {
path: getRestPath(
`${ WC_PRODUCT_VARIATIONS_NAMESPACE }/generate`,
{},
urlParameters
),
method: 'POST',
} );
return result;
} catch ( error ) {
const { key } = parseId( idQuery, urlParameters );
yield generateProductVariationsError( key, error );
throw error;
}
};
export function batchUpdateProductVariationsError(
key: IdType,
error: unknown
) {
return {
type: TYPES.BATCH_UPDATE_VARIATIONS_ERROR as const,
key,
error,
errorType: 'BATCH_UPDATE_VARIATIONS',
};
}
export function* batchUpdateProductVariations(
idQuery: IdQuery,
data: BatchUpdateRequest
) {
const urlParameters = getUrlParameters(
WC_PRODUCT_VARIATIONS_NAMESPACE,
idQuery
);
try {
const result: BatchUpdateResponse = yield apiFetch( {
path: getRestPath(
`${ WC_PRODUCT_VARIATIONS_NAMESPACE }/batch`,
{},
urlParameters
),
method: 'POST',
data,
} );
return result;
} catch ( error ) {
const { key } = parseId( idQuery, urlParameters );
yield batchUpdateProductVariationsError( key, error );
throw error;
}
}

View File

@ -3,12 +3,16 @@
*/ */
import { STORE_NAME, WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants'; import { STORE_NAME, WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants';
import { createCrudDataStore } from '../crud'; import { createCrudDataStore } from '../crud';
import * as actions from './actions';
createCrudDataStore( { createCrudDataStore( {
storeName: STORE_NAME, storeName: STORE_NAME,
resourceName: 'ProductVariation', resourceName: 'ProductVariation',
pluralResourceName: 'ProductVariations', pluralResourceName: 'ProductVariations',
namespace: WC_PRODUCT_VARIATIONS_NAMESPACE, namespace: WC_PRODUCT_VARIATIONS_NAMESPACE,
storeConfig: {
actions,
},
} ); } );
export const EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME = STORE_NAME; export const EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME = STORE_NAME;

View File

@ -15,11 +15,53 @@ export type ProductVariationAttribute = {
option: string; option: string;
}; };
/**
* Product variation - Image properties
*/
export interface ProductVariationImage {
/**
* Image ID.
*/
id: number;
/**
* The date the image was created, in the site's timezone.
*/
readonly date_created: string;
/**
* The date the image was created, as GMT.
*/
readonly date_created_gmt: string;
/**
* The date the image was last modified, in the site's timezone.
*/
readonly date_modified: string;
/**
* The date the image was last modified, as GMT.
*/
readonly date_modified_gmt: string;
/**
* Image URL.
*/
src: string;
/**
* Image name.
*/
name: string;
/**
* Image alternative text.
*/
alt: string;
}
export type ProductVariation = Omit< export type ProductVariation = Omit<
Product, Product,
'name' | 'slug' | 'attributes' 'name' | 'slug' | 'attributes' | 'images'
> & { > & {
attributes: ProductVariationAttribute[]; attributes: ProductVariationAttribute[];
/**
* Variation image data.
*/
image?: ProductVariationImage;
}; };
type Query = Omit< ProductQuery, 'name' >; type Query = Omit< ProductQuery, 'name' >;
@ -43,3 +85,16 @@ export type ProductVariationSelectors = CrudSelectors<
>; >;
export type ActionDispatchers = DispatchFromMap< ProductVariationActions >; export type ActionDispatchers = DispatchFromMap< ProductVariationActions >;
export type BatchUpdateRequest = {
create?: Partial< Omit< ProductVariation, 'id' > >[];
update?: ( Pick< ProductVariation, 'id' > &
Partial< Omit< ProductVariation, 'id' > > )[];
delete?: ProductVariation[ 'id' ][];
};
export type BatchUpdateResponse = {
create?: ProductVariation[];
update?: ProductVariation[];
delete?: ProductVariation[];
};

View File

@ -69,6 +69,7 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit<
id: number; id: number;
low_stock_amount: number; low_stock_amount: number;
manage_stock: boolean; manage_stock: boolean;
menu_order: number;
name: string; name: string;
on_sale: boolean; on_sale: boolean;
permalink: string; permalink: string;

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Cleanup product task experiment

View File

@ -34,7 +34,6 @@ export const trackView = async ( taskId: string, variant?: string ) => {
} ); } );
}; };
let experimentalVariant: string | undefined;
type WooOnboardingTaskProps = { type WooOnboardingTaskProps = {
id: string; id: string;
variant?: string; variant?: string;
@ -56,37 +55,15 @@ type WooOnboardingTaskSlotProps = Slot.Props & {
*/ */
const WooOnboardingTask: React.FC< WooOnboardingTaskProps > & { const WooOnboardingTask: React.FC< WooOnboardingTaskProps > & {
Slot: React.VFC< WooOnboardingTaskSlotProps >; Slot: React.VFC< WooOnboardingTaskSlotProps >;
} = ( { id, variant, ...props } ) => { } = ( { id, ...props } ) => {
useEffect( () => {
if ( id === 'products' ) {
experimentalVariant = variant;
}
}, [ id, variant ] );
return <Fill name={ 'woocommerce_onboarding_task_' + id } { ...props } />; return <Fill name={ 'woocommerce_onboarding_task_' + id } { ...props } />;
}; };
// We need this here just in case the experiment assignment takes awhile to load, so that we don't fire trackView with a blank experimentalVariant
// Remove all of the code in this file related to experiments and variants when the product task experiment concludes and never speak of the existence of this code to anyone
const pollForExperimentalVariant = ( id: string, count: number ) => {
if ( count > 20 ) {
trackView( id, 'experiment_timed_out' ); // if we can't fetch experiment after 4 seconds, give up
} else if ( experimentalVariant ) {
trackView( id, experimentalVariant );
} else {
setTimeout( () => pollForExperimentalVariant( id, count + 1 ), 200 );
}
};
WooOnboardingTask.Slot = ( { id, fillProps } ) => { WooOnboardingTask.Slot = ( { id, fillProps } ) => {
// The Slot is a React component and this hook works as expected. // The Slot is a React component and this hook works as expected.
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
useEffect( () => { useEffect( () => {
if ( id === 'products' ) { trackView( id );
pollForExperimentalVariant( id, 0 );
} else {
trackView( id );
}
}, [ id ] ); }, [ id ] );
return ( return (

View File

@ -9,7 +9,7 @@ import {
useMemo, useMemo,
useEffect, useEffect,
} from '@wordpress/element'; } from '@wordpress/element';
import { useSelect } from '@wordpress/data'; import { useSelect, useDispatch } from '@wordpress/data';
import { uniqueId, find } from 'lodash'; import { uniqueId, find } from 'lodash';
import { Icon, help as helpIcon, external } from '@wordpress/icons'; import { Icon, help as helpIcon, external } from '@wordpress/icons';
import { H, Section } from '@woocommerce/components'; import { H, Section } from '@woocommerce/components';
@ -43,9 +43,13 @@ import {
import { getUnapprovedReviews } from '../homescreen/activity-panel/reviews/utils'; import { getUnapprovedReviews } from '../homescreen/activity-panel/reviews/utils';
import { ABBREVIATED_NOTIFICATION_SLOT_NAME } from './panels/inbox/abbreviated-notifications-panel'; import { ABBREVIATED_NOTIFICATION_SLOT_NAME } from './panels/inbox/abbreviated-notifications-panel';
import { getAdminSetting } from '~/utils/admin-settings'; import { getAdminSetting } from '~/utils/admin-settings';
import { getUrlParams } from '~/utils';
import { useActiveSetupTasklist } from '~/tasks'; import { useActiveSetupTasklist } from '~/tasks';
import { LayoutContext } from '~/layout'; import { LayoutContext } from '~/layout';
import { getSegmentsFromPath } from '~/utils/url-helpers'; import { getSegmentsFromPath } from '~/utils/url-helpers';
import { FeedbackIcon } from '~/products/images/feedback-icon';
import { STORE_KEY as CES_STORE_KEY } from '~/customer-effort-score-tracks/data/constants';
import { ProductFeedbackTour } from '~/guided-tours/add-product-feedback-tour';
const HelpPanel = lazy( () => const HelpPanel = lazy( () =>
import( /* webpackChunkName: "activity-panels-help" */ './panels/help' ) import( /* webpackChunkName: "activity-panels-help" */ './panels/help' )
@ -202,6 +206,9 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
), ),
}; };
} ); } );
const { showCesModal } = useDispatch( CES_STORE_KEY );
const { currentUserCan } = useUser(); const { currentUserCan } = useUser();
const togglePanel = ( { name: tabName }, isTabOpen ) => { const togglePanel = ( { name: tabName }, isTabOpen ) => {
@ -237,13 +244,23 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
return query.page === 'wc-admin' && ! query.path; return query.page === 'wc-admin' && ! query.path;
}; };
const isProductPage = () => { const isProductScreen = () => {
const [ firstPathSegment ] = getSegmentsFromPath( query.path ); const [ firstPathSegment ] = getSegmentsFromPath( query.path );
return ( return (
firstPathSegment === 'add-product' || firstPathSegment === 'product' firstPathSegment === 'add-product' || firstPathSegment === 'product'
); );
}; };
const isAddProductPage = () => {
const urlParams = getUrlParams( window.location.search );
return (
isEmbedded &&
/post-new\.php$/.test( window.location.pathname ) &&
urlParams?.post_type === 'product'
);
};
const isPerformingSetupTask = () => { const isPerformingSetupTask = () => {
return ( return (
query.task && query.task &&
@ -264,7 +281,49 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
visible: visible:
( isEmbedded || ! isHomescreen() ) && ( isEmbedded || ! isHomescreen() ) &&
! isPerformingSetupTask() && ! isPerformingSetupTask() &&
! isProductPage(), ! isProductScreen(),
};
const feedback = {
name: 'feedback',
title: __( 'Feedback', 'woocommerce' ),
icon: <FeedbackIcon />,
onClick: () => {
setCurrentTab( 'feedback' );
setIsPanelOpen( true );
showCesModal(
{
action: 'product_feedback',
title: __(
"How's your experience with the product editor?",
'woocommerce'
),
firstQuestion: __(
'The product editing screen is easy to use',
'woocommerce'
),
secondQuestion: __(
"The product editing screen's functionality meets my needs",
'woocommerce'
),
},
{
onRecordScore: () => {
setCurrentTab( '' );
setIsPanelOpen( false );
},
onCloseModal: () => {
setCurrentTab( '' );
setIsPanelOpen( false );
},
},
{
type: 'snackbar',
icon: <span>🌟</span>,
}
);
},
visible: isAddProductPage(),
}; };
const setup = { const setup = {
@ -284,7 +343,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
! setupTaskListComplete && ! setupTaskListComplete &&
! setupTaskListHidden && ! setupTaskListHidden &&
! isHomescreen() && ! isHomescreen() &&
! isProductPage(), ! isProductScreen(),
}; };
const help = { const help = {
@ -337,6 +396,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
return [ return [
activity, activity,
feedback,
setup, setup,
previewSite, previewSite,
previewStore, previewStore,
@ -431,6 +491,9 @@ export const ActivityPanel = ( { isEmbedded, query } ) => {
clearPanel={ () => clearPanel() } clearPanel={ () => clearPanel() }
/> />
</Section> </Section>
{ isAddProductPage() && (
<ProductFeedbackTour currentTab={ currentTab } />
) }
{ showHelpHighlightTooltip ? ( { showHelpHighlightTooltip ? (
<HighlightTooltip <HighlightTooltip
delay={ 1000 } delay={ 1000 }

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { OPTIONS_STORE_NAME } from '@woocommerce/data'; import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { dispatch, resolveSelect } from '@wordpress/data'; import { dispatch, resolveSelect } from '@wordpress/data';
import { getQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
@ -42,7 +43,7 @@ export const getExitPageData = () => {
* @param {string} pageId of page exited early. * @param {string} pageId of page exited early.
*/ */
export const addExitPage = ( pageId: string ) => { export const addExitPage = ( pageId: string ) => {
if ( ! window.localStorage ) { if ( ! ( window.localStorage && allowTracking ) ) {
return; return;
} }
@ -93,8 +94,8 @@ export const addCustomerEffortScoreExitPageListener = (
pageId: string, pageId: string,
hasUnsavedChanges: () => boolean hasUnsavedChanges: () => boolean
) => { ) => {
eventListeners[ pageId ] = ( event ) => { eventListeners[ pageId ] = () => {
if ( hasUnsavedChanges() && allowTracking ) { if ( hasUnsavedChanges() ) {
addExitPage( pageId ); addExitPage( pageId );
} }
}; };
@ -205,19 +206,88 @@ function getExitPageCESCopy( pageId: string ): {
'woocommerce' 'woocommerce'
), ),
}; };
case 'shop_order_update':
return {
action: pageId,
icon: '📦',
noticeLabel: __(
'How easy or difficult was it to update this order?',
'woocommerce'
),
title: __(
"How's your experience with orders?",
'woocommerce'
),
description: __(
'We noticed you started editing an order, then left. How was it? Your feedback will help create a better experience for thousands of merchants like you.',
'woocommerce'
),
firstQuestion: __(
'The order editing screen is easy to use',
'woocommerce'
),
secondQuestion: __(
"The order details screen's functionality meets my needs",
'woocommerce'
),
};
case 'import_products':
return {
action: pageId,
icon: '🔄',
noticeLabel: __(
'How is your experience with importing products?',
'woocommerce'
),
title: __(
`How's your experience with importing products?`,
'woocommerce'
),
description: __(
'We noticed you started importing products, then left. How was it? Your feedback will help create a better experience for thousands of merchants like you.',
'woocommerce'
),
firstQuestion: __(
'The product import screen is easy to use',
'woocommerce'
),
secondQuestion: __(
"The product import screen's functionality meets my needs",
'woocommerce'
),
};
default: default:
return null; return null;
} }
} }
/**
* Stores trigger conditions for exit page actions.
*
* @param {string} pageId page id.
*/
function getShouldExitPageFire( pageId: string ) {
const conditionPageMap: Record< string, () => boolean > = {
import_products: () =>
( getQuery() as { page: string } ).page !== 'product_importer',
};
return conditionPageMap[ pageId ] ? conditionPageMap[ pageId ]() : true;
}
/** /**
* Checks the exit page list and triggers a CES survey for the first item in the list. * Checks the exit page list and triggers a CES survey for the first item in the list.
*/ */
export function triggerExitPageCesSurvey() { export function triggerExitPageCesSurvey() {
const exitPageItems: string[] = getExitPageData(); const exitPageItems: string[] = getExitPageData();
if ( exitPageItems && exitPageItems.length > 0 ) { if ( exitPageItems?.length ) {
if ( ! getShouldExitPageFire( exitPageItems[ 0 ] ) ) {
return;
}
const copy = getExitPageCESCopy( exitPageItems[ 0 ] ); const copy = getExitPageCESCopy( exitPageItems[ 0 ] );
if ( copy && copy.title.length > 0 ) {
if ( copy?.title?.length ) {
dispatch( 'wc/customer-effort-score' ).addCesSurvey( { dispatch( 'wc/customer-effort-score' ).addCesSurvey( {
...copy, ...copy,
pageNow: window.pagenow, pageNow: window.pagenow,

View File

@ -74,15 +74,18 @@ export const CustomerEffortScoreModalContainer: React.FC = () => {
return ( return (
<CustomerFeedbackModal <CustomerFeedbackModal
title={ visibleCESModalData.label } title={ visibleCESModalData.title }
description={ visibleCESModalData.description }
firstQuestion={ visibleCESModalData.firstQuestion } firstQuestion={ visibleCESModalData.firstQuestion }
secondQuestion={ visibleCESModalData.secondQuestion } secondQuestion={ visibleCESModalData.secondQuestion }
recordScoreCallback={ ( ...args ) => { recordScoreCallback={ ( ...args ) => {
recordScore( ...args ); recordScore( ...args );
hideCesModal(); hideCesModal();
visibleCESModalData.props?.onRecordScore?.();
} }
onCloseModal={ () => {
visibleCESModalData.props?.onCloseModal?.();
hideCesModal();
} } } }
onCloseModal={ () => hideCesModal() }
/> />
); );
}; };

View File

@ -25,7 +25,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
case TYPES.SHOW_CES_MODAL: case TYPES.SHOW_CES_MODAL:
const cesModalData = { const cesModalData = {
action: action.surveyProps.action, action: action.surveyProps.action,
label: action.surveyProps.label, title: action.surveyProps.title,
onSubmitLabel: action.onSubmitLabel, onSubmitLabel: action.onSubmitLabel,
firstQuestion: action.surveyProps.firstQuestion, firstQuestion: action.surveyProps.firstQuestion,
secondQuestion: action.surveyProps.secondQuestion, secondQuestion: action.surveyProps.secondQuestion,

View File

@ -67,7 +67,7 @@ export const ProductMVPCESFooter: React.FC = () => {
showCesModal( showCesModal(
{ {
action: cesAction, action: cesAction,
label: __( title: __(
"How's your experience with the product editor?", "How's your experience with the product editor?",
'woocommerce' 'woocommerce'
), ),

View File

@ -358,7 +358,10 @@ export function StoreAddress( {
required required
autoComplete="new-password" // disable autocomplete and autofill autoComplete="new-password" // disable autocomplete and autofill
getSearchExpression={ ( query: string ) => { getSearchExpression={ ( query: string ) => {
return new RegExp( '^' + query, 'i' ); return new RegExp(
'(^' + query + '| — (' + query + '))',
'i'
);
} } } }
options={ countryStateOptions } options={ countryStateOptions }
excludeSelectedOptions={ false } excludeSelectedOptions={ false }

View File

@ -0,0 +1,114 @@
/**
* External dependencies
*/
import { TourKit } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { useState, useEffect, useRef } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
const FEEDBACK_TOUR_OPTION = 'woocommerce_ces_product_feedback_shown';
const FEEDBACK_TIMEOUT_MS = 7 * 60 * 1000;
const useShowProductFeedbackTour = (): undefined | boolean => {
const { hasShownTour } = useSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
return {
hasShownTour: getOption( FEEDBACK_TOUR_OPTION ) as
| boolean
| undefined,
};
} );
return hasShownTour;
};
type ProductFeedbackTourProps = {
currentTab: string;
};
export const ProductFeedbackTour: React.FC< ProductFeedbackTourProps > = ( {
currentTab,
} ) => {
const hasShownTour = useShowProductFeedbackTour();
const [ isTourVisible, setIsTourVisible ] = useState( false );
const tourTimeout = useRef< ReturnType< typeof setTimeout > | null >(
null
);
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const clearTourTimeout = () => {
clearTimeout( tourTimeout.current as ReturnType< typeof setTimeout > );
tourTimeout.current = null;
};
useEffect( () => {
if ( hasShownTour !== false ) {
return;
}
tourTimeout.current = setTimeout( () => {
setIsTourVisible( true );
}, FEEDBACK_TIMEOUT_MS );
return () => clearTourTimeout();
}, [ hasShownTour ] );
useEffect( () => {
if ( ! isTourVisible ) {
return;
}
updateOptions( {
[ FEEDBACK_TOUR_OPTION ]: true,
} );
}, [ isTourVisible ] );
if (
currentTab === 'feedback' &&
( isTourVisible || tourTimeout.current )
) {
setIsTourVisible( false );
clearTourTimeout();
}
if ( ! isTourVisible ) {
return null;
}
return (
<TourKit
config={ {
steps: [
{
referenceElements: {
desktop: '#activity-panel-tab-feedback',
},
meta: {
name: 'product-feedback-tour-1',
heading: __( '🫣 Feeling stuck?', 'woocommerce' ),
descriptions: {
desktop: __(
"You have been working on this product for a few minutes now. Is there something you're struggling with? Share your feedback.",
'woocommerce'
),
},
primaryButton: {
isHidden: true,
},
},
},
],
placement: 'bottom-start',
options: {
effects: {
liveResize: { mutation: true, resize: true },
},
},
closeHandler: () => {
setIsTourVisible( false );
},
} }
/>
);
};

View File

@ -29,7 +29,6 @@ const EditProductPage = lazy( () =>
/* webpackChunkName: "edit-product-page" */ '../products/edit-product-page' /* webpackChunkName: "edit-product-page" */ '../products/edit-product-page'
) )
); );
const AddProductPage = lazy( () => const AddProductPage = lazy( () =>
import( import(
/* webpackChunkName: "add-product-page" */ '../products/add-product-page' /* webpackChunkName: "add-product-page" */ '../products/add-product-page'
@ -46,7 +45,6 @@ const AnalyticsSettings = lazy( () =>
const Dashboard = lazy( () => const Dashboard = lazy( () =>
import( /* webpackChunkName: "dashboard" */ '../dashboard' ) import( /* webpackChunkName: "dashboard" */ '../dashboard' )
); );
const Homescreen = lazy( () => const Homescreen = lazy( () =>
import( /* webpackChunkName: "homescreen" */ '../homescreen' ) import( /* webpackChunkName: "homescreen" */ '../homescreen' )
); );
@ -66,7 +64,6 @@ const ProfileWizard = lazy( () =>
const SettingsGroup = lazy( () => const SettingsGroup = lazy( () =>
import( /* webpackChunkName: "profile-wizard" */ '../settings' ) import( /* webpackChunkName: "profile-wizard" */ '../settings' )
); );
const WCPaymentsWelcomePage = lazy( () => const WCPaymentsWelcomePage = lazy( () =>
import( import(
/* webpackChunkName: "wcpay-payment-welcome-page" */ '../payments-welcome' /* webpackChunkName: "wcpay-payment-welcome-page" */ '../payments-welcome'
@ -202,6 +199,20 @@ export const getPages = () => {
wpOpenMenu: 'menu-posts-product', wpOpenMenu: 'menu-posts-product',
capability: 'manage_woocommerce', capability: 'manage_woocommerce',
} ); } );
pages.push( {
container: EditProductPage,
path: '/product/:productId/variation/:variationId',
breadcrumbs: [
[ '/edit-product', __( 'Product', 'woocommerce' ) ],
__( 'Edit Product Variation', 'woocommerce' ),
],
navArgs: {
id: 'woocommerce-edit-product',
},
wpOpenMenu: 'menu-posts-product',
capability: 'edit_products',
} );
} }
if ( window.wcAdminFeatures.onboarding ) { if ( window.wcAdminFeatures.onboarding ) {

View File

@ -8,6 +8,7 @@ import { useEffect } from '@wordpress/element';
* Internal dependencies * Internal dependencies
*/ */
import { ProductForm } from './product-form'; import { ProductForm } from './product-form';
import './product-page.scss';
const AddProductPage: React.FC = () => { const AddProductPage: React.FC = () => {
useEffect( () => { useEffect( () => {

View File

@ -5,3 +5,4 @@ export const ONLY_ONE_DECIMAL_SEPARATOR = '[%s](?=%s*[%s])';
export const ADD_NEW_SHIPPING_CLASS_OPTION_VALUE = export const ADD_NEW_SHIPPING_CLASS_OPTION_VALUE =
'__ADD_NEW_SHIPPING_CLASS_OPTION__'; '__ADD_NEW_SHIPPING_CLASS_OPTION__';
export const UNCATEGORIZED_CATEGORY_SLUG = 'uncategorized'; export const UNCATEGORIZED_CATEGORY_SLUG = 'uncategorized';
export const PRODUCT_VARIATION_TITLE_LIMIT = 32;

View File

@ -2,35 +2,43 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
import { useEffect, useRef } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { Spinner, FormRef } from '@woocommerce/components';
import { import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
PartialProduct, PartialProduct,
Product, Product,
PRODUCTS_STORE_NAME, PRODUCTS_STORE_NAME,
WCDataSelector, WCDataSelector,
} from '@woocommerce/data'; } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useEffect, useRef } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { Spinner, FormRef } from '@woocommerce/components';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ProductForm } from './product-form'; import { ProductForm } from './product-form';
import { ProductFormLayout } from './layout/product-form-layout'; import { ProductFormLayout } from './layout/product-form-layout';
import { ProductVariationForm } from './product-variation-form';
import './product-page.scss';
const EditProductPage: React.FC = () => { const EditProductPage: React.FC = () => {
const { productId } = useParams(); const { productId, variationId } = useParams();
const isProductVariation = !! variationId;
const previousProductRef = useRef< PartialProduct >(); const previousProductRef = useRef< PartialProduct >();
const formRef = useRef< FormRef< Partial< Product > > >( null ); const formRef = useRef< FormRef< Partial< Product > > >( null );
const { product, isLoading, isPendingAction } = useSelect( const { product, isLoading, isPendingAction, productVariation } = useSelect(
( select: WCDataSelector ) => { ( select: WCDataSelector ) => {
const { const {
getProduct, getProduct,
hasFinishedResolution, hasFinishedResolution: hasProductFinishedResolution,
isPending, isPending,
getPermalinkParts, getPermalinkParts,
} = select( PRODUCTS_STORE_NAME ); } = select( PRODUCTS_STORE_NAME );
const {
getProductVariation,
hasFinishedResolution: hasProductVariationFinishedResolution,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
if ( productId ) { if ( productId ) {
const retrievedProduct = getProduct( const retrievedProduct = getProduct(
parseInt( productId, 10 ), parseInt( productId, 10 ),
@ -44,13 +52,26 @@ const EditProductPage: React.FC = () => {
permalinkParts && retrievedProduct permalinkParts && retrievedProduct
? retrievedProduct ? retrievedProduct
: undefined, : undefined,
productVariation:
isProductVariation &&
getProductVariation( {
id: parseInt( variationId, 10 ),
product_id: parseInt( productId, 10 ),
} ),
isLoading: isLoading:
! hasFinishedResolution( 'getProduct', [ ! hasProductFinishedResolution( 'getProduct', [
parseInt( productId, 10 ), parseInt( productId, 10 ),
] ) || ] ) ||
! hasFinishedResolution( 'getPermalinkParts', [ ! hasProductFinishedResolution( 'getPermalinkParts', [
parseInt( productId, 10 ), parseInt( productId, 10 ),
] ), ] ) ||
! (
isProductVariation &&
hasProductVariationFinishedResolution(
'getProductVariation',
[ parseInt( variationId, 10 ) ]
)
),
isPendingAction: isPendingAction:
isPending( 'createProduct' ) || isPending( 'createProduct' ) ||
isPending( isPending(
@ -109,7 +130,14 @@ const EditProductPage: React.FC = () => {
</div> </div>
</ProductFormLayout> </ProductFormLayout>
) } ) }
{ product && { productVariation && product && (
<ProductVariationForm
product={ product }
productVariation={ productVariation }
/>
) }
{ ! isProductVariation &&
product &&
( product.status !== 'trash' || wasDeletedUsingAction ) && ( ( product.status !== 'trash' || wasDeletedUsingAction ) && (
<ProductForm formRef={ formRef } product={ product } /> <ProductForm formRef={ formRef } product={ product } />
) } ) }

View File

@ -0,0 +1,14 @@
.woocommerce-attribute-empty-state {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
&__image {
max-width: 150px;
margin: $gap-larger 0 $gap-large;
}
&__add-new {
margin: $gap-large 0 $gap-larger;
}
}

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Card, CardBody } from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
/**
* Internal dependencies
*/
import './attribute-empty-state.scss';
import AttributeEmptyStateLogo from './attribute-empty-state-logo.svg';
type AttributeEmptyStateProps = {
image?: string;
subtitle?: string;
addNewLabel?: string;
onNewClick?: () => void;
};
export const AttributeEmptyState: React.FC< AttributeEmptyStateProps > = ( {
image = AttributeEmptyStateLogo,
subtitle = __( 'No attributes yet', 'woocommerce' ),
addNewLabel = __( 'Add first attribute', 'woocommerce' ),
onNewClick,
} ) => {
return (
<Card>
<CardBody>
<div className="woocommerce-attribute-empty-state">
<img
src={ image }
alt="Completed"
className="woocommerce-attribute-empty-state__image"
/>
<Text
variant="subtitle.small"
weight="600"
size="14"
lineHeight="20px"
className="woocommerce-attribute-empty-state__subtitle"
>
{ subtitle }
</Text>
{ typeof onNewClick === 'function' && (
<Button
variant="secondary"
className="woocommerce-attribute-empty-state__add-new"
onClick={ onNewClick }
>
{ addNewLabel }
</Button>
) }
</div>
</CardBody>
</Card>
);
};

View File

@ -0,0 +1,2 @@
export * from './attribute-empty-state';
export { default as AttributeEmptyStateLogo } from './attribute-empty-state-logo.svg';

View File

@ -30,6 +30,21 @@ import { HydratedAttributeType } from '../attribute-field';
import { getProductAttributeObject } from './utils'; import { getProductAttributeObject } from './utils';
type AddAttributeModalProps = { type AddAttributeModalProps = {
title?: string;
notice?: string;
attributeLabel?: string;
valueLabel?: string;
attributePlaceholder?: string;
termPlaceholder?: string;
removeLabel?: string;
addAnotherAccessibleLabel?: string;
addAnotherLabel?: string;
cancelLabel?: string;
addAccessibleLabel?: string;
addLabel?: string;
confirmMessage?: string;
confirmCancelLabel?: string;
confirmConfirmLabel?: string;
onCancel: () => void; onCancel: () => void;
onAdd: ( newCategories: HydratedAttributeType[] ) => void; onAdd: ( newCategories: HydratedAttributeType[] ) => void;
selectedAttributeIds?: number[]; selectedAttributeIds?: number[];
@ -40,6 +55,27 @@ type AttributeForm = {
}; };
export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
title = __( 'Add attributes', 'woocommerce' ),
notice = __(
'By default, attributes are filterable and visible on the product page. You can change these settings for each attribute separately later.',
'woocommerce'
),
attributeLabel = __( 'Attribute', 'woocommerce' ),
valueLabel = __( 'Values', 'woocommerce' ),
attributePlaceholder = __( 'Search or create attribute', 'woocommerce' ),
termPlaceholder = __( 'Search or create value', 'woocommerce' ),
removeLabel = __( 'Remove attribute', 'woocommerce' ),
addAnotherAccessibleLabel = __( 'Add another attribute', 'woocommerce' ),
addAnotherLabel = __( '+ Add another', 'woocommerce' ),
cancelLabel = __( 'Cancel', 'woocommerce' ),
addAccessibleLabel = __( 'Add attributes', 'woocommerce' ),
addLabel = __( 'Add', 'woocommerce' ),
confirmMessage = __(
'You have some attributes added to the list, are you sure you want to cancel?',
'woocommerce'
),
confirmCancelLabel = __( 'No thanks', 'woocommerce' ),
confirmConfirmLabel = __( 'Yes please!', 'woocommerce' ),
onCancel, onCancel,
onAdd, onAdd,
selectedAttributeIds = [], selectedAttributeIds = [],
@ -124,9 +160,6 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
} }
}; };
const attributeLabel = __( 'Attribute', 'woocommerce' );
const valueLabel = __( 'Values', 'woocommerce' );
return ( return (
<> <>
<Form< AttributeForm > <Form< AttributeForm >
@ -144,7 +177,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
} ) => { } ) => {
return ( return (
<Modal <Modal
title={ __( 'Add attributes', 'woocommerce' ) } title={ title }
onRequestClose={ ( onRequestClose={ (
event: event:
| React.KeyboardEvent< Element > | React.KeyboardEvent< Element >
@ -158,12 +191,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
className="woocommerce-add-attribute-modal" className="woocommerce-add-attribute-modal"
> >
<Notice isDismissible={ false }> <Notice isDismissible={ false }>
<p> <p>{ notice }</p>
{ __(
'By default, attributes are filterable and visible on the product page. You can change these settings for each attribute separately later.',
'woocommerce'
) }
</p>
</Notice> </Notice>
<div className="woocommerce-add-attribute-modal__body"> <div className="woocommerce-add-attribute-modal__body">
@ -183,10 +211,9 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
> >
<td className="woocommerce-add-attribute-modal__table-attribute-column"> <td className="woocommerce-add-attribute-modal__table-attribute-column">
<AttributeInputField <AttributeInputField
placeholder={ __( placeholder={
'Search or create attribute', attributePlaceholder
'woocommerce' }
) }
value={ attribute } value={ attribute }
label={ label={
attributeLabel attributeLabel
@ -232,10 +259,9 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
{ attribute === null || { attribute === null ||
attribute.id !== 0 ? ( attribute.id !== 0 ? (
<AttributeTermInputField <AttributeTermInputField
placeholder={ __( placeholder={
'Search or create value', termPlaceholder
'woocommerce' }
) }
disabled={ disabled={
attribute attribute
? ! attribute.id ? ! attribute.id
@ -268,10 +294,9 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
/> />
) : ( ) : (
<CustomAttributeTermInputField <CustomAttributeTermInputField
placeholder={ __( placeholder={
'Search or create value', termPlaceholder
'woocommerce' }
) }
disabled={ disabled={
! attribute.name ! attribute.name
} }
@ -306,10 +331,9 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
.attributes[ 0 ] === .attributes[ 0 ] ===
null null
} }
label={ __( label={
'Remove attribute', removeLabel
'woocommerce' }
) }
onClick={ () => onClick={ () =>
onRemove( onRemove(
index, index,
@ -329,10 +353,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
<Button <Button
className="woocommerce-add-attribute-modal__add-attribute" className="woocommerce-add-attribute-modal__add-attribute"
variant="tertiary" variant="tertiary"
label={ __( label={ addAnotherAccessibleLabel }
'Add another attribute',
'woocommerce'
) }
onClick={ () => { onClick={ () => {
recordEvent( recordEvent(
'product_add_attributes_modal_add_another_attribute_button_click' 'product_add_attributes_modal_add_another_attribute_button_click'
@ -340,24 +361,20 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
addAnother( values, setValue ); addAnother( values, setValue );
} } } }
> >
+&nbsp; { addAnotherLabel }
{ __( 'Add another', 'woocommerce' ) }
</Button> </Button>
</div> </div>
<div className="woocommerce-add-attribute-modal__buttons"> <div className="woocommerce-add-attribute-modal__buttons">
<Button <Button
isSecondary isSecondary
label={ __( 'Cancel', 'woocommerce' ) } label={ cancelLabel }
onClick={ () => onClose( values ) } onClick={ () => onClose( values ) }
> >
{ __( 'Cancel', 'woocommerce' ) } { cancelLabel }
</Button> </Button>
<Button <Button
isPrimary isPrimary
label={ __( label={ addAccessibleLabel }
'Add attributes',
'woocommerce'
) }
disabled={ disabled={
values.attributes.length === 1 && values.attributes.length === 1 &&
values.attributes[ 0 ] === null values.attributes[ 0 ] === null
@ -366,7 +383,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
onAddingAttributes( values ) onAddingAttributes( values )
} }
> >
{ __( 'Add', 'woocommerce' ) } { addLabel }
</Button> </Button>
</div> </div>
</Modal> </Modal>
@ -377,15 +394,12 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
<SelectControlMenuSlot /> <SelectControlMenuSlot />
{ showConfirmClose && ( { showConfirmClose && (
<ConfirmDialog <ConfirmDialog
cancelButtonText={ __( 'No thanks', 'woocommerce' ) } cancelButtonText={ confirmCancelLabel }
confirmButtonText={ __( 'Yes please!', 'woocommerce' ) } confirmButtonText={ confirmConfirmLabel }
onCancel={ () => setShowConfirmClose( false ) } onCancel={ () => setShowConfirmClose( false ) }
onConfirm={ onCancel } onConfirm={ onCancel }
> >
{ __( { confirmMessage }
'You have some attributes added to the list, are you sure you want to cancel?',
'woocommerce'
) }
</ConfirmDialog> </ConfirmDialog>
) } ) }
</> </>

View File

@ -1,63 +1,14 @@
.woocommerce-attribute-field { .woocommerce-attribute-field {
width: 100%; width: 100%;
&__empty-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
&__empty-logo {
max-width: 150px;
margin: $gap-larger 0 $gap-large;
}
&__add-new {
margin: $gap-large 0 $gap-larger;
}
&__attribute-option-chip {
padding: $gap-smallest $gap-smaller;
gap: 2px;
background: $gray-100;
border-radius: 2px;
}
&__attribute-options {
display: flex;
flex-direction: row;
gap: $gap-smallest;
}
&__attribute-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: end;
gap: $gap-smaller;
}
.woocommerce-list-item {
min-height: 82px;
padding: 0 $gap-large;
&:last-child {
margin-bottom: -1px;
}
}
.woocommerce-sortable { .woocommerce-sortable {
margin: 0; margin: 0;
.woocommerce-list-item {
display: grid;
grid-template-columns: 24px 26% auto 90px;
}
} }
.woocommerce-sortable__item:not(:first-child) { .woocommerce-sortable__item:not(:first-child) {
margin-top: -1px; margin-top: -1px;
} }
.woocommerce-sortable__item:focus-visible:not(:active) + .woocommerce-sortable__item .woocommerce-list-item { .woocommerce-sortable__item:focus-visible:not(:active) + .woocommerce-sortable__item .woocommerce-attribute-list-item {
background: none; background: none;
border-top: 0; border-top: 0;
} }

View File

@ -1,8 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { sprintf, __ } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { Button, Card, CardBody } from '@wordpress/components';
import { useState, useCallback, useEffect } from '@wordpress/element'; import { useState, useCallback, useEffect } from '@wordpress/element';
import { import {
ProductAttribute, ProductAttribute,
@ -10,46 +9,51 @@ import {
ProductAttributeTerm, ProductAttributeTerm,
} from '@woocommerce/data'; } from '@woocommerce/data';
import { resolveSelect } from '@wordpress/data'; import { resolveSelect } from '@wordpress/data';
import { Text } from '@woocommerce/experimental';
import { import {
Sortable, Sortable,
ListItem,
__experimentalSelectControlMenuSlot as SelectControlMenuSlot, __experimentalSelectControlMenuSlot as SelectControlMenuSlot,
Link,
} from '@woocommerce/components'; } from '@woocommerce/components';
import { closeSmall } from '@wordpress/icons';
import { recordEvent } from '@woocommerce/tracks'; import { recordEvent } from '@woocommerce/tracks';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './attribute-field.scss'; import './attribute-field.scss';
import AttributeEmptyStateLogo from './attribute-empty-state-logo.svg';
import { AddAttributeModal } from './add-attribute-modal'; import { AddAttributeModal } from './add-attribute-modal';
import { EditAttributeModal } from './edit-attribute-modal'; import { EditAttributeModal } from './edit-attribute-modal';
import { reorderSortableProductAttributePositions } from './utils'; import { reorderSortableProductAttributePositions } from './utils';
import { sift } from '../../../utils'; import { sift } from '../../../utils';
import { AttributeEmptyState } from '../attribute-empty-state';
import {
AddAttributeListItem,
AttributeListItem,
} from '../attribute-list-item';
type AttributeFieldProps = { type AttributeFieldProps = {
value: ProductAttribute[]; value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void; onChange: ( value: ProductAttribute[] ) => void;
productId?: number; productId?: number;
// TODO: should we support an 'any' option to show all attributes?
attributeType?: 'regular' | 'for-variations';
}; };
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & { export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & {
options?: string[]; options?: string[];
terms?: ProductAttributeTerm[]; terms?: ProductAttributeTerm[];
visible?: boolean;
}; };
export const AttributeField: React.FC< AttributeFieldProps > = ( { export const AttributeField: React.FC< AttributeFieldProps > = ( {
value, value,
onChange, onChange,
productId, productId,
attributeType = 'regular',
} ) => { } ) => {
const [ showAddAttributeModal, setShowAddAttributeModal ] = const [ showAddAttributeModal, setShowAddAttributeModal ] =
useState( false ); useState( false );
const [ hydrationComplete, setHydrationComplete ] = useState< boolean >(
value ? false : true
);
const [ hydratedAttributes, setHydratedAttributes ] = useState< const [ hydratedAttributes, setHydratedAttributes ] = useState<
HydratedAttributeType[] HydratedAttributeType[]
>( [] ); >( [] );
@ -57,8 +61,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
null | string null | string
>( null ); >( null );
const CANCEL_BUTTON_EVENT_NAME = const isOnlyForVariations = attributeType === 'for-variations';
'product_add_attributes_modal_cancel_button_click';
const newAttributeProps = { variation: isOnlyForVariations };
const CANCEL_BUTTON_EVENT_NAME = isOnlyForVariations
? 'product_add_options_modal_cancel_button_click'
: 'product_add_attributes_modal_cancel_button_click';
const fetchTerms = useCallback( const fetchTerms = useCallback(
( attributeId: number ) => { ( attributeId: number ) => {
@ -82,7 +91,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
); );
useEffect( () => { useEffect( () => {
if ( ! value || hydrationComplete ) { // I think we'll need to move the hydration out of the individual component
// instance. To where, I do not yet know... maybe in the form context
// somewhere so that a single hydration source can be shared between multiple
// instances? Something like a simple key-value store in the form context
// would be handy.
if ( ! value || hydratedAttributes.length !== 0 ) {
return; return;
} }
@ -94,19 +108,26 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
).then( ( allResults ) => { ).then( ( allResults ) => {
setHydratedAttributes( [ setHydratedAttributes( [
...globalAttributes.map( ( attr, index ) => { ...globalAttributes.map( ( attr, index ) => {
const fetchedTerms = allResults[ index ];
const newAttr = { const newAttr = {
...attr, ...attr,
terms: allResults[ index ], // I'm not sure this is quite right for handling unpersisted terms,
options: undefined, // but this gets things kinda working for now
terms:
fetchedTerms.length > 0 ? fetchedTerms : undefined,
options:
fetchedTerms.length === 0
? attr.options
: undefined,
}; };
return newAttr; return newAttr;
} ), } ),
...customAttributes, ...customAttributes,
] ); ] );
setHydrationComplete( true );
} ); } );
}, [ productId, value, hydrationComplete ] ); }, [ fetchTerms, hydratedAttributes, value ] );
const fetchAttributeId = ( attribute: { id: number; name: string } ) => const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
`${ attribute.id }-${ attribute.name }`; `${ attribute.id }-${ attribute.name }`;
@ -121,6 +142,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
? attr.terms.map( ( term ) => term.name ) ? attr.terms.map( ( term ) => term.name )
: ( attr.options as string[] ), : ( attr.options as string[] ),
terms: undefined, terms: undefined,
visible: attr.visible || false,
}; };
} ) } )
); );
@ -157,68 +179,70 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
) )
) )
.map( ( newAttr, index ) => { .map( ( newAttr, index ) => {
newAttr.position = ( value || [] ).length + index; return {
return newAttr; ...newAttributeProps,
...newAttr,
position: ( value || [] ).length + index,
};
} ), } ),
] ); ] );
recordEvent( 'product_add_attributes_modal_add_button_click' ); recordEvent( 'product_add_attributes_modal_add_button_click' );
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
}; };
if ( ! value || value.length === 0 || hydratedAttributes.length === 0 ) { const filteredAttributes = value
? value.filter(
( attribute: ProductAttribute ) =>
attribute.variation === isOnlyForVariations
)
: false;
if (
! filteredAttributes ||
filteredAttributes.length === 0 ||
hydratedAttributes.length === 0
) {
return ( return (
<Card> <>
<CardBody> <AttributeEmptyState
<div className="woocommerce-attribute-field"> addNewLabel={
<div className="woocommerce-attribute-field__empty-container"> isOnlyForVariations
<img ? __( 'Add options', 'woocommerce' )
src={ AttributeEmptyStateLogo } : undefined
alt="Completed" }
className="woocommerce-attribute-field__empty-logo" onNewClick={ () => {
/> recordEvent(
<Text 'product_add_first_attribute_button_click'
variant="subtitle.small" );
weight="600" setShowAddAttributeModal( true );
size="14" } }
lineHeight="20px" subtitle={
className="woocommerce-attribute-field__empty-subtitle" isOnlyForVariations
> ? __( 'No options yet', 'woocommerce' )
{ __( 'No attributes yet', 'woocommerce' ) } : undefined
</Text> }
<Button />
variant="secondary" { showAddAttributeModal && (
className="woocommerce-attribute-field__add-new" <AddAttributeModal
onClick={ () => { onCancel={ () => {
recordEvent( recordEvent( CANCEL_BUTTON_EVENT_NAME );
'product_add_first_attribute_button_click' setShowAddAttributeModal( false );
); } }
setShowAddAttributeModal( true ); onAdd={ onAddNewAttributes }
} } selectedAttributeIds={ ( filteredAttributes || [] ).map(
> ( attr ) => attr.id
{ __( 'Add first attribute', 'woocommerce' ) }
</Button>
</div>
{ showAddAttributeModal && (
<AddAttributeModal
onCancel={ () => {
recordEvent( CANCEL_BUTTON_EVENT_NAME );
setShowAddAttributeModal( false );
} }
onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( value || [] ).map(
( attr ) => attr.id
) }
/>
) } ) }
<SelectControlMenuSlot /> />
</div> ) }
</CardBody> <SelectControlMenuSlot />
</Card> </>
); );
} }
const sortedAttributes = value.sort( ( a, b ) => a.position - b.position ); const sortedAttributes = filteredAttributes.sort(
const attributeKeyValues = value.reduce( ( a, b ) => a.position - b.position
);
const attributeKeyValues = filteredAttributes.reduce(
( (
keyValue: Record< number, ProductAttribute >, keyValue: Record< number, ProductAttribute >,
attribute: ProductAttribute attribute: ProductAttribute
@ -229,6 +253,20 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
{} as Record< number, ProductAttribute > {} as Record< number, ProductAttribute >
); );
const attribute = hydratedAttributes.find(
( attr ) => fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType;
const editAttributeCopy = isOnlyForVariations
? __(
`You can change the option's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
)
: __(
`You can change the attribute's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
);
return ( return (
<div className="woocommerce-attribute-field"> <div className="woocommerce-attribute-field">
<Sortable <Sortable
@ -241,66 +279,39 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
); );
} } } }
> >
{ sortedAttributes.map( ( attribute ) => ( { sortedAttributes.map( ( attr ) => (
<ListItem key={ fetchAttributeId( attribute ) }> <AttributeListItem
<div>{ attribute.name }</div> attribute={ attr }
<div className="woocommerce-attribute-field__attribute-options"> key={ fetchAttributeId( attr ) }
{ attribute.options onEditClick={ () =>
.slice( 0, 2 ) setEditingAttributeId( fetchAttributeId( attr ) )
.map( ( option, index ) => ( }
<div onRemoveClick={ () => onRemove( attr ) }
className="woocommerce-attribute-field__attribute-option-chip" />
key={ index }
>
{ option }
</div>
) ) }
{ attribute.options.length > 2 && (
<div className="woocommerce-attribute-field__attribute-option-chip">
{ sprintf(
__( '+ %i more', 'woocommerce' ),
attribute.options.length - 2
) }
</div>
) }
</div>
<div className="woocommerce-attribute-field__attribute-actions">
<Button
variant="tertiary"
onClick={ () =>
setEditingAttributeId(
fetchAttributeId( attribute )
)
}
>
{ __( 'edit', 'woocommerce' ) }
</Button>
<Button
icon={ closeSmall }
label={ __(
'Remove attribute',
'woocommerce'
) }
onClick={ () => onRemove( attribute ) }
></Button>
</div>
</ListItem>
) ) } ) ) }
</Sortable> </Sortable>
<ListItem> <AddAttributeListItem
<Button label={
variant="secondary" isOnlyForVariations
className="woocommerce-attribute-field__add-attribute" ? __( 'Add option', 'woocommerce' )
onClick={ () => { : undefined
recordEvent( 'product_add_attribute_button' ); }
setShowAddAttributeModal( true ); onAddClick={ () => {
} } recordEvent(
> isOnlyForVariations
{ __( 'Add attribute', 'woocommerce' ) } ? 'product_add_option_button'
</Button> : 'product_add_attribute_button'
</ListItem> );
setShowAddAttributeModal( true );
} }
/>
{ showAddAttributeModal && ( { showAddAttributeModal && (
<AddAttributeModal <AddAttributeModal
title={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onCancel={ () => { onCancel={ () => {
recordEvent( CANCEL_BUTTON_EVENT_NAME ); recordEvent( CANCEL_BUTTON_EVENT_NAME );
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
@ -312,12 +323,37 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
<SelectControlMenuSlot /> <SelectControlMenuSlot />
{ editingAttributeId && ( { editingAttributeId && (
<EditAttributeModal <EditAttributeModal
title={
/* translators: %s is the attribute name */
sprintf(
__( 'Edit %s', 'woocommerce' ),
attribute.name
)
}
globalAttributeHelperMessage={ interpolateComponents( {
mixedString: editAttributeCopy,
components: {
link: (
<Link
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
target="_blank"
type="wp-admin"
>
<></>
</Link>
),
},
} ) }
onCancel={ () => setEditingAttributeId( null ) } onCancel={ () => setEditingAttributeId( null ) }
onEdit={ ( changedAttribute ) => { onEdit={ ( changedAttribute ) => {
const newAttributesSet = [ ...hydratedAttributes ]; const newAttributesSet = [ ...hydratedAttributes ];
const changedAttributeIndex: number = const changedAttributeIndex: number =
newAttributesSet.findIndex( newAttributesSet.findIndex( ( attr ) =>
( attr ) => attr.id === changedAttribute.id attr.id !== 0
? attr.id === changedAttribute.id
: attr.name === changedAttribute.name
); );
newAttributesSet.splice( newAttributesSet.splice(
@ -329,12 +365,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
updateAttributes( newAttributesSet ); updateAttributes( newAttributesSet );
setEditingAttributeId( null ); setEditingAttributeId( null );
} } } }
attribute={ attribute={ attribute }
hydratedAttributes.find(
( attr ) =>
fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType
}
/> />
) } ) }
</div> </div>

View File

@ -9,12 +9,7 @@ import {
TextControl, TextControl,
} from '@wordpress/components'; } from '@wordpress/components';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { import { __experimentalTooltip as Tooltip } from '@woocommerce/components';
__experimentalTooltip as Tooltip,
Link,
} from '@woocommerce/components';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
/** /**
* Internal dependencies * Internal dependencies
@ -28,12 +23,42 @@ import { HydratedAttributeType } from './attribute-field';
import './edit-attribute-modal.scss'; import './edit-attribute-modal.scss';
type EditAttributeModalProps = { type EditAttributeModalProps = {
title?: string;
nameLabel?: string;
globalAttributeHelperMessage?: JSX.Element;
customAttributeHelperMessage?: string;
termsLabel?: string;
termsPlaceholder?: string;
visibleLabel?: string;
visibleTooltip?: string;
cancelAccessibleLabel?: string;
cancelLabel?: string;
updateAccessibleLabel?: string;
updateLabel?: string;
onCancel: () => void; onCancel: () => void;
onEdit: ( alteredAttribute: HydratedAttributeType ) => void; onEdit: ( alteredAttribute: HydratedAttributeType ) => void;
attribute: HydratedAttributeType; attribute: HydratedAttributeType;
}; };
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
title = __( 'Edit attribute', 'woocommerce' ),
nameLabel = __( 'Name', 'woocommerce' ),
globalAttributeHelperMessage,
customAttributeHelperMessage = __(
'Your customers will see this on the product page',
'woocommerce'
),
termsLabel = __( 'Values', 'woocommerce' ),
termsPlaceholder = __( 'Search or create value', 'woocommerce' ),
visibleLabel = __( 'Visible to customers', 'woocommerce' ),
visibleTooltip = __(
'Show or hide this attribute on the product page',
'woocommerce'
),
cancelAccessibleLabel = __( 'Cancel', 'woocommerce' ),
cancelLabel = __( 'Cancel', 'woocommerce' ),
updateAccessibleLabel = __( 'Edit attribute', 'woocommerce' ),
updateLabel = __( 'Update', 'woocommerce' ),
onCancel, onCancel,
onEdit, onEdit,
attribute, attribute,
@ -46,13 +71,13 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
return ( return (
<Modal <Modal
title={ __( 'Edit attribute', 'woocommerce' ) } title={ title }
onRequestClose={ () => onCancel() } onRequestClose={ () => onCancel() }
className="woocommerce-edit-attribute-modal" className="woocommerce-edit-attribute-modal"
> >
<div className="woocommerce-edit-attribute-modal__body"> <div className="woocommerce-edit-attribute-modal__body">
<TextControl <TextControl
label={ __( 'Name', 'woocommerce' ) } label={ nameLabel }
disabled={ ! isCustomAttribute } disabled={ ! isCustomAttribute }
value={ value={
editableAttribute?.name ? editableAttribute?.name : '' editableAttribute?.name ? editableAttribute?.name : ''
@ -66,37 +91,13 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
/> />
<p className="woocommerce-edit-attribute-modal__helper-text"> <p className="woocommerce-edit-attribute-modal__helper-text">
{ ! isCustomAttribute { ! isCustomAttribute
? interpolateComponents( { ? globalAttributeHelperMessage
mixedString: __( : customAttributeHelperMessage }
`You can change the attribute's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
),
components: {
link: (
<Link
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
target="_blank"
type="wp-admin"
>
<></>
</Link>
),
},
} )
: __(
'Your customers will see this on the product page',
'woocommerce'
) }
</p> </p>
{ attribute.terms ? ( { attribute.terms ? (
<AttributeTermInputField <AttributeTermInputField
label={ __( 'Values', 'woocommerce' ) } label={ termsLabel }
placeholder={ __( placeholder={ termsPlaceholder }
'Search or create value',
'woocommerce'
) }
value={ editableAttribute?.terms } value={ editableAttribute?.terms }
attributeId={ editableAttribute?.id } attributeId={ editableAttribute?.id }
onChange={ ( val ) => { onChange={ ( val ) => {
@ -108,11 +109,8 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
/> />
) : ( ) : (
<CustomAttributeTermInputField <CustomAttributeTermInputField
label={ __( 'Values', 'woocommerce' ) } label={ termsLabel }
placeholder={ __( placeholder={ termsPlaceholder }
'Search or create value',
'woocommerce'
) }
disabled={ ! attribute?.name } disabled={ ! attribute?.name }
value={ editableAttribute?.options } value={ editableAttribute?.options }
onChange={ ( val ) => { onChange={ ( val ) => {
@ -133,50 +131,27 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
} ) } )
} }
checked={ editableAttribute?.visible } checked={ editableAttribute?.visible }
label={ __( 'Visible to customers', 'woocommerce' ) } label={ visibleLabel }
/>
<Tooltip
text={ __(
'Show or hide this attribute on the product page',
'woocommerce'
) }
/>
</div>
<div className="woocommerce-edit-attribute-modal__option-container">
<CheckboxControl
onChange={ ( val ) =>
setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ),
variation: val,
} )
}
checked={ editableAttribute?.variation }
label={ __( 'Used for filters', 'woocommerce' ) }
/>
<Tooltip
text={ __(
`Show or hide this attribute in the filters section on your store's category and shop pages`,
'woocommerce'
) }
/> />
<Tooltip text={ visibleTooltip } />
</div> </div>
</div> </div>
<div className="woocommerce-add-attribute-modal__buttons"> <div className="woocommerce-add-attribute-modal__buttons">
<Button <Button
isSecondary isSecondary
label={ __( 'Cancel', 'woocommerce' ) } label={ cancelAccessibleLabel }
onClick={ () => onCancel() } onClick={ () => onCancel() }
> >
{ __( 'Cancel', 'woocommerce' ) } { cancelLabel }
</Button> </Button>
<Button <Button
isPrimary isPrimary
label={ __( 'Edit attribute', 'woocommerce' ) } label={ updateAccessibleLabel }
onClick={ () => { onClick={ () => {
onEdit( editableAttribute as HydratedAttributeType ); onEdit( editableAttribute as HydratedAttributeType );
} } } }
> >
{ __( 'Update', 'woocommerce' ) } { updateLabel }
</Button> </Button>
</div> </div>
</Modal> </Modal>

View File

@ -26,7 +26,7 @@ const attributeList: ProductAttribute[] = [
visible: true, visible: true,
variation: true, variation: true,
options: [ options: [
'Beige', 'beige',
'black', 'black',
'Blue', 'Blue',
'brown', 'brown',
@ -134,23 +134,24 @@ describe( 'AttributeField', () => {
await screen.findByText( attributeList[ 0 ].name ) await screen.findByText( attributeList[ 0 ].name )
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
await screen.findByText( attributeList[ 1 ].name ) await screen.queryByText( attributeList[ 1 ].name )
).toBeInTheDocument(); ).not.toBeInTheDocument();
} ); } );
it( 'should render the first two terms of each attribute, and show "+ n more" for the rest', async () => { it( 'should render the first two terms of each option, and show "+ n more" for the rest', async () => {
act( () => { act( () => {
render( render(
<AttributeField <AttributeField
value={ [ ...attributeList ] } value={ [ ...attributeList ] }
onChange={ () => {} } onChange={ () => {} }
attributeType="for-variations"
/> />
); );
} ); } );
expect( expect(
await screen.findByText( attributeList[ 0 ].options[ 0 ] ) await screen.queryByText( attributeList[ 0 ].options[ 0 ] )
).toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
await screen.findByText( attributeList[ 1 ].options[ 0 ] ) await screen.findByText( attributeList[ 1 ].options[ 0 ] )
).toBeInTheDocument(); ).toBeInTheDocument();

View File

@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { DragEventHandler } from 'react';
import { Button } from '@wordpress/components';
import { ListItem } from '@woocommerce/components';
type AddAttributeListItemProps = {
label?: string;
onAddClick?: () => void;
};
export const AddAttributeListItem: React.FC< AddAttributeListItemProps > = ( {
label = __( 'Add attribute', 'woocommerce' ),
onAddClick,
} ) => {
return (
<ListItem className="woocommerce-add-attribute-list-item">
<Button
variant="secondary"
className="woocommerce-add-attribute-list-item__add-button"
onClick={ onAddClick }
>
{ label }
</Button>
</ListItem>
);
};

View File

@ -0,0 +1,40 @@
.woocommerce-add-attribute-list-item,
.woocommerce-attribute-list-item {
min-height: 82px;
padding: 0 $gap-large;
&:last-child {
margin-bottom: -1px;
}
}
.woocommerce-attribute-list-item {
display: grid;
grid-template-columns: 24px 26% auto 90px;
&:last-child {
margin-bottom: -1px;
}
&__options {
display: flex;
flex-direction: row;
gap: $gap-smallest;
}
&__option-chip {
padding: $gap-smallest $gap-smaller;
gap: 2px;
background: $gray-100;
border-radius: 2px;
}
&__actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: end;
gap: $gap-smaller;
}
}

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { DragEventHandler } from 'react';
import { ListItem } from '@woocommerce/components';
import { ProductAttribute } from '@woocommerce/data';
import { sprintf, __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { closeSmall } from '@wordpress/icons';
/**
* Internal dependencies
*/
import './attribute-list-item.scss';
type AttributeListItemProps = {
attribute: ProductAttribute;
editLabel?: string;
removeLabel?: string;
onDragStart?: DragEventHandler< HTMLDivElement >;
onDragEnd?: DragEventHandler< HTMLDivElement >;
onEditClick?: ( attribute: ProductAttribute ) => void;
onRemoveClick?: ( attribute: ProductAttribute ) => void;
};
export const AttributeListItem: React.FC< AttributeListItemProps > = ( {
attribute,
editLabel = __( 'edit', 'woocommerce' ),
removeLabel = __( 'Remove attribute', 'woocommerce' ),
onDragStart,
onDragEnd,
onEditClick,
onRemoveClick,
} ) => {
return (
<ListItem
className="woocommerce-attribute-list-item"
onDragStart={ onDragStart }
onDragEnd={ onDragEnd }
>
<div>{ attribute.name }</div>
<div className="woocommerce-attribute-list-item__options">
{ attribute.options.slice( 0, 2 ).map( ( option, index ) => (
<div
className="woocommerce-attribute-list-item__option-chip"
key={ index }
>
{ option }
</div>
) ) }
{ attribute.options.length > 2 && (
<div className="woocommerce-attribute-list-item__option-chip">
{ sprintf(
__( '+ %i more', 'woocommerce' ),
attribute.options.length - 2
) }
</div>
) }
</div>
<div className="woocommerce-attribute-list-item__actions">
{ typeof onEditClick === 'function' && (
<Button
variant="tertiary"
onClick={ () => onEditClick( attribute ) }
>
{ editLabel }
</Button>
) }
{ typeof onRemoveClick === 'function' && (
<Button
icon={ closeSmall }
label={ removeLabel }
onClick={ () => onRemoveClick( attribute ) }
></Button>
) }
</div>
</ListItem>
);
};

View File

@ -0,0 +1,2 @@
export * from './add-attribute-list-item';
export * from './attribute-list-item';

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
type AttributesProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
};
export const Attributes: React.FC< AttributesProps > = ( {
value,
onChange,
productId,
} ) => {
return (
<AttributeField
attributeType="regular"
value={ value }
onChange={ onChange }
productId={ productId }
/>
);
};

View File

@ -0,0 +1 @@
export * from './attributes';

View File

@ -0,0 +1 @@
export * from './options';

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
type OptionsProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
};
export const Options: React.FC< OptionsProps > = ( {
value,
onChange,
productId,
} ) => {
return (
<AttributeField
attributeType="for-variations"
value={ value }
onChange={ onChange }
productId={ productId }
/>
);
};

View File

@ -0,0 +1 @@
export * from './single-image-field';

View File

@ -0,0 +1,28 @@
.woocommerce-single-image-field {
&__gallery {
margin-top: $gap-smaller;
.woocommerce-image-gallery .woocommerce-sortable {
margin: 0;
}
}
&__drop-zone {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: calc($gap * 2) 0;
margin-top: $gap-smaller;
gap: calc($gap * 2);
isolation: isolate;
min-height: 144px;
background: $white;
border: 1px dashed $gray-700;
border-radius: 2px;
position: relative;
}
}

View File

@ -0,0 +1,107 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
MediaUploader,
ImageGallery,
ImageGalleryItem,
} from '@woocommerce/components';
import { MediaItem } from '@wordpress/media-utils';
import uniqueId from 'lodash/uniqueId';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './single-image-field.scss';
export function SingleImageField( {
id,
label,
value,
className,
onChange,
...props
}: SingleImageFieldProps ) {
const fieldId = id ?? uniqueId();
function handleChange( image?: MediaItem ) {
if ( typeof onChange === 'function' ) {
onChange( image );
}
}
return (
<div
{ ...props }
className={ classNames(
'woocommerce-single-image-field',
className
) }
>
<label
htmlFor={ fieldId }
className="components-base-control__label woocommerce-single-image-field__label"
>
{ label }
</label>
{ value ? (
<div
id={ fieldId }
className="woocommerce-single-image-field__gallery"
tabIndex={ -1 }
role="region"
>
<ImageGallery
onReplace={ ( { media } ) => handleChange( media ) }
onRemove={ () => handleChange( undefined ) }
>
<ImageGalleryItem
key={ value.id }
id={ String( value.id ) }
alt={ value.alt }
src={ value.url }
/>
</ImageGallery>
</div>
) : (
<div
id={ fieldId }
className="woocommerce-single-image-field__drop-zone"
tabIndex={ -1 }
role="region"
>
<MediaUploader
onError={ () => null }
onSelect={ ( image ) =>
handleChange( image as MediaItem )
}
onUpload={ ( [ image ] ) => handleChange( image ) }
onFileUploadChange={ ( [ image ] ) =>
handleChange( image )
}
label={ __(
'Drag image here or click to upload',
'woocommerce'
) }
buttonText={ __( 'Choose image', 'woocommerce' ) }
/>
</div>
) }
</div>
);
}
export type SingleImageFieldProps = Omit<
React.DetailedHTMLProps<
React.HTMLAttributes< HTMLDivElement >,
HTMLDivElement
>,
'onChange'
> & {
label: string;
value?: MediaItem;
onChange?( value?: MediaItem ): void;
};

View File

@ -1,5 +1,9 @@
.woocommerce-product-variations { .woocommerce-product-variations {
min-height: 300px; ol {
@media ( min-width: #{ ($break-medium) } ) {
min-height: 420px;
}
}
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> div { > div {
@ -10,7 +14,7 @@
&__header { &__header {
display: grid; display: grid;
grid-template-columns: calc(38px + 25%) 25% 25%; grid-template-columns: auto 25% 25% 88px;
padding: $gap-small $gap; padding: $gap-small $gap;
h4 { h4 {
@ -22,12 +26,63 @@
} }
} }
&__status-dot {
margin-right: $gap-smaller;
&.green {
color: $alert-green;
}
&.yellow {
color: $alert-yellow;
}
&.red {
color: $alert-red;
}
}
&__price--fade,
&__quantity--fade {
opacity: 0.5;
}
&__actions {
display: flex;
align-items: center;
justify-content: end;
.components-button {
position: relative;
color: var(--wp-admin-theme-color);
&:disabled,
&[aria-disabled='true'] {
opacity: 1;
}
.components-spinner {
margin: 4px;
}
}
.components-button svg {
fill: none;
}
.components-button--visible {
color: $gray-700;
}
.components-button--hidden {
color: $alert-red;
}
}
.woocommerce-list-item { .woocommerce-list-item {
display: grid; display: grid;
grid-template-columns: 38px 25% 25% 25%; grid-template-columns: 38px auto 25% 25% 88px;
margin-left: -1px; margin-left: -1px;
margin-right: -1px; margin-right: -1px;
margin-bottom: -1px; margin-bottom: -1px;
min-height: 84px;
} }
.woocommerce-sortable { .woocommerce-sortable {
@ -35,7 +90,7 @@
flex: 1 0 auto; flex: 1 0 auto;
} }
.components-spinner { &.is-loading .components-spinner {
width: 34px; width: 34px;
height: 34px; height: 34px;
left: 50%; left: 50%;

View File

@ -2,21 +2,37 @@
* External dependencies * External dependencies
*/ */
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { Card, Spinner } from '@wordpress/components'; import { Button, Card, Spinner, Tooltip } from '@wordpress/components';
import { import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
ProductVariation, ProductVariation,
} from '@woocommerce/data'; } from '@woocommerce/data';
import { ListItem, Pagination, Sortable, Tag } from '@woocommerce/components'; import {
Link,
ListItem,
Pagination,
Sortable,
Tag,
} from '@woocommerce/components';
import { getNewPath } from '@woocommerce/navigation';
import { useContext, useState } from '@wordpress/element'; import { useContext, useState } from '@wordpress/element';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useSelect } from '@wordpress/data'; import { useSelect, useDispatch } from '@wordpress/data';
import classnames from 'classnames';
import truncate from 'lodash/truncate';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { PRODUCT_VARIATION_TITLE_LIMIT } from '~/products/constants';
import useVariationsOrder from '~/products/hooks/use-variations-order';
import HiddenIcon from '~/products/images/hidden-icon';
import VisibleIcon from '~/products/images/visible-icon';
import { CurrencyContext } from '../../../lib/currency-context'; import { CurrencyContext } from '../../../lib/currency-context';
import { getProductStockStatus } from '../../utils/get-product-stock-status'; import {
getProductStockStatus,
getProductStockStatusClass,
} from '../../utils/get-product-stock-status';
import './variations.scss'; import './variations.scss';
/** /**
@ -29,9 +45,16 @@ import './variations.scss';
*/ */
const DEFAULT_PER_PAGE_OPTION = 25; const DEFAULT_PER_PAGE_OPTION = 25;
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
const VISIBLE_TEXT = __( 'Visible to customers', 'woocommerce' );
const UPDATING_TEXT = __( 'Updating product variation', 'woocommerce' );
export const Variations: React.FC = () => { export const Variations: React.FC = () => {
const [ currentPage, setCurrentPage ] = useState( 1 ); const [ currentPage, setCurrentPage ] = useState( 1 );
const [ perPage, setPerPage ] = useState( DEFAULT_PER_PAGE_OPTION ); const [ perPage, setPerPage ] = useState( DEFAULT_PER_PAGE_OPTION );
const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >(
{}
);
const { productId } = useParams(); const { productId } = useParams();
const context = useContext( CurrencyContext ); const context = useContext( CurrencyContext );
const { formatAmount, getCurrencyConfig } = context; const { formatAmount, getCurrencyConfig } = context;
@ -46,6 +69,8 @@ export const Variations: React.FC = () => {
product_id: productId, product_id: productId,
page: currentPage, page: currentPage,
per_page: perPage, per_page: perPage,
order: 'asc',
orderby: 'menu_order',
}; };
return { return {
isLoading: ! hasFinishedResolution( 'getProductVariations', [ isLoading: ! hasFinishedResolution( 'getProductVariations', [
@ -60,6 +85,13 @@ export const Variations: React.FC = () => {
[ currentPage, perPage ] [ currentPage, perPage ]
); );
const { updateProductVariation } = useDispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const { sortedVariations, getVariationKey, onOrderChange } =
useVariationsOrder( { variations, currentPage } );
if ( ! variations || isLoading ) { if ( ! variations || isLoading ) {
return ( return (
<Card className="woocommerce-product-variations is-loading"> <Card className="woocommerce-product-variations is-loading">
@ -70,6 +102,26 @@ export const Variations: React.FC = () => {
const currencyConfig = getCurrencyConfig(); const currencyConfig = getCurrencyConfig();
function handleCustomerVisibilityClick(
variationId: number,
status: 'private' | 'publish'
) {
if ( isUpdating[ variationId ] ) return;
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: true,
} ) );
updateProductVariation< Promise< ProductVariation > >(
{ product_id: productId, id: variationId },
{ status }
).finally( () =>
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
}
return ( return (
<Card className="woocommerce-product-variations"> <Card className="woocommerce-product-variations">
<div className="woocommerce-product-variations__header"> <div className="woocommerce-product-variations__header">
@ -83,27 +135,143 @@ export const Variations: React.FC = () => {
</h4> </h4>
<h4>{ __( 'Quantity', 'woocommerce' ) }</h4> <h4>{ __( 'Quantity', 'woocommerce' ) }</h4>
</div> </div>
<Sortable> <Sortable onOrderChange={ onOrderChange }>
{ variations.map( ( variation ) => ( { sortedVariations.map( ( variation ) => (
<ListItem key={ variation.id }> <ListItem key={ getVariationKey( variation ) }>
<div className="woocommerce-product-variations__attributes"> <div className="woocommerce-product-variations__attributes">
{ variation.attributes.map( ( attribute ) => ( { variation.attributes.map( ( attribute ) => {
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ const tag = (
/* @ts-ignore Additional props are not required. */ /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
<Tag /* @ts-ignore Additional props are not required. */
id={ attribute.id } <Tag
className="woocommerce-product-variations__attribute" id={ attribute.id }
key={ attribute.id } className="woocommerce-product-variations__attribute"
label={ attribute.option } key={ attribute.id }
/> label={ truncate( attribute.option, {
) ) } length: PRODUCT_VARIATION_TITLE_LIMIT,
} ) }
screenReaderLabel={ attribute.option }
/>
);
return attribute.option.length <=
PRODUCT_VARIATION_TITLE_LIMIT ? (
tag
) : (
<Tooltip
key={ attribute.id }
text={ attribute.option }
position="top center"
>
<span>{ tag }</span>
</Tooltip>
);
} ) }
</div> </div>
<div className="woocommerce-product-variations__price"> <div
className={ classnames(
'woocommerce-product-variations__price',
{
'woocommerce-product-variations__price--fade':
variation.status === 'private',
}
) }
>
{ formatAmount( variation.price ) } { formatAmount( variation.price ) }
</div> </div>
<div className="woocommerce-product-variations__quantity"> <div
className={ classnames(
'woocommerce-product-variations__quantity',
{
'woocommerce-product-variations__quantity--fade':
variation.status === 'private',
}
) }
>
<span
className={ classnames(
'woocommerce-product-variations__status-dot',
getProductStockStatusClass( variation )
) }
>
</span>
{ getProductStockStatus( variation ) } { getProductStockStatus( variation ) }
</div> </div>
<div className="woocommerce-product-variations__actions">
<Link
href={ getNewPath(
{},
`/product/${ productId }/variation/${ variation.id }`
) }
type="wc-admin"
className="components-button"
>
{ __( 'Edit', 'woocommerce' ) }
</Link>
{ variation.status === 'private' && (
<Tooltip
position="top center"
text={ NOT_VISIBLE_TEXT }
>
<Button
className="components-button--hidden"
aria-label={
isUpdating[ variation.id ]
? UPDATING_TEXT
: NOT_VISIBLE_TEXT
}
aria-disabled={
isUpdating[ variation.id ]
}
onClick={ () =>
handleCustomerVisibilityClick(
variation.id,
'publish'
)
}
>
{ isUpdating[ variation.id ] ? (
<Spinner />
) : (
<HiddenIcon />
) }
</Button>
</Tooltip>
) }
{ variation.status === 'publish' && (
<Tooltip
position="top center"
text={ VISIBLE_TEXT }
>
<Button
className="components-button--visible"
aria-label={
isUpdating[ variation.id ]
? UPDATING_TEXT
: VISIBLE_TEXT
}
aria-disabled={
isUpdating[ variation.id ]
}
onClick={ () =>
handleCustomerVisibilityClick(
variation.id,
'private'
)
}
>
{ isUpdating[ variation.id ] ? (
<Spinner />
) : (
<VisibleIcon />
) }
</Button>
</Tooltip>
) }
</div>
</ListItem> </ListItem>
) ) } ) ) }
</Sortable> </Sortable>

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { PartialProduct, ProductVariation } from '@woocommerce/data';
import { getNewPath, getPersistedQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { PostsNavigationProps } from '../shared/posts-navigation';
export default function useProductVariationNavigation( {
product,
productVariation,
}: UseProductVariationNavigationInput ): UseProductVariationNavigationOutput {
const { variations } = product;
const variationIds = variations ?? [];
const currentIndex = variationIds.indexOf( productVariation.id ?? -1 );
const canNavigatePrev = currentIndex > 0;
const canNavigateNext = currentIndex < variationIds.length - 1;
const prevVariationId = canNavigatePrev
? variationIds[ currentIndex - 1 ]
: undefined;
const nextVariationId = canNavigateNext
? variationIds[ currentIndex + 1 ]
: undefined;
const persistedQuery = getPersistedQuery();
return {
actionHref: getNewPath( persistedQuery, `/product/${ product.id }` ),
prevHref: prevVariationId
? getNewPath(
persistedQuery,
`/product/${ product.id }/variation/${ prevVariationId }`
)
: undefined,
nextHref: nextVariationId
? getNewPath(
persistedQuery,
`/product/${ product.id }/variation/${ nextVariationId }`
)
: undefined,
};
}
export type UseProductVariationNavigationInput = {
product: PartialProduct;
productVariation: Partial< ProductVariation >;
};
export type UseProductVariationNavigationOutput = Pick<
PostsNavigationProps,
'actionHref' | 'prevHref' | 'nextHref'
>;

View File

@ -0,0 +1,97 @@
/**
* External dependencies
*/
import { useFormContext } from '@woocommerce/components';
import type { ProductVariation } from '@woocommerce/data';
/**
* Internal dependencies
*/
const KEY_SEPARATOR = ':';
function getVariationKey( variation: ProductVariation ) {
return `${ variation.id }${ KEY_SEPARATOR }${ variation.menu_order }`;
}
function getVariationId( { key }: JSX.Element ) {
return typeof key === 'string'
? Number.parseInt( key.split( KEY_SEPARATOR )[ 0 ], 10 )
: 0;
}
function getVariationOrder( { key }: JSX.Element ) {
return typeof key === 'string'
? Number.parseInt( key.split( KEY_SEPARATOR )[ 1 ], 10 )
: Number.MAX_SAFE_INTEGER;
}
function sort(
variations: ProductVariation[],
currentPage: number,
{ variationsOrder }: ProductVariationsOrder
) {
if ( ! variationsOrder || ! variationsOrder[ currentPage ] )
return variations;
const currentPageVariationsOrder = variationsOrder[ currentPage ];
return [ ...variations ].sort( ( a, b ) => {
if (
! currentPageVariationsOrder[ a.id ] ||
! currentPageVariationsOrder[ b.id ]
)
return 0;
return (
currentPageVariationsOrder[ a.id ] -
currentPageVariationsOrder[ b.id ]
);
} );
}
export default function useVariationsOrder( {
variations,
currentPage,
}: UseVariationsOrderInput ): UseVariationsOrderOutput {
const { setValue, values } = useFormContext< ProductVariationsOrder >();
function onOrderChange( items: JSX.Element[] ) {
const minOrder = Math.min( ...items.map( getVariationOrder ) );
setValue( 'variationsOrder', {
...values.variationsOrder,
[ currentPage ]: items.reduce( ( prev, item, index ) => {
const id = getVariationId( item );
return {
...prev,
[ id ]: minOrder + index,
};
}, {} ),
} );
}
return {
sortedVariations: sort( variations, currentPage, values ),
getVariationKey,
onOrderChange,
};
}
export type UseVariationsOrderInput = {
variations: ProductVariation[];
currentPage: number;
};
export type UseVariationsOrderOutput = {
sortedVariations: ProductVariation[];
getVariationKey( variation: ProductVariation ): string;
onOrderChange( items: JSX.Element[] ): void;
};
export type ProductVariationsOrder = {
variationsOrder?: {
[ page: number ]: {
[ variationId: number ]: number;
};
};
};

View File

@ -4,14 +4,13 @@ export const FeedbackIcon = () => {
width="16" width="16"
height="17" height="17"
viewBox="0 0 16 17" viewBox="0 0 16 17"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M2.68822 12.625L1.5 13.8145L1.5 1.5L14.5 1.5L14.5 12.625L2.68822 12.625ZM3.31 14.125L15 14.125C15.5523 14.125 16 13.6773 16 13.125L16 1C16 0.447717 15.5523 0 15 0H1C0.447717 0 0 0.447716 0 1V15.5247C0 15.8173 0.161234 16.086 0.419354 16.2237C0.727111 16.3878 1.10601 16.3313 1.35252 16.0845L3.31 14.125ZM12 5.99997H4V4.49997H12V5.99997ZM4 9.99997H9V8.49997H4V9.99997Z" d="M2.68822 12.625L1.5 13.8145L1.5 1.5L14.5 1.5L14.5 12.625L2.68822 12.625ZM3.31 14.125L15 14.125C15.5523 14.125 16 13.6773 16 13.125L16 1C16 0.447717 15.5523 0 15 0H1C0.447717 0 0 0.447716 0 1V15.5247C0 15.8173 0.161234 16.086 0.419354 16.2237C0.727111 16.3878 1.10601 16.3313 1.35252 16.0845L3.31 14.125ZM12 5.99997H4V4.49997H12V5.99997ZM4 9.99997H9V8.49997H4V9.99997Z"
fill="#1E1E1E" fill="currentColor"
/> />
</svg> </svg>
); );

View File

@ -0,0 +1,32 @@
export default function HiddenIcon( {
width = 24,
height = 24,
...props
}: React.SVGProps< SVGSVGElement > ) {
return (
<svg
{ ...props }
width={ width }
height={ height }
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.7226 6.2125C13.1641 6.0766 12.5883 6 11.9999 6C8.10055 6 4.75407 9.36447 3.31899 11.0546C2.8507 11.6061 2.8507 12.3939 3.31899 12.9454C4.17896 13.9582 5.72533 15.5723 7.66574 16.7033L8.41572 15.4043C8.13761 15.242 7.86389 15.0655 7.59553 14.8776C6.25019 13.9359 5.15775 12.7905 4.48406 12C5.15775 11.2095 6.25019 10.0641 7.59553 9.12235C8.96667 8.16257 10.4775 7.5 11.9999 7.5C12.3118 7.5 12.6231 7.5278 12.9329 7.58027L13.7226 6.2125ZM12.3504 8.58923C12.2352 8.57753 12.1182 8.57153 11.9999 8.57153C10.1063 8.57153 8.57132 10.1066 8.57132 12.0001C8.57132 12.7505 8.81237 13.4445 9.22126 14.0091L10.1233 12.4467C10.0893 12.3034 10.0713 12.1538 10.0713 12.0001C10.0713 11.1266 10.652 10.3888 11.4484 10.1515L12.3504 8.58923ZM12.8092 10.2491L13.5611 8.94679C14.6697 9.51479 15.4285 10.6688 15.4285 12.0001C15.4285 13.8937 13.8934 15.4287 11.9999 15.4287C11.3128 15.4287 10.6729 15.2266 10.1364 14.8785L10.8883 13.5763C11.2025 13.7983 11.5859 13.9287 11.9999 13.9287C13.065 13.9287 13.9285 13.0652 13.9285 12.0001C13.9285 11.224 13.4701 10.555 12.8092 10.2491ZM9.51376 15.957C10.3246 16.2986 11.1605 16.5 11.9999 16.5C13.5223 16.5 15.0331 15.8374 16.4043 14.8776C17.7496 13.9359 18.842 12.7905 19.5157 12C18.842 11.2095 17.7496 10.0641 16.4043 9.12235C15.6875 8.62066 14.9327 8.20018 14.1579 7.91308L14.917 6.59839C17.5164 7.64275 19.6204 9.80575 20.6808 11.0546C21.1491 11.6061 21.1491 12.3939 20.6808 12.9454C19.2457 14.6355 15.8992 18 11.9999 18C10.8611 18 9.76945 17.713 8.7588 17.2646L9.51376 15.957Z"
fill="currentColor"
/>
<rect
x="16.0625"
y="4.61377"
width="1.22727"
height="16"
transform="rotate(30 16.0625 4.61377)"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,31 @@
export default function VisibleIcon( {
width = 24,
height = 24,
...props
}: React.SVGProps< SVGSVGElement > ) {
return (
<svg
{ ...props }
width={ width }
height={ height }
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M20.1091 11.54C20.3396 11.8116 20.3396 12.1884 20.1091 12.46C19.4144 13.2781 18.266 14.4899 16.8343 15.4921C15.397 16.4982 13.7359 17.25 11.9999 17.25C10.2638 17.25 8.60268 16.4982 7.1654 15.4921C5.73376 14.4899 4.58533 13.2781 3.89066 12.46C3.6601 12.1884 3.6601 11.8116 3.89066 11.54C4.58533 10.7219 5.73376 9.51006 7.1654 8.50792C8.60268 7.50184 10.2638 6.75 11.9999 6.75C13.7359 6.75 15.397 7.50184 16.8343 8.50792C18.266 9.51006 19.4144 10.7219 20.1091 11.54Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle
cx="11.9999"
cy="11.9999"
r="2.67857"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
}

View File

@ -62,6 +62,22 @@ $product-form-tabs-height: 56px;
font-weight: 600; font-weight: 600;
box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) transparent, inset 0 -3px 0 0 var(--wp-admin-theme-color); box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) transparent, inset 0 -3px 0 0 var(--wp-admin-theme-color);
} }
&:disabled,
&[aria-disabled='true'] {
// We need tooltips at full opacity so only child elements have reduced opacity.
opacity: 1;
.woocommerce-product-form-tab__item-inner-text {
opacity: 0.3;
}
}
.woocommerce-product-form-tab__item-inner {
min-height: $product-form-tabs-height;
display: flex;
align-items: center;
}
} }
} }

View File

@ -3,7 +3,8 @@
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Children, useEffect } from '@wordpress/element'; import { Children, useEffect } from '@wordpress/element';
import { TabPanel } from '@wordpress/components'; import { TabPanel, Tooltip } from '@wordpress/components';
import { navigateTo, getNewPath, getQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
@ -14,6 +15,8 @@ import { ProductFormTab } from '../product-form-tab';
export const ProductFormLayout: React.FC< { export const ProductFormLayout: React.FC< {
children: JSX.Element | JSX.Element[]; children: JSX.Element | JSX.Element[];
} > = ( { children } ) => { } > = ( { children } ) => {
const query = getQuery() as Record< string, string >;
useEffect( () => { useEffect( () => {
window.document.body.classList.add( window.document.body.classList.add(
'woocommerce-admin-product-layout' 'woocommerce-admin-product-layout'
@ -32,7 +35,27 @@ export const ProductFormLayout: React.FC< {
} }
return { return {
name: child.props.name, name: child.props.name,
title: child.props.title, title: child.props.disabled ? (
<Tooltip
text={ __(
'Manage individual variation details in the Options tab.',
'woocommerce'
) }
>
<span className="woocommerce-product-form-tab__item-inner">
<span className="woocommerce-product-form-tab__item-inner-text">
{ child.props.title }
</span>
</span>
</Tooltip>
) : (
<span className="woocommerce-product-form-tab__item-inner">
<span className="woocommerce-product-form-tab__item-inner-text">
{ child.props.title }
</span>
</span>
),
disabled: child.props.disabled,
}; };
} ); } );
@ -40,8 +63,16 @@ export const ProductFormLayout: React.FC< {
<TabPanel <TabPanel
className="product-form-layout" className="product-form-layout"
activeClass="is-active" activeClass="is-active"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Disabled properties will be included in newer versions of Gutenberg.
tabs={ tabs } tabs={ tabs }
onSelect={ () => ( window.document.documentElement.scrollTop = 0 ) } initialTabName={ query.tab ?? tabs[ 0 ].name }
onSelect={ ( tabName: string ) => {
window.document.documentElement.scrollTop = 0;
navigateTo( {
url: getNewPath( { tab: tabName } ),
} );
} }
> >
{ ( tab ) => ( { ( tab ) => (
<> <>

View File

@ -0,0 +1,14 @@
/**
* Internal dependencies
*/
import { ProductVariationFormActions } from '../product-variation-form-actions';
import { ProductTitle } from '../product-title';
export const ProductVariationFormHeader: React.FC = () => {
return (
<>
<ProductTitle />
<ProductVariationFormActions />
</>
);
};

View File

@ -6,7 +6,7 @@ $gutenberg-blue-darker: var(--wp-admin-theme-color-darker-20);
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding-right: $gap-smaller; padding-right: $gap-large;
@include breakpoint( '<782px' ) { @include breakpoint( '<782px' ) {
position: fixed; position: fixed;
@ -58,4 +58,8 @@ $gutenberg-blue-darker: var(--wp-admin-theme-color-darker-20);
.woocommerce-layout { .woocommerce-layout {
margin-bottom: calc(70px + $gap); // Product actions height + gap. margin-bottom: calc(70px + $gap); // Product actions height + gap.
} }
.is-variation .woocommerce-product-form-actions__preview {
display: none;
}
} }

View File

@ -4,6 +4,7 @@
import classnames from 'classnames'; import classnames from 'classnames';
export const ProductFormTab: React.FC< { export const ProductFormTab: React.FC< {
disabled?: boolean;
name: string; name: string;
title: string; title: string;
children: JSX.Element | JSX.Element[] | string; children: JSX.Element | JSX.Element[] | string;

View File

@ -16,9 +16,9 @@ import { PricingSection } from './sections/pricing-section';
import { ProductShippingSection } from './sections/product-shipping-section'; import { ProductShippingSection } from './sections/product-shipping-section';
import { ProductVariationsSection } from './sections/product-variations-section'; import { ProductVariationsSection } from './sections/product-variations-section';
import { ImagesSection } from './sections/images-section'; import { ImagesSection } from './sections/images-section';
import './product-page.scss';
import { validate } from './product-validation'; import { validate } from './product-validation';
import { AttributesSection } from './sections/attributes-section'; import { AttributesSection } from './sections/attributes-section';
import { OptionsSection } from './sections/options-section';
import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormFooter } from './layout/product-form-footer';
import { ProductFormTab } from './product-form-tab'; import { ProductFormTab } from './product-form-tab';
@ -48,16 +48,29 @@ export const ProductForm: React.FC< {
<ImagesSection /> <ImagesSection />
<AttributesSection /> <AttributesSection />
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="pricing" title="Pricing"> <ProductFormTab
name="pricing"
title="Pricing"
disabled={ !! product?.variations?.length }
>
<PricingSection /> <PricingSection />
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="inventory" title="Inventory"> <ProductFormTab
name="inventory"
title="Inventory"
disabled={ !! product?.variations?.length }
>
<ProductInventorySection /> <ProductInventorySection />
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="shipping" title="Shipping"> <ProductFormTab
name="shipping"
title="Shipping"
disabled={ !! product?.variations?.length }
>
<ProductShippingSection product={ product } /> <ProductShippingSection product={ product } />
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="options" title="Options"> <ProductFormTab name="options" title="Options">
<OptionsSection />
<ProductVariationsSection /> <ProductVariationsSection />
</ProductFormTab> </ProductFormTab>
</ProductFormLayout> </ProductFormLayout>

View File

@ -38,11 +38,10 @@ export const ProductMoreMenu = () => {
<> <>
<MenuItem <MenuItem
onClick={ () => { onClick={ () => {
// @todo This should open the CES modal.
showCesModal( showCesModal(
{ {
action: 'new_product', action: 'new_product',
label: __( title: __(
"How's your experience with the product editor?", "How's your experience with the product editor?",
'woocommerce' 'woocommerce'
), ),

View File

@ -28,6 +28,10 @@
} }
} }
.woocommerce-product-settings__toggle {
margin-left: -$gap;
}
@include breakpoint( '<782px' ) { @include breakpoint( '<782px' ) {
.woocommerce-product-settings__toggle, .woocommerce-product-settings__toggle,
.woocommerce-product-settings__panel { .woocommerce-product-settings__panel {

View File

@ -2,12 +2,14 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { getAdminLink } from '@woocommerce/settings';
import { import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
Product, Product,
PRODUCTS_STORE_NAME, PRODUCTS_STORE_NAME,
WCDataSelector, WCDataSelector,
} from '@woocommerce/data'; } from '@woocommerce/data';
import { getAdminLink } from '@woocommerce/settings';
import { getNewPath } from '@woocommerce/navigation';
import { useFormContext } from '@woocommerce/components'; import { useFormContext } from '@woocommerce/components';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useSelect } from '@wordpress/data'; import { useSelect } from '@wordpress/data';
@ -16,6 +18,10 @@ import { useSelect } from '@wordpress/data';
* Internal dependencies * Internal dependencies
*/ */
import { getProductTitle } from './utils/get-product-title'; import { getProductTitle } from './utils/get-product-title';
import {
getProductVariationTitle,
getTruncatedProductVariationTitle,
} from './utils/get-product-variation-title';
import { ProductBreadcrumbs } from './product-breadcrumbs'; import { ProductBreadcrumbs } from './product-breadcrumbs';
import { ProductStatusBadge } from './product-status-badge'; import { ProductStatusBadge } from './product-status-badge';
import { WooHeaderPageTitle } from '~/header/utils'; import { WooHeaderPageTitle } from '~/header/utils';
@ -23,35 +29,102 @@ import './product-title.scss';
export const ProductTitle: React.FC = () => { export const ProductTitle: React.FC = () => {
const { values } = useFormContext< Product >(); const { values } = useFormContext< Product >();
const { productId } = useParams(); const { productId, variationId } = useParams();
const { persistedName } = useSelect( ( select: WCDataSelector ) => { const { isLoading, persistedName, productVariation } = useSelect(
const product = productId ( select: WCDataSelector ) => {
? select( PRODUCTS_STORE_NAME ).getProduct( const {
getProduct,
hasFinishedResolution: hasProductFinishedResolution,
} = select( PRODUCTS_STORE_NAME );
const {
getProductVariation,
hasFinishedResolution: hasProductVariationFinishedResolution,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const product = productId
? getProduct( parseInt( productId, 10 ) )
: null;
const variation =
variationId && productId
? getProductVariation( {
id: parseInt( variationId, 10 ),
product_id: parseInt( productId, 10 ),
} )
: null;
const isProductLoading =
productId &&
! hasProductFinishedResolution( 'getProduct', [
parseInt( productId, 10 ), parseInt( productId, 10 ),
undefined ] );
)
: null;
return { const isVariationLoading =
persistedName: product?.name, variationId &&
}; productId &&
} ); ! hasProductVariationFinishedResolution(
'getProductVariation',
[
{
id: parseInt( variationId, 10 ),
product_id: parseInt( productId, 10 ),
},
]
);
const breadcrumbs = [ return {
persistedName: product?.name,
productVariation: variation,
isLoading: isProductLoading || isVariationLoading,
};
}
);
const productTitle = getProductTitle(
values.name,
values.type,
persistedName
);
const productVariationTitle =
productVariation && getProductVariationTitle( productVariation );
const pageHierarchy = [
{ {
href: getAdminLink( 'edit.php?post_type=product' ), href: getAdminLink( 'edit.php?post_type=product' ),
title: __( 'Products', 'woocommerce' ), title: __( 'Products', 'woocommerce' ),
}, },
]; {
const title = getProductTitle( values.name, values.type, persistedName ); href: getNewPath( {}, '/product/' + productId ),
type: 'wc-admin',
title: (
<>
{ productTitle }
<ProductStatusBadge />
</>
),
},
productVariationTitle && {
title: (
<span title={ productVariationTitle }>
{ getTruncatedProductVariationTitle( productVariation ) }
</span>
),
},
].filter( ( page ) => !! page ) as {
href: string;
title: string | JSX.Element;
}[];
const current = pageHierarchy.pop();
if ( isLoading ) {
return null;
}
return ( return (
<WooHeaderPageTitle> <WooHeaderPageTitle>
<span className="woocommerce-product-title"> <span className="woocommerce-product-title">
<ProductBreadcrumbs breadcrumbs={ breadcrumbs } /> <ProductBreadcrumbs breadcrumbs={ pageHierarchy } />
<span className="woocommerce-product-title__wrapper"> <span className="woocommerce-product-title__wrapper">
{ title } { current?.title }
<ProductStatusBadge />
</span> </span>
</span> </span>
</WooHeaderPageTitle> </WooHeaderPageTitle>

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