diff --git a/.github/workflows/cherry-pick.yml b/.github/workflows/cherry-pick.yml index d9a22a4fb85..312ff9d6dcb 100644 --- a/.github/workflows/cherry-pick.yml +++ b/.github/workflows/cherry-pick.yml @@ -1,28 +1,28 @@ name: Cherry Pick Tool on: - issues: - types: [milestoned, labeled] - pull_request: - types: [closed] - workflow_dispatch: - inputs: - release_branch: - description: Provide the release branch you want to cherry pick into. Example release/6.9 - default: '' - required: true - pull_requests: - description: The pull request number. - default: '' - required: true - skipSlackPing: - description: "Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)" - type: boolean - required: false - default: false - slackChannelOverride: - description: "Slack Channel Override: The channel ID to send the Slack ping about the code freeze violation" - required: false - default: '' + issues: + types: [milestoned, labeled] + pull_request: + types: [closed] + workflow_dispatch: + inputs: + release_branch: + description: Provide the release branch you want to cherry pick into. Example release/6.9 + default: '' + required: true + pull_requests: + description: The pull request number. + default: '' + required: true + skipSlackPing: + description: 'Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)' + type: boolean + required: false + default: false + slackChannelOverride: + description: 'Slack Channel Override: The channel ID to send the Slack ping about the code freeze violation' + required: false + default: '' env: GIT_COMMITTER_NAME: 'WooCommerce Bot' @@ -31,320 +31,321 @@ env: GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com' jobs: - verify: - name: Verify - runs-on: ubuntu-20.04 - outputs: - run: ${{ steps.check.outputs.run }} - steps: - - name: check - id: check - uses: actions/github-script@v6 - with: - script: | - let run = false; + verify: + name: Verify + runs-on: ubuntu-20.04 + outputs: + run: ${{ steps.check.outputs.run }} + steps: + - name: check + id: check + uses: actions/github-script@v6 + with: + script: | + let run = false; - 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 isManualTrigger = context.payload.inputs && context.payload.inputs.release_branch && context.payload.inputs.release_branch != 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 ) ) { - 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 ) }} + const isMergedMilestonedPR = context.payload.pull_request && context.payload.pull_request != null && context.payload.pull_request.merged == true && context.payload.pull_request.milestone != null; - // Means this workflow was triggered manually. - if ( event.inputs && event.inputs.release_branch ) { - const releaseBranch = '${{ inputs.release_branch }}' - const version = releaseBranch.replace( 'release/', '' ); + const isBot = context.payload.pull_request && ( context.payload.pull_request.user.login == 'github-actions[bot]' || context.payload.pull_request.user.type == 'Bot' ); - console.log( "::set-output name=version::" + version ) - console.log( "::set-output name=release::${{ inputs.release_branch }}" ) - } else if ( event.action === 'milestoned' ) { - const version = '${{ github.event.issue.milestone.title }}' - const release = version.substring( 0, 3 ) + if ( !isBot && ( isManualTrigger || isMergedMilestonedIssue || isMergedMilestonedPR ) ) { + core.setOutput( 'run', 'true' ); + } else { + core.setOutput( '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 ) }} - console.log( "::set-output name=version::" + version ) - console.log( "::set-output name=release::release/" + release ) - } else { - const version = '${{ github.event.pull_request.milestone.title }}' - const release = version.substring( 0, 3 ) + // Means this workflow was triggered manually. + if ( event.inputs && event.inputs.release_branch ) { + const releaseBranch = '${{ inputs.release_branch }}' + const version = releaseBranch.replace( 'release/', '' ) - console.log( "::set-output name=version::" + version ) - console.log( "::set-output name=release::release/" + release ) - } + core.setOutput( 'version', version ) + 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. - if ( event.inputs && event.inputs.pull_requests ) { - console.log( "::set-output name=pr::${{ inputs.pull_requests }}" ) - } else if ( event.action === 'milestoned' ) { - console.log( "::set-output name=pr::${{ github.event.issue.number }}" ) - } 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 + core.setOutput( 'version', version ) + core.setOutput( 'release', `release/${release}` ) + } else { + const version = '${{ github.event.pull_request.milestone.title }}' + const release = version.substring( 0, 3 ) - - 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 }}' - }) - - 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; + core.setOutput( 'version', version ) + core.setOutput( 'release', `release/${release}` ) } - if ( isInRange === true && line.match( /\*\*WooCommerce Blocks/ ) ) { - isInRange = false; + // Means this workflow was triggered manually. + 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". - 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; - } + core.setOutput( 'changelogsToBeDeleted', changelogsToBeDeleted.join( ' ' ) ) - 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 => { - if ( err ) { - console.error( `Unable to generate the changelog entry for PR ${{ needs.prep.outputs.pr }}` ); - } - } ); - } ); - } ); - } - } + - name: Commit changes for cherry pick + if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null + 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 - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }} + - name: Create the PR for cherry pick branch + 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"; - - name: Commit changes for cherry pick - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git commit --no-verify -am "Prep for cherry pick ${{ needs.prep.outputs.pr }}" + cherryPickPRBody = `${cherryPickPRBody}` + `* #${{ needs.prep.outputs.pr }}` + "\n"; - - name: Push cherry pick branch up - run: git push origin cherry-pick-${{ needs.prep.outputs.version }}/${{ needs.prep.outputs.pr }} + const pr = await github.rest.pulls.create({ + 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 - 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"; + core.setOutput( 'cherry-pick-pr', pr.data.html_url ) - 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({ - 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 a branch based on trunk branch + if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null + run: git checkout -b delete-changelogs/${{ needs.prep.outputs.pr }} - console.log( `::set-output name=cherry-pick-pr::${ pr.data.html_url }` ) - - name: Checkout trunk branch - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git checkout trunk + - name: Delete changelogs from trunk + if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null + run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }} - - name: Create a branch based on trunk branch - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git checkout -b delete-changelogs/${{ needs.prep.outputs.pr }} + - name: Commit changes for deletion + if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null + run: git commit --no-verify -am "Delete changelog files for ${{ needs.prep.outputs.pr }}" - - name: Delete changelogs from trunk - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git rm ${{ steps.changelog.outputs.changelogsToBeDeleted }} + - name: Push deletion branch up + if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null + run: git push origin delete-changelogs/${{ needs.prep.outputs.pr }} - - name: Commit changes for deletion - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git commit --no-verify -am "Delete changelog files for ${{ needs.prep.outputs.pr }}" + - name: Create the PR for deletion branch + id: deletion-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 - if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null - run: git push origin delete-changelogs/${{ needs.prep.outputs.pr }} + core.setOutput( 'deletion-pr', pr.data.html_url ) - - name: Create the PR for deletion branch - id: deletion-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: Notify Slack on failure + if: ${{ failure() && inputs.skipSlackPing != true }} + 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: - 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 - if: ${{ failure() && inputs.skipSlackPing != true }} - 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: + - name: Notify Slack on success + if: ${{ success() && inputs.skipSlackPing != true }} + 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: - 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 - if: ${{ success() && inputs.skipSlackPing != true }} - 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 }} + ${{ steps.cherry-pick-pr.outputs.cherry-pick-pr }} + ${{ steps.deletion-pr.outputs.deletion-pr }} diff --git a/.github/workflows/community-label.yml b/.github/workflows/community-label.yml index 221753b62e3..6856aec116f 100644 --- a/.github/workflows/community-label.yml +++ b/.github/workflows/community-label.yml @@ -1,38 +1,40 @@ name: Add Community Label on: - pull_request_target: - types: [opened] - issues: - types: [opened] - + pull_request_target: + types: [opened] + issues: + types: [opened] -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - verify: - name: Verify - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 + verify: + name: Verify + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 - - name: Setup Node.js - uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93 + - name: Setup Node.js + uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93 - - name: Install Octokit - run: npm --prefix .github/workflows/scripts install @octokit/action + - name: Install Octokit + run: npm --prefix .github/workflows/scripts install @octokit/action - - name: Check if user is a community contributor - id: check - run: node .github/workflows/scripts/is-community-contributor.js - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: "If community PR, assign a reviewer" - if: github.event.pull_request && steps.check.outputs.is-community == 'yes' - uses: shufo/auto-assign-reviewer-by-files@f5f3db9ef06bd72ab6978996988c6462cbdaabf6 - with: - config: ".github/project-community-pr-assigner.yml" - token: ${{ secrets.PR_ASSIGN_TOKEN }} + - name: Install Actions Core + run: npm --prefix .github/workflows/scripts install @actions/core + + - name: Check if user is a community contributor + id: check + run: node .github/workflows/scripts/is-community-contributor.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 'If community PR, assign a reviewer' + if: github.event.pull_request && steps.check.outputs.is-community == 'yes' + uses: shufo/auto-assign-reviewer-by-files@f5f3db9ef06bd72ab6978996988c6462cbdaabf6 + with: + config: '.github/project-community-pr-assigner.yml' + token: ${{ secrets.PR_ASSIGN_TOKEN }} diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml index c9454db35ef..7ed2c418986 100644 --- a/.github/workflows/post-release.yml +++ b/.github/workflows/post-release.yml @@ -1,7 +1,7 @@ name: Run post release processes -on: - release: - types: [released] +on: + release: + types: [released] env: GIT_COMMITTER_NAME: 'WooCommerce Bot' @@ -10,119 +10,119 @@ env: GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com' jobs: - changelog-version-update: - name: Update changelog and version - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 + changelog-version-update: + name: Update changelog and version + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 - - name: Git fetch trunk branch - run: git fetch origin trunk + - name: Git fetch trunk branch + run: git fetch origin trunk - - name: Copy readme.txt to vm root - run: cp ./plugins/woocommerce/readme.txt ../../readme.txt + - name: Copy readme.txt to vm root + run: cp ./plugins/woocommerce/readme.txt ../../readme.txt - - name: Switch to trunk branch - run: git checkout trunk + - name: Switch to trunk branch + run: git checkout trunk - - name: Create a new branch based on trunk - run: git checkout -b prep/post-release-tasks-${{ github.event.release.tag_name }} + - name: Create a new branch based on trunk + run: git checkout -b prep/post-release-tasks-${{ github.event.release.tag_name }} - - name: Check if we need to continue processing - uses: actions/github-script@v6 - id: check - with: - script: | - const fs = require( 'node:fs' ); - const version = ${{ toJSON( github.event.release.tag_name ) }} + - name: Check if we need to continue processing + uses: actions/github-script@v6 + id: check + with: + script: | + const fs = require( 'node:fs' ); + const version = ${{ toJSON( github.event.release.tag_name ) }} - fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) { - if ( err ) { - console.error( err ); - } + fs.readFile( './plugins/woocommerce/readme.txt', 'utf-8', function( err, data ) { + if ( 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 ( 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( `::set-output name=continue::false` ) - return; - } else { - console.log( `::set-output name=continue::true` ) - } - } ) + // If the release version is less than stable version we can bail. + 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.' ); + core.setOutput( 'continue', 'false' ) + return; + } else { + core.setOutput( 'continue', 'true' ) + } + } ) - - name: Update changelog.txt entries - uses: actions/github-script@v6 - id: update-entries - if: steps.check.outputs.continue == 'true' - with: - script: | - const fs = require( 'node:fs' ); - const version = ${{ toJSON( github.event.release.tag_name ) }} + - name: Update changelog.txt entries + uses: actions/github-script@v6 + id: update-entries + if: steps.check.outputs.continue == 'true' + with: + script: | + const fs = require( 'node:fs' ); + const version = ${{ toJSON( github.event.release.tag_name ) }} - // Read the saved readme.txt file from earlier. - fs.readFile( '../../readme.txt', 'utf-8', function( err, readme ) { - if ( err ) { - console.log( `::set-output name=continue::false` ) - console.error( err ); - } + // Read the saved readme.txt file from earlier. + fs.readFile( '../../readme.txt', 'utf-8', function( err, readme ) { + if ( err ) { + core.setOutput( 'continue', 'false' ); + 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 ) { - if ( err ) { - console.log( `::set-output name=continue::false` ) - console.error( err ); - } + fs.readFile( './changelog.txt', 'utf-8', function( err, changelog ) { + if ( err ) { + core.setOutput( 'continue', 'false' ); + 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 => { - if ( err ) { - console.log( `::set-output name=continue::false` ) - console.error( 'Unable to update changelog entries in changelog.txt' ); - } + fs.writeFile( './changelog.txt', updatedChangelog, err => { + if ( err ) { + core.setOutput( 'continue', 'false' ); + console.error( 'Unable to update changelog entries in changelog.txt' ); + } - console.log( `::set-output name=continue::true` ) - } ) - } ) - } ) + core.setOutput( 'continue', 'true' ); + } ) + } ) + } ) - - name: Commit changes - if: steps.update-entries.outputs.continue == 'true' - run: git commit -am "Prep trunk post release ${{ github.event.release.tag_name }}" + - name: Commit changes + if: steps.update-entries.outputs.continue == 'true' + run: git commit -am "Prep trunk post release ${{ github.event.release.tag_name }}" - - name: Push branch up - if: steps.update-entries.outputs.continue == 'true' - run: git push origin prep/post-release-tasks-${{ github.event.release.tag_name }} + - name: Push branch up + if: steps.update-entries.outputs.continue == 'true' + run: git push origin prep/post-release-tasks-${{ github.event.release.tag_name }} - - name: Create the PR - if: steps.update-entries.outputs.continue == 'true' - uses: actions/github-script@v6 - with: - script: | - const body = "This PR updates the changelog.txt entries based on the latest release: ${{ github.event.release.tag_name }}" + - name: Create the PR + if: steps.update-entries.outputs.continue == 'true' + uses: actions/github-script@v6 + with: + script: | + 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({ - owner: context.repo.owner, - repo: context.repo.repo, - title: "Update changelog.txt from release ${{ github.event.release.tag_name }}", - head: "prep/post-release-tasks-${{ github.event.release.tag_name }}", - base: "trunk", - body: body - }) + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: "Update changelog.txt from release ${{ github.event.release.tag_name }}", + head: "prep/post-release-tasks-${{ github.event.release.tag_name }}", + base: "trunk", + body: body + }) - const prCreated = await github.rest.pulls.requestReviewers({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.data.number, - reviewers: ["${{ github.event.release.author.login }}"] - }) + const prCreated = await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.data.number, + reviewers: ["${{ github.event.release.author.login }}"] + }) diff --git a/.github/workflows/pr-build-and-e2e-tests.yml b/.github/workflows/pr-build-and-e2e-tests.yml index b4dbc95df24..d66270ba70d 100644 --- a/.github/workflows/pr-build-and-e2e-tests.yml +++ b/.github/workflows/pr-build-and-e2e-tests.yml @@ -12,8 +12,8 @@ jobs: name: Runs E2E tests. runs-on: ubuntu-20.04 env: - ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results - ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-report + ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-results + ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-report outputs: E2E_GRAND_TOTAL: ${{ steps.count_e2e_total.outputs.E2E_GRAND_TOTAL }} steps: @@ -80,8 +80,8 @@ jobs: name: Runs API tests. runs-on: ubuntu-20.04 env: - ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results - ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report + 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/test-results/allure-report steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index 2ad6197d74f..17aa826f569 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -1,7 +1,7 @@ name: 'Release: Code freeze' on: schedule: - - cron: '0 16 * * 4' # Run at 1600 UTC on Thursdays. + - cron: '0 23 * * 1' # Run at 2300 UTC on Mondays. workflow_dispatch: inputs: timeOverride: @@ -42,12 +42,12 @@ jobs: $now = strtotime( getenv( 'TIME_OVERRIDE' ) ); } - // Code freeze comes 26 days prior to release day. - $release_time = strtotime( '+26 days', $now ); + // Code freeze comes 22 days prior to release day. + $release_time = strtotime( '+22 days', $now ); $release_day_of_week = date( 'l', $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 ) { file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=1\n", FILE_APPEND ); } else { @@ -163,7 +163,7 @@ jobs: workflow_id: 'release-changelog.yml', ref: 'trunk', inputs: { - releaseVersion: "release/${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}", - releaseBranch: "${{ 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.branch }}" } }) diff --git a/.github/workflows/scripts/is-community-contributor.js b/.github/workflows/scripts/is-community-contributor.js index 7708995fc06..9a4311d7878 100644 --- a/.github/workflows/scripts/is-community-contributor.js +++ b/.github/workflows/scripts/is-community-contributor.js @@ -1,50 +1,62 @@ -// Note you'll need to install this dependency as part of your workflow. -const { Octokit } = require('@octokit/action'); +// Note you'll need to install these dependencies as part of your workflow. +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 // this won't work. const octokit = new Octokit(); -const getIssueAuthor = (payload) => { - return payload?.issue?.user?.login || payload?.pull_request?.user?.login || null; -} +const getIssueAuthor = ( payload ) => { + return ( + payload?.issue?.user?.login || + payload?.pull_request?.user?.login || + null + ); +}; -const isCommunityContributor = async (owner, repo, username) => { - if (username) { - const {data: {permission}} = await octokit.rest.repos.getCollaboratorPermissionLevel({ +const isCommunityContributor = async ( owner, repo, username ) => { + if ( username ) { + const { + data: { permission }, + } = await octokit.rest.repos.getCollaboratorPermissionLevel( { owner, repo, username, - }); - + } ); + return permission === 'read' || permission === 'none'; } - return false; -} + return false; +}; -const addLabel = async(label, owner, repo, issueNumber) => { - await octokit.rest.issues.addLabels({ +const addLabel = async ( label, owner, repo, issueNumber ) => { + await octokit.rest.issues.addLabels( { owner, repo, issue_number: issueNumber, - labels: [label], - }); -} + labels: [ label ], + } ); +}; const applyLabelToCommunityContributor = async () => { - const eventPayload = require(process.env.GITHUB_EVENT_PATH); - const username = getIssueAuthor(eventPayload); - const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + const eventPayload = require( process.env.GITHUB_EVENT_PATH ); + const username = getIssueAuthor( eventPayload ); + const [ owner, repo ] = process.env.GITHUB_REPOSITORY.split( '/' ); const { number } = eventPayload?.issue || eventPayload?.pull_request; - - const isCommunityUser = await isCommunityContributor(owner, repo, username); - console.log( '::set-output name=is-community::%s', isCommunityUser ? 'yes' : 'no' ); - - if (isCommunityUser) { - console.log('Adding community contributor label'); - await addLabel('type: community contribution', owner, repo, number); + + const isCommunityUser = await isCommunityContributor( + owner, + repo, + username + ); + + core.setOutput( 'is-community', isCommunityUser ? 'yes' : 'no' ); + + if ( isCommunityUser ) { + console.log( 'Adding community contributor label' ); + await addLabel( 'type: community contribution', owner, repo, number ); } -} +}; applyLabelToCommunityContributor(); diff --git a/.github/workflows/scripts/release-code-freeze.php b/.github/workflows/scripts/release-code-freeze.php index 210e4f5767b..5990e89c36d 100644 --- a/.github/workflows/scripts/release-code-freeze.php +++ b/.github/workflows/scripts/release-code-freeze.php @@ -23,14 +23,14 @@ function set_output( $name, $value ) { file_put_contents( getenv( 'GITHUB_OUTPUT' ), "{$name}={$value}" . PHP_EOL, FILE_APPEND ); } -// Code freeze comes 26 days prior to release day. -$release_time = strtotime( '+26 days', $now ); +// Code freeze comes 22 days prior to release day. +$release_time = strtotime( '+22 days', $now ); $release_day_of_week = date( 'l', $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 ) { - 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 ); } diff --git a/.github/workflows/smoke-test-daily.yml b/.github/workflows/smoke-test-daily.yml index 7c985e30813..51d762e0e7d 100644 --- a/.github/workflows/smoke-test-daily.yml +++ b/.github/workflows/smoke-test-daily.yml @@ -55,7 +55,8 @@ jobs: working-directory: plugins/woocommerce env: 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. if: success() || failure() @@ -79,8 +80,8 @@ jobs: needs: [e2e-tests] if: success() || failure() env: - ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results - ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report + 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/test-results/allure-report steps: - uses: actions/checkout@v3 with: @@ -163,7 +164,7 @@ jobs: A_PW: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }} C_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }} C_PW: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }} - P_ID: 274 + P_ID: 22733 run: | ./k6 run plugins/woocommerce/tests/performance/tests/gh-action-daily-ext-requests.js @@ -219,7 +220,7 @@ jobs: working-directory: plugins/woocommerce env: 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. if: success() || failure() diff --git a/.gitignore b/.gitignore index 78d84df67a0..dfa505b1edd 100644 --- a/.gitignore +++ b/.gitignore @@ -72,8 +72,8 @@ yarn.lock # Editors nbproject/private/ -# Test Results -test-results.json +# E2E and API Test Results +test-results # Admin Feature config plugins/woocommerce/includes/react-admin/feature-config.php @@ -89,13 +89,6 @@ allure-results changes.json .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 .turbo diff --git a/changelog.txt b/changelog.txt index f65998cfc1a..2b78a742a29 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,254 @@ == 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 = **WooCommerce** diff --git a/packages/js/components/changelog/add-35301-delayed-ces-prompt b/packages/js/components/changelog/add-35301-delayed-ces-prompt new file mode 100644 index 00000000000..a0589d9d4ab --- /dev/null +++ b/packages/js/components/changelog/add-35301-delayed-ces-prompt @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding isHidden option for primary button in TourKit component. diff --git a/packages/js/components/changelog/add-35789_set_variations_list_fixed_height b/packages/js/components/changelog/add-35789_set_variations_list_fixed_height new file mode 100644 index 00000000000..6d397019d36 --- /dev/null +++ b/packages/js/components/changelog/add-35789_set_variations_list_fixed_height @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add className prop to Sortable diff --git a/packages/js/components/changelog/enhancement-table-add-no-data-label-property b/packages/js/components/changelog/enhancement-table-add-no-data-label-property new file mode 100644 index 00000000000..70efa89f698 --- /dev/null +++ b/packages/js/components/changelog/enhancement-table-add-no-data-label-property @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Add noDataLabel property into table.js component to allow No Data label customization. diff --git a/packages/js/components/changelog/fix-36129-table-className b/packages/js/components/changelog/fix-36129-table-className new file mode 100644 index 00000000000..1b601edb7bf --- /dev/null +++ b/packages/js/components/changelog/fix-36129-table-className @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Make Table component accept className prop. diff --git a/packages/js/components/changelog/list-item-classname b/packages/js/components/changelog/list-item-classname new file mode 100644 index 00000000000..9f5a5ea0d55 --- /dev/null +++ b/packages/js/components/changelog/list-item-classname @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add className prop to ListItem. diff --git a/packages/js/components/changelog/update-add-aria-label-for-simple-select-dropdown b/packages/js/components/changelog/update-add-aria-label-for-simple-select-dropdown new file mode 100644 index 00000000000..ff75398a39b --- /dev/null +++ b/packages/js/components/changelog/update-add-aria-label-for-simple-select-dropdown @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add aria-label for simple select dropdown diff --git a/packages/js/components/src/list-item/list-item.tsx b/packages/js/components/src/list-item/list-item.tsx index d6b77112e6e..f36c7815304 100644 --- a/packages/js/components/src/list-item/list-item.tsx +++ b/packages/js/components/src/list-item/list-item.tsx @@ -12,19 +12,21 @@ import { SortableHandle } from '../sortable'; export type ListItemProps = { children: JSX.Element | JSX.Element[] | string; + className?: string; onDragStart?: DragEventHandler< HTMLDivElement >; onDragEnd?: DragEventHandler< HTMLDivElement >; }; export const ListItem = ( { children, + className, onDragStart, onDragEnd, }: ListItemProps ) => { const isDraggable = onDragEnd && onDragStart; return ( -
+
{ isDraggable && } { children }
diff --git a/packages/js/components/src/select-control/control.js b/packages/js/components/src/select-control/control.js index c1baa3bf07d..931a4e425a2 100644 --- a/packages/js/components/src/select-control/control.js +++ b/packages/js/components/src/select-control/control.js @@ -154,6 +154,7 @@ class Control extends Component { : null } disabled={ disabled } + aria-label={ this.props.ariaLabel ?? this.props.label } /> ); } diff --git a/packages/js/components/src/table/README.md b/packages/js/components/src/table/README.md index 21e30c4a83f..54378b6dd25 100644 --- a/packages/js/components/src/table/README.md +++ b/packages/js/components/src/table/README.md @@ -241,6 +241,7 @@ Name | Type | Default | Description `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 `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 diff --git a/packages/js/components/src/table/index.js b/packages/js/components/src/table/index.js index b40d94b87d4..2723ceb6c32 100644 --- a/packages/js/components/src/table/index.js +++ b/packages/js/components/src/table/index.js @@ -153,6 +153,7 @@ class TableCard extends Component { title, totalRows, rowKey, + emptyMessage, } = this.props; const { showCols } = this.state; const allHeaders = this.props.headers; @@ -237,6 +238,7 @@ class TableCard extends Component { query={ query } onSort={ onSort || onQueryChange( 'sort' ) } rowKey={ rowKey } + emptyMessage={ emptyMessage } /> ) } @@ -361,6 +363,10 @@ TableCard.propTypes = { * This uses the index if not defined. */ rowKey: PropTypes.func, + /** + * Customize the message to show when there are no rows in the table. + */ + emptyMessage: PropTypes.string, }; TableCard.defaultProps = { @@ -372,6 +378,7 @@ TableCard.defaultProps = { rowHeader: 0, rows: [], showMenu: true, + emptyMessage: undefined, }; export default TableCard; diff --git a/packages/js/components/src/table/placeholder.js b/packages/js/components/src/table/placeholder.js index 956730ad1eb..cd6e2cbf999 100644 --- a/packages/js/components/src/table/placeholder.js +++ b/packages/js/components/src/table/placeholder.js @@ -25,7 +25,7 @@ class TablePlaceholder extends Component { return ( diff --git a/packages/js/components/src/table/stories/table.js b/packages/js/components/src/table/stories/table.js index 9d754370e54..1ea3ffb97c9 100644 --- a/packages/js/components/src/table/stories/table.js +++ b/packages/js/components/src/table/stories/table.js @@ -20,6 +20,18 @@ export const Basic = () => ( ); +export const NoDataCustomMessage = () => ( + +
row[ 0 ].value } + emptyMessage="Custom empty message" + /> + +); + export default { title: 'WooCommerce Admin/components/Table', component: Table, diff --git a/packages/js/components/src/table/table.js b/packages/js/components/src/table/table.js index 89def764924..0f484027b69 100644 --- a/packages/js/components/src/table/table.js +++ b/packages/js/components/src/table/table.js @@ -14,6 +14,7 @@ import { find, get, noop } from 'lodash'; import PropTypes from 'prop-types'; import { withInstanceId } from '@wordpress/compose'; import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; +import deprecated from '@wordpress/deprecated'; const ASC = 'asc'; const DESC = 'desc'; @@ -140,18 +141,35 @@ class Table extends Component { const { ariaHidden, caption, + className, classNames, headers, instanceId, query, rowHeader, rows, + emptyMessage, } = this.props; const { isScrollableRight, isScrollableLeft, tabIndex } = this.state; - const classes = classnames( 'woocommerce-table__table', classNames, { - 'is-scrollable-right': isScrollableRight, - 'is-scrollable-left': isScrollableLeft, - } ); + + if ( classNames ) { + 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 = query.orderby || get( find( headers, { defaultSort: true } ), 'key', false ); @@ -344,10 +362,11 @@ class Table extends Component { className="woocommerce-table__empty-item" colSpan={ headers.length } > - { __( - 'No data to display', - 'woocommerce' - ) } + { emptyMessage ?? + __( + 'No data to display', + 'woocommerce' + ) } ) } @@ -454,6 +473,10 @@ Table.propTypes = { * Defaults to index. */ rowKey: PropTypes.func, + /** + * Customize the message to show when there are no rows in the table. + */ + emptyMessage: PropTypes.string, }; Table.defaultProps = { @@ -462,6 +485,7 @@ Table.defaultProps = { onSort: noop, query: {}, rowHeader: 0, + emptyMessage: undefined, }; export default withInstanceId( Table ); diff --git a/packages/js/components/src/table/test/index.js b/packages/js/components/src/table/test/index.js index d46df160c27..84acf0a0dbe 100644 --- a/packages/js/components/src/table/test/index.js +++ b/packages/js/components/src/table/test/index.js @@ -10,6 +10,7 @@ import { createElement } from '@wordpress/element'; * Internal dependencies */ import TableCard from '../index'; +import Table from '../table'; import mockHeaders from './data/table-mock-headers'; import mockData from './data/table-mock-data'; import mockSummary from './data/table-mock-summary'; @@ -171,4 +172,69 @@ describe( 'TableCard', () => { 'is-left-aligned' ); } ); + + it( 'should render the default "No data to display" when there are no data and emptyMessage is unset', () => { + render( + + ); + + 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( + + ); + + expect( screen.queryByText( emptyMessage ) ).toBeInTheDocument(); + } ); +} ); + +describe( 'Table', () => { + it( 'should accept className prop and renders it in the HTML output', () => { + render( +
+ ); + + 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( +
+ ); + + const el = screen.getByLabelText( 'Table with classNames' ); + + expect( el ).toHaveClass( 'class-222' ); + } ); } ); diff --git a/packages/js/components/src/tour-kit/components/step-navigation.tsx b/packages/js/components/src/tour-kit/components/step-navigation.tsx index a4e78bb7fc2..6c5b2b6ba50 100644 --- a/packages/js/components/src/tour-kit/components/step-navigation.tsx +++ b/packages/js/components/src/tour-kit/components/step-navigation.tsx @@ -25,7 +25,7 @@ const StepNavigation: React.FunctionComponent< Props > = ( { const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; - const { primaryButton = { text: '', isDisabled: false } } = + const { primaryButton = { text: '', isDisabled: false, isHidden: false } } = steps[ currentStepIndex ].meta; const NextButton = ( @@ -80,6 +80,10 @@ const StepNavigation: React.FunctionComponent< Props > = ( { ); }; + if ( primaryButton.isHidden ) { + return null; + } + return (
diff --git a/packages/js/components/src/tour-kit/types.ts b/packages/js/components/src/tour-kit/types.ts index 7fac3ba9f57..543ce6f7d3e 100644 --- a/packages/js/components/src/tour-kit/types.ts +++ b/packages/js/components/src/tour-kit/types.ts @@ -22,6 +22,7 @@ export interface WooStep extends Step { text?: string; /** Disable the button or not. Default to False */ isDisabled?: boolean; + isHidden?: boolean; }; }; /** Auto apply the focus state for the element. Default to null */ diff --git a/packages/js/create-woo-extension/$slug.php.mustache b/packages/js/create-woo-extension/$slug.php.mustache index 9fa926613e5..8482f8e1388 100644 --- a/packages/js/create-woo-extension/$slug.php.mustache +++ b/packages/js/create-woo-extension/$slug.php.mustache @@ -77,7 +77,6 @@ if ( ! class_exists( '{{slugSnakeCase}}' ) ) : /** * Cloning is forbidden. - * */ public function __clone() { 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. - * */ public function __wakeup() { wc_doing_it_wrong( __FUNCTION__, __( 'Unserializing instances of this class is forbidden.', '{{slugSnakeCase}}' ), $this->version ); diff --git a/packages/js/create-woo-extension/.gitignore b/packages/js/create-woo-extension/.gitignore new file mode 100644 index 00000000000..4b894a3e094 --- /dev/null +++ b/packages/js/create-woo-extension/.gitignore @@ -0,0 +1,3 @@ +vendor +node_modules +.turbo diff --git a/packages/js/create-woo-extension/CHANGELOG.md b/packages/js/create-woo-extension/CHANGELOG.md new file mode 100644 index 00000000000..6fefe003856 --- /dev/null +++ b/packages/js/create-woo-extension/CHANGELOG.md @@ -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). diff --git a/packages/js/create-woo-extension/README.md.mustache b/packages/js/create-woo-extension/README.md.mustache index ff8f47f94dd..247a1adb2c7 100644 --- a/packages/js/create-woo-extension/README.md.mustache +++ b/packages/js/create-woo-extension/README.md.mustache @@ -13,7 +13,7 @@ A boilerplate for modern WooCommerce development. This project adds a React page ``` npm install -npm build +npm run build wp-env start ``` diff --git a/packages/js/create-woo-extension/changelog/add-wc-validation b/packages/js/create-woo-extension/changelog/add-wc-validation deleted file mode 100644 index 4e8303e4307..00000000000 --- a/packages/js/create-woo-extension/changelog/add-wc-validation +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: dev - -Add WC validation diff --git a/packages/js/create-woo-extension/changelog/php-cleanup b/packages/js/create-woo-extension/changelog/php-cleanup new file mode 100644 index 00000000000..b9f125755e2 --- /dev/null +++ b/packages/js/create-woo-extension/changelog/php-cleanup @@ -0,0 +1,3 @@ +Significance: patch +Type: dev +Comment: Just some PHP clean up to adhere to coding standards diff --git a/packages/js/create-woo-extension/includes/Admin/Setup.php.mustache b/packages/js/create-woo-extension/includes/admin/setup.php.mustache similarity index 100% rename from packages/js/create-woo-extension/includes/Admin/Setup.php.mustache rename to packages/js/create-woo-extension/includes/admin/setup.php.mustache diff --git a/packages/js/create-woo-extension/index.js b/packages/js/create-woo-extension/index.js index b4af0d6a82e..8f8f0c0a5d0 100644 --- a/packages/js/create-woo-extension/index.js +++ b/packages/js/create-woo-extension/index.js @@ -10,8 +10,8 @@ module.exports = { ], namespace: 'extension', license: 'GPL-3.0+', - }, - customScripts: { - postinstall: 'composer install', + customScripts: { + postinstall: 'composer install', + }, }, }; diff --git a/packages/js/create-woo-extension/package.json b/packages/js/create-woo-extension/package.json index c6d9efe834c..efb49a732d8 100644 --- a/packages/js/create-woo-extension/package.json +++ b/packages/js/create-woo-extension/package.json @@ -1,6 +1,6 @@ { "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.", "main": "index.js", "engines": { diff --git a/packages/js/data/changelog/add-35778-data-store b/packages/js/data/changelog/add-35778-data-store new file mode 100644 index 00000000000..e44e540625d --- /dev/null +++ b/packages/js/data/changelog/add-35778-data-store @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Extend product variations data store with generate variations actions diff --git a/packages/js/data/changelog/add-35786 b/packages/js/data/changelog/add-35786 new file mode 100644 index 00000000000..c872094db78 --- /dev/null +++ b/packages/js/data/changelog/add-35786 @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix IdQuery selector for getItem selector in CRUD data stores diff --git a/packages/js/data/changelog/add-35990 b/packages/js/data/changelog/add-35990 new file mode 100644 index 00000000000..46e9fc22c40 --- /dev/null +++ b/packages/js/data/changelog/add-35990 @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Update IdQuery type on get item selectors diff --git a/packages/js/data/changelog/add-36059 b/packages/js/data/changelog/add-36059 new file mode 100644 index 00000000000..20ec5dea987 --- /dev/null +++ b/packages/js/data/changelog/add-36059 @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix up updateItem query in CRUD data store diff --git a/packages/js/data/changelog/add-36060 b/packages/js/data/changelog/add-36060 new file mode 100644 index 00000000000..885c83a6fab --- /dev/null +++ b/packages/js/data/changelog/add-36060 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add batchUpdate to product variations datastore diff --git a/packages/js/data/changelog/add-36115 b/packages/js/data/changelog/add-36115 new file mode 100644 index 00000000000..08103beb8f0 --- /dev/null +++ b/packages/js/data/changelog/add-36115 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add image to product variation and export types diff --git a/packages/js/data/src/crud/README.md b/packages/js/data/src/crud/README.md index 48e190951cb..a2b29dfb2a1 100644 --- a/packages/js/data/src/crud/README.md +++ b/packages/js/data/src/crud/README.md @@ -18,6 +18,12 @@ createCrudDataStore( { resourceName: 'MyThing', pluralResourceName: 'MyThings', 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 import { createSelectors } from '../crud/selectors'; -import { createResolvers } from '../crud/selectors'; +import { createResolvers } from '../crud/resolvers'; import { createActions } from '../crud/actions'; import { registerStore, combineReducers } from '@wordpress/data'; diff --git a/packages/js/data/src/crud/actions.ts b/packages/js/data/src/crud/actions.ts index 3f572e860d3..2743c329505 100644 --- a/packages/js/data/src/crud/actions.ts +++ b/packages/js/data/src/crud/actions.ts @@ -192,10 +192,11 @@ export const createDispatchActions = ( { const item: Item = yield apiFetch( { path: getRestPath( `${ namespace }/${ id }`, - cleanQuery( query, namespace ), + {}, urlParameters ), method: 'PUT', + data: query, } ); yield updateItemSuccess( key, item ); diff --git a/packages/js/data/src/crud/index.ts b/packages/js/data/src/crud/index.ts index 6e44a1b50b7..6274f94fcad 100644 --- a/packages/js/data/src/crud/index.ts +++ b/packages/js/data/src/crud/index.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { registerStore } from '@wordpress/data'; +import { combineReducers, registerStore, StoreConfig } from '@wordpress/data'; import { Reducer } from 'redux'; /** @@ -9,7 +9,7 @@ import { Reducer } from 'redux'; */ import { createSelectors } from './selectors'; import { createDispatchActions } from './actions'; -import controls from '../controls'; +import defaultControls from '../controls'; import { createResolvers } from './resolvers'; import { createReducer, ResourceState } from './reducer'; @@ -18,6 +18,7 @@ type CrudDataStore = { resourceName: string; pluralResourceName: string; namespace: string; + storeConfig?: Partial< StoreConfig< ResourceState > >; }; export const createCrudDataStore = ( { @@ -25,29 +26,44 @@ export const createCrudDataStore = ( { resourceName, namespace, pluralResourceName, + storeConfig = {}, }: CrudDataStore ) => { - const reducer = createReducer(); - const actions = createDispatchActions( { + const crudReducer = createReducer(); + + const crudActions = createDispatchActions( { resourceName, namespace, } ); - const resolvers = createResolvers( { + const crudResolvers = createResolvers( { storeName, resourceName, pluralResourceName, namespace, } ); - const selectors = createSelectors( { + const crudSelectors = createSelectors( { resourceName, pluralResourceName, namespace, } ); + const { + reducer, + actions = {}, + selectors = {}, + resolvers = {}, + controls = {}, + } = storeConfig; + registerStore( storeName, { - reducer: reducer as Reducer< ResourceState >, - actions, - selectors, - resolvers, - controls, + reducer: reducer + ? ( combineReducers( { + crudReducer, + reducer, + } ) as Reducer ) + : ( crudReducer as Reducer< ResourceState > ), + actions: { ...crudActions, ...actions }, + selectors: { ...crudSelectors, ...selectors }, + resolvers: { ...crudResolvers, ...resolvers }, + controls: { ...defaultControls, ...controls }, } ); }; diff --git a/packages/js/data/src/crud/types.ts b/packages/js/data/src/crud/types.ts index d0a0c02eb8a..d38ed4296b4 100644 --- a/packages/js/data/src/crud/types.ts +++ b/packages/js/data/src/crud/types.ts @@ -76,7 +76,7 @@ export type CrudSelectors< '': WPDataSelector< typeof getItem >; }, ResourceName, - IdType, + IdQuery, ItemType > & MapSelectors< @@ -86,7 +86,7 @@ export type CrudSelectors< UpdateError: WPDataSelector< typeof getItemUpdateError >; }, ResourceName, - IdType, + IdQuery, unknown > & MapSelectors< diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index f386b083731..8805b6dc794 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -77,7 +77,11 @@ export * from './countries/types'; export * from './onboarding/types'; export * from './plugins/types'; export * from './products/types'; -export { ProductVariation } from './product-variations/types'; +export type { + ProductVariation, + ProductVariationAttribute, + ProductVariationImage, +} from './product-variations/types'; export { QueryProductAttribute, ProductAttributeSelectors, diff --git a/packages/js/data/src/product-variations/action-types.ts b/packages/js/data/src/product-variations/action-types.ts new file mode 100644 index 00000000000..89489d516ab --- /dev/null +++ b/packages/js/data/src/product-variations/action-types.ts @@ -0,0 +1,6 @@ +export enum TYPES { + GENERATE_VARIATIONS_ERROR = 'GENERATE_VARIATIONS_ERROR', + BATCH_UPDATE_VARIATIONS_ERROR = 'BATCH_UPDATE_VARIATIONS_ERROR', +} + +export default TYPES; diff --git a/packages/js/data/src/product-variations/actions.ts b/packages/js/data/src/product-variations/actions.ts new file mode 100644 index 00000000000..613b3864ce9 --- /dev/null +++ b/packages/js/data/src/product-variations/actions.ts @@ -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; + } +} diff --git a/packages/js/data/src/product-variations/index.ts b/packages/js/data/src/product-variations/index.ts index 4a6a444c202..c9b06818f33 100644 --- a/packages/js/data/src/product-variations/index.ts +++ b/packages/js/data/src/product-variations/index.ts @@ -3,12 +3,16 @@ */ import { STORE_NAME, WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants'; import { createCrudDataStore } from '../crud'; +import * as actions from './actions'; createCrudDataStore( { storeName: STORE_NAME, resourceName: 'ProductVariation', pluralResourceName: 'ProductVariations', namespace: WC_PRODUCT_VARIATIONS_NAMESPACE, + storeConfig: { + actions, + }, } ); export const EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/product-variations/types.ts b/packages/js/data/src/product-variations/types.ts index 2369bce1d8c..5275ec24961 100644 --- a/packages/js/data/src/product-variations/types.ts +++ b/packages/js/data/src/product-variations/types.ts @@ -15,11 +15,53 @@ export type ProductVariationAttribute = { 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< Product, - 'name' | 'slug' | 'attributes' + 'name' | 'slug' | 'attributes' | 'images' > & { attributes: ProductVariationAttribute[]; + /** + * Variation image data. + */ + image?: ProductVariationImage; }; type Query = Omit< ProductQuery, 'name' >; @@ -43,3 +85,16 @@ export type ProductVariationSelectors = CrudSelectors< >; 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[]; +}; diff --git a/packages/js/data/src/products/types.ts b/packages/js/data/src/products/types.ts index 7b3f6154634..4050775f183 100644 --- a/packages/js/data/src/products/types.ts +++ b/packages/js/data/src/products/types.ts @@ -69,6 +69,7 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit< id: number; low_stock_amount: number; manage_stock: boolean; + menu_order: number; name: string; on_sale: boolean; permalink: string; diff --git a/packages/js/onboarding/changelog/dev-clean-up-product-layout-exp-codes b/packages/js/onboarding/changelog/dev-clean-up-product-layout-exp-codes new file mode 100644 index 00000000000..4229640cc51 --- /dev/null +++ b/packages/js/onboarding/changelog/dev-clean-up-product-layout-exp-codes @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Cleanup product task experiment diff --git a/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.tsx b/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.tsx index 6367ca0205a..7856278b3b0 100644 --- a/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.tsx +++ b/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.tsx @@ -34,7 +34,6 @@ export const trackView = async ( taskId: string, variant?: string ) => { } ); }; -let experimentalVariant: string | undefined; type WooOnboardingTaskProps = { id: string; variant?: string; @@ -56,37 +55,15 @@ type WooOnboardingTaskSlotProps = Slot.Props & { */ const WooOnboardingTask: React.FC< WooOnboardingTaskProps > & { Slot: React.VFC< WooOnboardingTaskSlotProps >; -} = ( { id, variant, ...props } ) => { - useEffect( () => { - if ( id === 'products' ) { - experimentalVariant = variant; - } - }, [ id, variant ] ); - +} = ( { id, ...props } ) => { return ; }; -// 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 } ) => { // The Slot is a React component and this hook works as expected. // eslint-disable-next-line react-hooks/rules-of-hooks useEffect( () => { - if ( id === 'products' ) { - pollForExperimentalVariant( id, 0 ); - } else { - trackView( id ); - } + trackView( id ); }, [ id ] ); return ( diff --git a/plugins/woocommerce-admin/client/activity-panel/activity-panel.js b/plugins/woocommerce-admin/client/activity-panel/activity-panel.js index d94961584fd..308cf600904 100644 --- a/plugins/woocommerce-admin/client/activity-panel/activity-panel.js +++ b/plugins/woocommerce-admin/client/activity-panel/activity-panel.js @@ -9,7 +9,7 @@ import { useMemo, useEffect, } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { uniqueId, find } from 'lodash'; import { Icon, help as helpIcon, external } from '@wordpress/icons'; import { H, Section } from '@woocommerce/components'; @@ -43,9 +43,13 @@ import { import { getUnapprovedReviews } from '../homescreen/activity-panel/reviews/utils'; import { ABBREVIATED_NOTIFICATION_SLOT_NAME } from './panels/inbox/abbreviated-notifications-panel'; import { getAdminSetting } from '~/utils/admin-settings'; +import { getUrlParams } from '~/utils'; import { useActiveSetupTasklist } from '~/tasks'; import { LayoutContext } from '~/layout'; 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( () => 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 togglePanel = ( { name: tabName }, isTabOpen ) => { @@ -237,13 +244,23 @@ export const ActivityPanel = ( { isEmbedded, query } ) => { return query.page === 'wc-admin' && ! query.path; }; - const isProductPage = () => { + const isProductScreen = () => { const [ firstPathSegment ] = getSegmentsFromPath( query.path ); return ( 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 = () => { return ( query.task && @@ -264,7 +281,49 @@ export const ActivityPanel = ( { isEmbedded, query } ) => { visible: ( isEmbedded || ! isHomescreen() ) && ! isPerformingSetupTask() && - ! isProductPage(), + ! isProductScreen(), + }; + + const feedback = { + name: 'feedback', + title: __( 'Feedback', 'woocommerce' ), + icon: , + 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: 🌟, + } + ); + }, + visible: isAddProductPage(), }; const setup = { @@ -284,7 +343,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => { ! setupTaskListComplete && ! setupTaskListHidden && ! isHomescreen() && - ! isProductPage(), + ! isProductScreen(), }; const help = { @@ -337,6 +396,7 @@ export const ActivityPanel = ( { isEmbedded, query } ) => { return [ activity, + feedback, setup, previewSite, previewStore, @@ -431,6 +491,9 @@ export const ActivityPanel = ( { isEmbedded, query } ) => { clearPanel={ () => clearPanel() } /> + { isAddProductPage() && ( + + ) } { showHelpHighlightTooltip ? ( { * @param {string} pageId of page exited early. */ export const addExitPage = ( pageId: string ) => { - if ( ! window.localStorage ) { + if ( ! ( window.localStorage && allowTracking ) ) { return; } @@ -93,8 +94,8 @@ export const addCustomerEffortScoreExitPageListener = ( pageId: string, hasUnsavedChanges: () => boolean ) => { - eventListeners[ pageId ] = ( event ) => { - if ( hasUnsavedChanges() && allowTracking ) { + eventListeners[ pageId ] = () => { + if ( hasUnsavedChanges() ) { addExitPage( pageId ); } }; @@ -205,19 +206,88 @@ function getExitPageCESCopy( pageId: string ): { '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: 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. */ export function triggerExitPageCesSurvey() { const exitPageItems: string[] = getExitPageData(); - if ( exitPageItems && exitPageItems.length > 0 ) { + if ( exitPageItems?.length ) { + if ( ! getShouldExitPageFire( exitPageItems[ 0 ] ) ) { + return; + } + const copy = getExitPageCESCopy( exitPageItems[ 0 ] ); - if ( copy && copy.title.length > 0 ) { + + if ( copy?.title?.length ) { dispatch( 'wc/customer-effort-score' ).addCesSurvey( { ...copy, pageNow: window.pagenow, diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx index 038f166ac07..01216f84dc1 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx @@ -74,15 +74,18 @@ export const CustomerEffortScoreModalContainer: React.FC = () => { return ( { recordScore( ...args ); hideCesModal(); + visibleCESModalData.props?.onRecordScore?.(); + } } + onCloseModal={ () => { + visibleCESModalData.props?.onCloseModal?.(); + hideCesModal(); } } - onCloseModal={ () => hideCesModal() } /> ); }; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js index 9cf2d75e330..63d9c3d1d24 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js @@ -25,7 +25,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => { case TYPES.SHOW_CES_MODAL: const cesModalData = { action: action.surveyProps.action, - label: action.surveyProps.label, + title: action.surveyProps.title, onSubmitLabel: action.onSubmitLabel, firstQuestion: action.surveyProps.firstQuestion, secondQuestion: action.surveyProps.secondQuestion, diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx b/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx index 3c1ea3809e3..d4cb4c80e05 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx @@ -67,7 +67,7 @@ export const ProductMVPCESFooter: React.FC = () => { showCesModal( { action: cesAction, - label: __( + title: __( "How's your experience with the product editor?", 'woocommerce' ), diff --git a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx index de8c9c40ddd..3d173b9e091 100644 --- a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx +++ b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx @@ -358,7 +358,10 @@ export function StoreAddress( { required autoComplete="new-password" // disable autocomplete and autofill getSearchExpression={ ( query: string ) => { - return new RegExp( '^' + query, 'i' ); + return new RegExp( + '(^' + query + '| — (' + query + '))', + 'i' + ); } } options={ countryStateOptions } excludeSelectedOptions={ false } diff --git a/plugins/woocommerce-admin/client/guided-tours/add-product-feedback-tour.tsx b/plugins/woocommerce-admin/client/guided-tours/add-product-feedback-tour.tsx new file mode 100644 index 00000000000..5873c25e2ba --- /dev/null +++ b/plugins/woocommerce-admin/client/guided-tours/add-product-feedback-tour.tsx @@ -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 ( + { + setIsTourVisible( false ); + }, + } } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/layout/controller.js b/plugins/woocommerce-admin/client/layout/controller.js index 0ef2068d80f..4612662e02d 100644 --- a/plugins/woocommerce-admin/client/layout/controller.js +++ b/plugins/woocommerce-admin/client/layout/controller.js @@ -29,7 +29,6 @@ const EditProductPage = lazy( () => /* webpackChunkName: "edit-product-page" */ '../products/edit-product-page' ) ); - const AddProductPage = lazy( () => import( /* webpackChunkName: "add-product-page" */ '../products/add-product-page' @@ -46,7 +45,6 @@ const AnalyticsSettings = lazy( () => const Dashboard = lazy( () => import( /* webpackChunkName: "dashboard" */ '../dashboard' ) ); - const Homescreen = lazy( () => import( /* webpackChunkName: "homescreen" */ '../homescreen' ) ); @@ -66,7 +64,6 @@ const ProfileWizard = lazy( () => const SettingsGroup = lazy( () => import( /* webpackChunkName: "profile-wizard" */ '../settings' ) ); - const WCPaymentsWelcomePage = lazy( () => import( /* webpackChunkName: "wcpay-payment-welcome-page" */ '../payments-welcome' @@ -202,6 +199,20 @@ export const getPages = () => { wpOpenMenu: 'menu-posts-product', 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 ) { diff --git a/plugins/woocommerce-admin/client/products/add-product-page.tsx b/plugins/woocommerce-admin/client/products/add-product-page.tsx index ac84914a862..6b0ab353f11 100644 --- a/plugins/woocommerce-admin/client/products/add-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/add-product-page.tsx @@ -8,6 +8,7 @@ import { useEffect } from '@wordpress/element'; * Internal dependencies */ import { ProductForm } from './product-form'; +import './product-page.scss'; const AddProductPage: React.FC = () => { useEffect( () => { diff --git a/plugins/woocommerce-admin/client/products/constants.ts b/plugins/woocommerce-admin/client/products/constants.ts index 5a340309aea..090e98116df 100644 --- a/plugins/woocommerce-admin/client/products/constants.ts +++ b/plugins/woocommerce-admin/client/products/constants.ts @@ -5,3 +5,4 @@ export const ONLY_ONE_DECIMAL_SEPARATOR = '[%s](?=%s*[%s])'; export const ADD_NEW_SHIPPING_CLASS_OPTION_VALUE = '__ADD_NEW_SHIPPING_CLASS_OPTION__'; export const UNCATEGORIZED_CATEGORY_SLUG = 'uncategorized'; +export const PRODUCT_VARIATION_TITLE_LIMIT = 32; diff --git a/plugins/woocommerce-admin/client/products/edit-product-page.tsx b/plugins/woocommerce-admin/client/products/edit-product-page.tsx index 9544f353adf..b1104679c46 100644 --- a/plugins/woocommerce-admin/client/products/edit-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/edit-product-page.tsx @@ -2,35 +2,43 @@ * External dependencies */ 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 { + EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, PartialProduct, Product, PRODUCTS_STORE_NAME, WCDataSelector, } 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'; /** * Internal dependencies */ import { ProductForm } from './product-form'; import { ProductFormLayout } from './layout/product-form-layout'; +import { ProductVariationForm } from './product-variation-form'; +import './product-page.scss'; const EditProductPage: React.FC = () => { - const { productId } = useParams(); + const { productId, variationId } = useParams(); + const isProductVariation = !! variationId; const previousProductRef = useRef< PartialProduct >(); const formRef = useRef< FormRef< Partial< Product > > >( null ); - const { product, isLoading, isPendingAction } = useSelect( + const { product, isLoading, isPendingAction, productVariation } = useSelect( ( select: WCDataSelector ) => { const { getProduct, - hasFinishedResolution, + hasFinishedResolution: hasProductFinishedResolution, isPending, getPermalinkParts, } = select( PRODUCTS_STORE_NAME ); + const { + getProductVariation, + hasFinishedResolution: hasProductVariationFinishedResolution, + } = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME ); if ( productId ) { const retrievedProduct = getProduct( parseInt( productId, 10 ), @@ -44,13 +52,26 @@ const EditProductPage: React.FC = () => { permalinkParts && retrievedProduct ? retrievedProduct : undefined, + productVariation: + isProductVariation && + getProductVariation( { + id: parseInt( variationId, 10 ), + product_id: parseInt( productId, 10 ), + } ), isLoading: - ! hasFinishedResolution( 'getProduct', [ + ! hasProductFinishedResolution( 'getProduct', [ parseInt( productId, 10 ), ] ) || - ! hasFinishedResolution( 'getPermalinkParts', [ + ! hasProductFinishedResolution( 'getPermalinkParts', [ parseInt( productId, 10 ), - ] ), + ] ) || + ! ( + isProductVariation && + hasProductVariationFinishedResolution( + 'getProductVariation', + [ parseInt( variationId, 10 ) ] + ) + ), isPendingAction: isPending( 'createProduct' ) || isPending( @@ -109,7 +130,14 @@ const EditProductPage: React.FC = () => {
) } - { product && + { productVariation && product && ( + + ) } + { ! isProductVariation && + product && ( product.status !== 'trash' || wasDeletedUsingAction ) && ( ) } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-empty-state-logo.svg b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state-logo.svg similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-empty-state-logo.svg rename to plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state-logo.svg diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state.scss b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state.scss new file mode 100644 index 00000000000..c340fab81a8 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state.tsx new file mode 100644 index 00000000000..9285e395f86 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/attribute-empty-state.tsx @@ -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 ( + + +
+ Completed + + { subtitle } + + { typeof onNewClick === 'function' && ( + + ) } +
+
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/index.ts new file mode 100644 index 00000000000..c7f15a07625 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-empty-state/index.ts @@ -0,0 +1,2 @@ +export * from './attribute-empty-state'; +export { default as AttributeEmptyStateLogo } from './attribute-empty-state-logo.svg'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx index fb2bd1f0554..cf5c61288c8 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx @@ -30,6 +30,21 @@ import { HydratedAttributeType } from '../attribute-field'; import { getProductAttributeObject } from './utils'; 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; onAdd: ( newCategories: HydratedAttributeType[] ) => void; selectedAttributeIds?: number[]; @@ -40,6 +55,27 @@ type AttributeForm = { }; 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, onAdd, selectedAttributeIds = [], @@ -124,9 +160,6 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { } }; - const attributeLabel = __( 'Attribute', 'woocommerce' ); - const valueLabel = __( 'Values', 'woocommerce' ); - return ( <> @@ -144,7 +177,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { } ) => { return ( @@ -158,12 +191,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { className="woocommerce-add-attribute-modal" > -

- { __( - 'By default, attributes are filterable and visible on the product page. You can change these settings for each attribute separately later.', - 'woocommerce' - ) } -

+

{ notice }

@@ -183,10 +211,9 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { >
= ( { { attribute === null || attribute.id !== 0 ? ( = ( { /> ) : ( = ( { .attributes[ 0 ] === null } - label={ __( - 'Remove attribute', - 'woocommerce' - ) } + label={ + removeLabel + } onClick={ () => onRemove( index, @@ -329,10 +353,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
@@ -377,15 +394,12 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { { showConfirmClose && ( setShowConfirmClose( false ) } onConfirm={ onCancel } > - { __( - 'You have some attributes added to the list, are you sure you want to cancel?', - 'woocommerce' - ) } + { confirmMessage } ) } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss index b056534865b..3a1b21d41f0 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss @@ -1,63 +1,14 @@ .woocommerce-attribute-field { 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 { margin: 0; - - .woocommerce-list-item { - display: grid; - grid-template-columns: 24px 26% auto 90px; - } } .woocommerce-sortable__item:not(:first-child) { 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; border-top: 0; } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx index 2b70153c1bb..f3fe69a63fb 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx @@ -1,8 +1,7 @@ /** * External dependencies */ -import { sprintf, __ } from '@wordpress/i18n'; -import { Button, Card, CardBody } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; import { useState, useCallback, useEffect } from '@wordpress/element'; import { ProductAttribute, @@ -10,46 +9,51 @@ import { ProductAttributeTerm, } from '@woocommerce/data'; import { resolveSelect } from '@wordpress/data'; -import { Text } from '@woocommerce/experimental'; import { Sortable, - ListItem, __experimentalSelectControlMenuSlot as SelectControlMenuSlot, + Link, } from '@woocommerce/components'; -import { closeSmall } from '@wordpress/icons'; import { recordEvent } from '@woocommerce/tracks'; +import interpolateComponents from '@automattic/interpolate-components'; +import { getAdminLink } from '@woocommerce/settings'; /** * Internal dependencies */ import './attribute-field.scss'; -import AttributeEmptyStateLogo from './attribute-empty-state-logo.svg'; import { AddAttributeModal } from './add-attribute-modal'; import { EditAttributeModal } from './edit-attribute-modal'; import { reorderSortableProductAttributePositions } from './utils'; import { sift } from '../../../utils'; +import { AttributeEmptyState } from '../attribute-empty-state'; +import { + AddAttributeListItem, + AttributeListItem, +} from '../attribute-list-item'; type AttributeFieldProps = { value: ProductAttribute[]; onChange: ( value: ProductAttribute[] ) => void; productId?: number; + // TODO: should we support an 'any' option to show all attributes? + attributeType?: 'regular' | 'for-variations'; }; export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & { options?: string[]; terms?: ProductAttributeTerm[]; + visible?: boolean; }; export const AttributeField: React.FC< AttributeFieldProps > = ( { value, onChange, productId, + attributeType = 'regular', } ) => { const [ showAddAttributeModal, setShowAddAttributeModal ] = useState( false ); - const [ hydrationComplete, setHydrationComplete ] = useState< boolean >( - value ? false : true - ); const [ hydratedAttributes, setHydratedAttributes ] = useState< HydratedAttributeType[] >( [] ); @@ -57,8 +61,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { null | string >( null ); - const CANCEL_BUTTON_EVENT_NAME = - 'product_add_attributes_modal_cancel_button_click'; + const isOnlyForVariations = attributeType === 'for-variations'; + + 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( ( attributeId: number ) => { @@ -82,7 +91,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ); 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; } @@ -94,19 +108,26 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ).then( ( allResults ) => { setHydratedAttributes( [ ...globalAttributes.map( ( attr, index ) => { + const fetchedTerms = allResults[ index ]; + const newAttr = { ...attr, - terms: allResults[ index ], - options: undefined, + // I'm not sure this is quite right for handling unpersisted terms, + // but this gets things kinda working for now + terms: + fetchedTerms.length > 0 ? fetchedTerms : undefined, + options: + fetchedTerms.length === 0 + ? attr.options + : undefined, }; return newAttr; } ), ...customAttributes, ] ); - setHydrationComplete( true ); } ); - }, [ productId, value, hydrationComplete ] ); + }, [ fetchTerms, hydratedAttributes, value ] ); const fetchAttributeId = ( attribute: { id: number; name: string } ) => `${ attribute.id }-${ attribute.name }`; @@ -121,6 +142,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ? attr.terms.map( ( term ) => term.name ) : ( attr.options as string[] ), terms: undefined, + visible: attr.visible || false, }; } ) ); @@ -157,68 +179,70 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ) ) .map( ( newAttr, index ) => { - newAttr.position = ( value || [] ).length + index; - return newAttr; + return { + ...newAttributeProps, + ...newAttr, + position: ( value || [] ).length + index, + }; } ), ] ); recordEvent( 'product_add_attributes_modal_add_button_click' ); 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 ( - - -
-
- Completed - - { __( 'No attributes yet', 'woocommerce' ) } - - -
- { showAddAttributeModal && ( - { - recordEvent( CANCEL_BUTTON_EVENT_NAME ); - setShowAddAttributeModal( false ); - } } - onAdd={ onAddNewAttributes } - selectedAttributeIds={ ( value || [] ).map( - ( attr ) => attr.id - ) } - /> + <> + { + recordEvent( + 'product_add_first_attribute_button_click' + ); + setShowAddAttributeModal( true ); + } } + subtitle={ + isOnlyForVariations + ? __( 'No options yet', 'woocommerce' ) + : undefined + } + /> + { showAddAttributeModal && ( + { + recordEvent( CANCEL_BUTTON_EVENT_NAME ); + setShowAddAttributeModal( false ); + } } + onAdd={ onAddNewAttributes } + selectedAttributeIds={ ( filteredAttributes || [] ).map( + ( attr ) => attr.id ) } - -
-
-
+ /> + ) } + + ); } - const sortedAttributes = value.sort( ( a, b ) => a.position - b.position ); - const attributeKeyValues = value.reduce( + const sortedAttributes = filteredAttributes.sort( + ( a, b ) => a.position - b.position + ); + const attributeKeyValues = filteredAttributes.reduce( ( keyValue: Record< number, ProductAttribute >, attribute: ProductAttribute @@ -229,6 +253,20 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { {} 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 (
= ( { ); } } > - { sortedAttributes.map( ( attribute ) => ( - -
{ attribute.name }
-
- { attribute.options - .slice( 0, 2 ) - .map( ( option, index ) => ( -
- { option } -
- ) ) } - { attribute.options.length > 2 && ( -
- { sprintf( - __( '+ %i more', 'woocommerce' ), - attribute.options.length - 2 - ) } -
- ) } -
-
- - -
-
+ { sortedAttributes.map( ( attr ) => ( + + setEditingAttributeId( fetchAttributeId( attr ) ) + } + onRemoveClick={ () => onRemove( attr ) } + /> ) ) }
- - - + { + recordEvent( + isOnlyForVariations + ? 'product_add_option_button' + : 'product_add_attribute_button' + ); + setShowAddAttributeModal( true ); + } } + /> { showAddAttributeModal && ( { recordEvent( CANCEL_BUTTON_EVENT_NAME ); setShowAddAttributeModal( false ); @@ -312,12 +323,37 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { { editingAttributeId && ( + <> + + ), + }, + } ) } onCancel={ () => setEditingAttributeId( null ) } onEdit={ ( changedAttribute ) => { const newAttributesSet = [ ...hydratedAttributes ]; const changedAttributeIndex: number = - newAttributesSet.findIndex( - ( attr ) => attr.id === changedAttribute.id + newAttributesSet.findIndex( ( attr ) => + attr.id !== 0 + ? attr.id === changedAttribute.id + : attr.name === changedAttribute.name ); newAttributesSet.splice( @@ -329,12 +365,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { updateAttributes( newAttributesSet ); setEditingAttributeId( null ); } } - attribute={ - hydratedAttributes.find( - ( attr ) => - fetchAttributeId( attr ) === editingAttributeId - ) as HydratedAttributeType - } + attribute={ attribute } /> ) }
diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx index 19e82720c5e..deaf03ee225 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx @@ -9,12 +9,7 @@ import { TextControl, } from '@wordpress/components'; import { useState } from '@wordpress/element'; -import { - __experimentalTooltip as Tooltip, - Link, -} from '@woocommerce/components'; -import interpolateComponents from '@automattic/interpolate-components'; -import { getAdminLink } from '@woocommerce/settings'; +import { __experimentalTooltip as Tooltip } from '@woocommerce/components'; /** * Internal dependencies @@ -28,12 +23,42 @@ import { HydratedAttributeType } from './attribute-field'; import './edit-attribute-modal.scss'; 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; onEdit: ( alteredAttribute: HydratedAttributeType ) => void; attribute: HydratedAttributeType; }; 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, onEdit, attribute, @@ -46,13 +71,13 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { return ( onCancel() } className="woocommerce-edit-attribute-modal" >
= ( { />

{ ! isCustomAttribute - ? interpolateComponents( { - mixedString: __( - `You can change the attribute's name in {{link}}Attributes{{/link}}.`, - 'woocommerce' - ), - components: { - link: ( - - <> - - ), - }, - } ) - : __( - 'Your customers will see this on the product page', - 'woocommerce' - ) } + ? globalAttributeHelperMessage + : customAttributeHelperMessage }

{ attribute.terms ? ( { @@ -108,11 +109,8 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { /> ) : ( { @@ -133,50 +131,27 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { } ) } checked={ editableAttribute?.visible } - label={ __( 'Visible to customers', 'woocommerce' ) } - /> - -
-
- - setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), - variation: val, - } ) - } - checked={ editableAttribute?.variation } - label={ __( 'Used for filters', 'woocommerce' ) } - /> - +
diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx index 5eb5801b01b..f4a85ee1c7c 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx @@ -26,7 +26,7 @@ const attributeList: ProductAttribute[] = [ visible: true, variation: true, options: [ - 'Beige', + 'beige', 'black', 'Blue', 'brown', @@ -134,23 +134,24 @@ describe( 'AttributeField', () => { await screen.findByText( attributeList[ 0 ].name ) ).toBeInTheDocument(); expect( - await screen.findByText( attributeList[ 1 ].name ) - ).toBeInTheDocument(); + await screen.queryByText( attributeList[ 1 ].name ) + ).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( () => { render( {} } + attributeType="for-variations" /> ); } ); expect( - await screen.findByText( attributeList[ 0 ].options[ 0 ] ) - ).toBeInTheDocument(); + await screen.queryByText( attributeList[ 0 ].options[ 0 ] ) + ).not.toBeInTheDocument(); expect( await screen.findByText( attributeList[ 1 ].options[ 0 ] ) ).toBeInTheDocument(); diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-list-item/add-attribute-list-item.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/add-attribute-list-item.tsx new file mode 100644 index 00000000000..1992bda1889 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/add-attribute-list-item.tsx @@ -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 ( + + + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-list-item/attribute-list-item.scss b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/attribute-list-item.scss new file mode 100644 index 00000000000..ada8ad5b84a --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/attribute-list-item.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-list-item/attribute-list-item.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/attribute-list-item.tsx new file mode 100644 index 00000000000..f2ee31f6707 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/attribute-list-item.tsx @@ -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 ( + +
{ attribute.name }
+
+ { attribute.options.slice( 0, 2 ).map( ( option, index ) => ( +
+ { option } +
+ ) ) } + { attribute.options.length > 2 && ( +
+ { sprintf( + __( '+ %i more', 'woocommerce' ), + attribute.options.length - 2 + ) } +
+ ) } +
+
+ { typeof onEditClick === 'function' && ( + + ) } + { typeof onRemoveClick === 'function' && ( + + ) } +
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-list-item/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/index.ts new file mode 100644 index 00000000000..018701d9d73 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-list-item/index.ts @@ -0,0 +1,2 @@ +export * from './add-attribute-list-item'; +export * from './attribute-list-item'; diff --git a/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx b/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx new file mode 100644 index 00000000000..85f905ce06d --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx @@ -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 ( + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/attributes/index.ts b/plugins/woocommerce-admin/client/products/fields/attributes/index.ts new file mode 100644 index 00000000000..b17fcda94ec --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attributes/index.ts @@ -0,0 +1 @@ +export * from './attributes'; diff --git a/plugins/woocommerce-admin/client/products/fields/options/index.ts b/plugins/woocommerce-admin/client/products/fields/options/index.ts new file mode 100644 index 00000000000..5f30ef383a5 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/options/index.ts @@ -0,0 +1 @@ +export * from './options'; diff --git a/plugins/woocommerce-admin/client/products/fields/options/options.tsx b/plugins/woocommerce-admin/client/products/fields/options/options.tsx new file mode 100644 index 00000000000..69b8b0a0e76 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/options/options.tsx @@ -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 ( + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fields/single-image-field/index.ts b/plugins/woocommerce-admin/client/products/fields/single-image-field/index.ts new file mode 100644 index 00000000000..b3430e285c3 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/single-image-field/index.ts @@ -0,0 +1 @@ +export * from './single-image-field'; diff --git a/plugins/woocommerce-admin/client/products/fields/single-image-field/single-image-field.scss b/plugins/woocommerce-admin/client/products/fields/single-image-field/single-image-field.scss new file mode 100644 index 00000000000..75eecca08fa --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/single-image-field/single-image-field.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/products/fields/single-image-field/single-image-field.tsx b/plugins/woocommerce-admin/client/products/fields/single-image-field/single-image-field.tsx new file mode 100644 index 00000000000..77f31422bcc --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/single-image-field/single-image-field.tsx @@ -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 ( +
+ + + { value ? ( +
+ handleChange( media ) } + onRemove={ () => handleChange( undefined ) } + > + + +
+ ) : ( +
+ 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' ) } + /> +
+ ) } +
+ ); +} + +export type SingleImageFieldProps = Omit< + React.DetailedHTMLProps< + React.HTMLAttributes< HTMLDivElement >, + HTMLDivElement + >, + 'onChange' +> & { + label: string; + value?: MediaItem; + onChange?( value?: MediaItem ): void; +}; diff --git a/plugins/woocommerce-admin/client/products/fields/variations/variations.scss b/plugins/woocommerce-admin/client/products/fields/variations/variations.scss index 7a4b3f5fc62..56c5e50e4ee 100644 --- a/plugins/woocommerce-admin/client/products/fields/variations/variations.scss +++ b/plugins/woocommerce-admin/client/products/fields/variations/variations.scss @@ -1,5 +1,9 @@ .woocommerce-product-variations { - min-height: 300px; + ol { + @media ( min-width: #{ ($break-medium) } ) { + min-height: 420px; + } + } display: flex; flex-direction: column; > div { @@ -10,7 +14,7 @@ &__header { display: grid; - grid-template-columns: calc(38px + 25%) 25% 25%; + grid-template-columns: auto 25% 25% 88px; padding: $gap-small $gap; 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 { display: grid; - grid-template-columns: 38px 25% 25% 25%; + grid-template-columns: 38px auto 25% 25% 88px; margin-left: -1px; margin-right: -1px; margin-bottom: -1px; + min-height: 84px; } .woocommerce-sortable { @@ -35,7 +90,7 @@ flex: 1 0 auto; } - .components-spinner { + &.is-loading .components-spinner { width: 34px; height: 34px; left: 50%; diff --git a/plugins/woocommerce-admin/client/products/fields/variations/variations.tsx b/plugins/woocommerce-admin/client/products/fields/variations/variations.tsx index ec894b6c944..f8dfafd04bb 100644 --- a/plugins/woocommerce-admin/client/products/fields/variations/variations.tsx +++ b/plugins/woocommerce-admin/client/products/fields/variations/variations.tsx @@ -2,21 +2,37 @@ * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { Card, Spinner } from '@wordpress/components'; +import { Button, Card, Spinner, Tooltip } from '@wordpress/components'; import { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, ProductVariation, } 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 { 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 */ +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 { getProductStockStatus } from '../../utils/get-product-stock-status'; +import { + getProductStockStatus, + getProductStockStatusClass, +} from '../../utils/get-product-stock-status'; import './variations.scss'; /** @@ -29,9 +45,16 @@ import './variations.scss'; */ 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 = () => { const [ currentPage, setCurrentPage ] = useState( 1 ); const [ perPage, setPerPage ] = useState( DEFAULT_PER_PAGE_OPTION ); + const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >( + {} + ); const { productId } = useParams(); const context = useContext( CurrencyContext ); const { formatAmount, getCurrencyConfig } = context; @@ -46,6 +69,8 @@ export const Variations: React.FC = () => { product_id: productId, page: currentPage, per_page: perPage, + order: 'asc', + orderby: 'menu_order', }; return { isLoading: ! hasFinishedResolution( 'getProductVariations', [ @@ -60,6 +85,13 @@ export const Variations: React.FC = () => { [ currentPage, perPage ] ); + const { updateProductVariation } = useDispatch( + EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME + ); + + const { sortedVariations, getVariationKey, onOrderChange } = + useVariationsOrder( { variations, currentPage } ); + if ( ! variations || isLoading ) { return ( @@ -70,6 +102,26 @@ export const Variations: React.FC = () => { 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 (
@@ -83,27 +135,143 @@ export const Variations: React.FC = () => {

{ __( 'Quantity', 'woocommerce' ) }

- - { variations.map( ( variation ) => ( - + + { sortedVariations.map( ( variation ) => ( +
- { variation.attributes.map( ( attribute ) => ( - /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ - /* @ts-ignore Additional props are not required. */ - - ) ) } + { variation.attributes.map( ( attribute ) => { + const tag = ( + /* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ + /* @ts-ignore Additional props are not required. */ + + ); + + return attribute.option.length <= + PRODUCT_VARIATION_TITLE_LIMIT ? ( + tag + ) : ( + + { tag } + + ); + } ) }
-
+
{ formatAmount( variation.price ) }
-
+
+ + ● + { getProductStockStatus( variation ) }
+
+ + { __( 'Edit', 'woocommerce' ) } + + + { variation.status === 'private' && ( + + + + ) } + + { variation.status === 'publish' && ( + + + + ) } +
) ) } diff --git a/plugins/woocommerce-admin/client/products/hooks/use-product-variation-navigation.ts b/plugins/woocommerce-admin/client/products/hooks/use-product-variation-navigation.ts new file mode 100644 index 00000000000..fc2b97e6442 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/hooks/use-product-variation-navigation.ts @@ -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' +>; diff --git a/plugins/woocommerce-admin/client/products/hooks/use-variations-order.ts b/plugins/woocommerce-admin/client/products/hooks/use-variations-order.ts new file mode 100644 index 00000000000..99b2ffc4e37 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/hooks/use-variations-order.ts @@ -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; + }; + }; +}; diff --git a/plugins/woocommerce-admin/client/products/images/feedback-icon.tsx b/plugins/woocommerce-admin/client/products/images/feedback-icon.tsx index d444327c551..40ad443c5ae 100644 --- a/plugins/woocommerce-admin/client/products/images/feedback-icon.tsx +++ b/plugins/woocommerce-admin/client/products/images/feedback-icon.tsx @@ -4,14 +4,13 @@ export const FeedbackIcon = () => { width="16" height="17" viewBox="0 0 16 17" - fill="none" xmlns="http://www.w3.org/2000/svg" > ); diff --git a/plugins/woocommerce-admin/client/products/images/hidden-icon.tsx b/plugins/woocommerce-admin/client/products/images/hidden-icon.tsx new file mode 100644 index 00000000000..95e0e985935 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/images/hidden-icon.tsx @@ -0,0 +1,32 @@ +export default function HiddenIcon( { + width = 24, + height = 24, + ...props +}: React.SVGProps< SVGSVGElement > ) { + return ( + + ); +} diff --git a/plugins/woocommerce-admin/client/products/images/visible-icon.tsx b/plugins/woocommerce-admin/client/products/images/visible-icon.tsx new file mode 100644 index 00000000000..7848cef02bc --- /dev/null +++ b/plugins/woocommerce-admin/client/products/images/visible-icon.tsx @@ -0,0 +1,31 @@ +export default function VisibleIcon( { + width = 24, + height = 24, + ...props +}: React.SVGProps< SVGSVGElement > ) { + return ( + + ); +} diff --git a/plugins/woocommerce-admin/client/products/layout/product-form-layout.scss b/plugins/woocommerce-admin/client/products/layout/product-form-layout.scss index 73255c8fc66..59c240aa9d7 100644 --- a/plugins/woocommerce-admin/client/products/layout/product-form-layout.scss +++ b/plugins/woocommerce-admin/client/products/layout/product-form-layout.scss @@ -62,6 +62,22 @@ $product-form-tabs-height: 56px; 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); } + + &: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; + } } } diff --git a/plugins/woocommerce-admin/client/products/layout/product-form-layout.tsx b/plugins/woocommerce-admin/client/products/layout/product-form-layout.tsx index 713c33e6bd0..38b4d237cc4 100644 --- a/plugins/woocommerce-admin/client/products/layout/product-form-layout.tsx +++ b/plugins/woocommerce-admin/client/products/layout/product-form-layout.tsx @@ -3,7 +3,8 @@ */ import { __ } from '@wordpress/i18n'; 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 @@ -14,6 +15,8 @@ import { ProductFormTab } from '../product-form-tab'; export const ProductFormLayout: React.FC< { children: JSX.Element | JSX.Element[]; } > = ( { children } ) => { + const query = getQuery() as Record< string, string >; + useEffect( () => { window.document.body.classList.add( 'woocommerce-admin-product-layout' @@ -32,7 +35,27 @@ export const ProductFormLayout: React.FC< { } return { name: child.props.name, - title: child.props.title, + title: child.props.disabled ? ( + + + + { child.props.title } + + + + ) : ( + + + { child.props.title } + + + ), + disabled: child.props.disabled, }; } ); @@ -40,8 +63,16 @@ export const ProductFormLayout: React.FC< { ( 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 ) => ( <> diff --git a/plugins/woocommerce-admin/client/products/layout/product-variation-form-header.tsx b/plugins/woocommerce-admin/client/products/layout/product-variation-form-header.tsx new file mode 100644 index 00000000000..018b09b1903 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/layout/product-variation-form-header.tsx @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { ProductVariationFormActions } from '../product-variation-form-actions'; +import { ProductTitle } from '../product-title'; + +export const ProductVariationFormHeader: React.FC = () => { + return ( + <> + + + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/product-form-actions.scss b/plugins/woocommerce-admin/client/products/product-form-actions.scss index 0dc999c656c..25e3d924b87 100644 --- a/plugins/woocommerce-admin/client/products/product-form-actions.scss +++ b/plugins/woocommerce-admin/client/products/product-form-actions.scss @@ -6,7 +6,7 @@ $gutenberg-blue-darker: var(--wp-admin-theme-color-darker-20); flex-direction: row; align-items: center; justify-content: flex-end; - padding-right: $gap-smaller; + padding-right: $gap-large; @include breakpoint( '<782px' ) { position: fixed; @@ -58,4 +58,8 @@ $gutenberg-blue-darker: var(--wp-admin-theme-color-darker-20); .woocommerce-layout { margin-bottom: calc(70px + $gap); // Product actions height + gap. } + + .is-variation .woocommerce-product-form-actions__preview { + display: none; + } } diff --git a/plugins/woocommerce-admin/client/products/product-form-tab.tsx b/plugins/woocommerce-admin/client/products/product-form-tab.tsx index 9795f664a11..2c2f0b9a814 100644 --- a/plugins/woocommerce-admin/client/products/product-form-tab.tsx +++ b/plugins/woocommerce-admin/client/products/product-form-tab.tsx @@ -4,6 +4,7 @@ import classnames from 'classnames'; export const ProductFormTab: React.FC< { + disabled?: boolean; name: string; title: string; children: JSX.Element | JSX.Element[] | string; diff --git a/plugins/woocommerce-admin/client/products/product-form.tsx b/plugins/woocommerce-admin/client/products/product-form.tsx index d2a98a57ca5..9b188078404 100644 --- a/plugins/woocommerce-admin/client/products/product-form.tsx +++ b/plugins/woocommerce-admin/client/products/product-form.tsx @@ -16,9 +16,9 @@ import { PricingSection } from './sections/pricing-section'; import { ProductShippingSection } from './sections/product-shipping-section'; import { ProductVariationsSection } from './sections/product-variations-section'; import { ImagesSection } from './sections/images-section'; -import './product-page.scss'; import { validate } from './product-validation'; import { AttributesSection } from './sections/attributes-section'; +import { OptionsSection } from './sections/options-section'; import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormTab } from './product-form-tab'; @@ -48,16 +48,29 @@ export const ProductForm: React.FC< { - + - + - + + diff --git a/plugins/woocommerce-admin/client/products/product-more-menu.tsx b/plugins/woocommerce-admin/client/products/product-more-menu.tsx index a2b2c11fa9a..69ea7a6a8ab 100644 --- a/plugins/woocommerce-admin/client/products/product-more-menu.tsx +++ b/plugins/woocommerce-admin/client/products/product-more-menu.tsx @@ -38,11 +38,10 @@ export const ProductMoreMenu = () => { <> { - // @todo This should open the CES modal. showCesModal( { action: 'new_product', - label: __( + title: __( "How's your experience with the product editor?", 'woocommerce' ), diff --git a/plugins/woocommerce-admin/client/products/product-settings/product-settings.scss b/plugins/woocommerce-admin/client/products/product-settings/product-settings.scss index ed83b0d8441..31df678c6a0 100644 --- a/plugins/woocommerce-admin/client/products/product-settings/product-settings.scss +++ b/plugins/woocommerce-admin/client/products/product-settings/product-settings.scss @@ -28,6 +28,10 @@ } } +.woocommerce-product-settings__toggle { + margin-left: -$gap; +} + @include breakpoint( '<782px' ) { .woocommerce-product-settings__toggle, .woocommerce-product-settings__panel { diff --git a/plugins/woocommerce-admin/client/products/product-title.tsx b/plugins/woocommerce-admin/client/products/product-title.tsx index 6ffba5169d4..7a377c657c3 100644 --- a/plugins/woocommerce-admin/client/products/product-title.tsx +++ b/plugins/woocommerce-admin/client/products/product-title.tsx @@ -2,12 +2,14 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { getAdminLink } from '@woocommerce/settings'; import { + EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, Product, PRODUCTS_STORE_NAME, WCDataSelector, } from '@woocommerce/data'; +import { getAdminLink } from '@woocommerce/settings'; +import { getNewPath } from '@woocommerce/navigation'; import { useFormContext } from '@woocommerce/components'; import { useParams } from 'react-router-dom'; import { useSelect } from '@wordpress/data'; @@ -16,6 +18,10 @@ import { useSelect } from '@wordpress/data'; * Internal dependencies */ import { getProductTitle } from './utils/get-product-title'; +import { + getProductVariationTitle, + getTruncatedProductVariationTitle, +} from './utils/get-product-variation-title'; import { ProductBreadcrumbs } from './product-breadcrumbs'; import { ProductStatusBadge } from './product-status-badge'; import { WooHeaderPageTitle } from '~/header/utils'; @@ -23,35 +29,102 @@ import './product-title.scss'; export const ProductTitle: React.FC = () => { const { values } = useFormContext< Product >(); - const { productId } = useParams(); - const { persistedName } = useSelect( ( select: WCDataSelector ) => { - const product = productId - ? select( PRODUCTS_STORE_NAME ).getProduct( + const { productId, variationId } = useParams(); + const { isLoading, persistedName, productVariation } = useSelect( + ( select: WCDataSelector ) => { + 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 ), - undefined - ) - : null; + ] ); - return { - persistedName: product?.name, - }; - } ); + const isVariationLoading = + 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' ), title: __( 'Products', 'woocommerce' ), }, - ]; - const title = getProductTitle( values.name, values.type, persistedName ); + { + href: getNewPath( {}, '/product/' + productId ), + type: 'wc-admin', + title: ( + <> + { productTitle } + + + ), + }, + productVariationTitle && { + title: ( + + { getTruncatedProductVariationTitle( productVariation ) } + + ), + }, + ].filter( ( page ) => !! page ) as { + href: string; + title: string | JSX.Element; + }[]; + + const current = pageHierarchy.pop(); + + if ( isLoading ) { + return null; + } return ( - + - { title } - + { current?.title } diff --git a/plugins/woocommerce-admin/client/products/product-variation-form-actions.tsx b/plugins/woocommerce-admin/client/products/product-variation-form-actions.tsx new file mode 100644 index 00000000000..30f462d0688 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/product-variation-form-actions.tsx @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, ButtonGroup } from '@wordpress/components'; +import { + EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, + ProductVariation, +} from '@woocommerce/data'; +import { registerPlugin } from '@wordpress/plugins'; +import { useDispatch } from '@wordpress/data'; +import { useFormContext } from '@woocommerce/components'; +import { useParams } from 'react-router-dom'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import usePreventLeavingPage from '~/hooks/usePreventLeavingPage'; +import { WooHeaderItem } from '~/header/utils'; +import './product-form-actions.scss'; + +export const ProductVariationFormActions: React.FC = () => { + const { productId, variationId } = useParams(); + const { isDirty, isValidForm, values } = + useFormContext< ProductVariation >(); + const { updateProductVariation } = useDispatch( + EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME + ); + const { createNotice } = useDispatch( 'core/notices' ); + const [ isSaving, setIsSaving ] = useState( false ); + + usePreventLeavingPage( isDirty ); + + const onSave = async () => { + setIsSaving( true ); + updateProductVariation< Promise< ProductVariation > >( + { id: variationId, product_id: productId }, + values + ) + .then( () => { + createNotice( + 'success', + `🎉‎ ${ __( + 'Product variation successfully updated.', + 'woocommerce' + ) }` + ); + } ) + .catch( () => { + createNotice( + 'error', + __( 'Failed to updated product variation.', 'woocommerce' ) + ); + } ) + .finally( () => { + setIsSaving( false ); + } ); + }; + + return ( + + { () => ( +
+ + + + +
+ ) } +
+ ); +}; + +registerPlugin( 'product-variation-action-buttons-header-item', { + render: ProductVariationFormActions, + icon: 'admin-generic', +} ); diff --git a/plugins/woocommerce-admin/client/products/product-variation-form.scss b/plugins/woocommerce-admin/client/products/product-variation-form.scss new file mode 100644 index 00000000000..88aceb40971 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/product-variation-form.scss @@ -0,0 +1,25 @@ +.product-variation-form__navigation { + display: flex; + align-items: end; + justify-content: center; + width: 100%; + position: fixed; + bottom: 16px; + left: 0; + right: 0; + padding-left: 160px; + + @include breakpoint('782px-960px') { + padding-left: 18px; + } + + @include breakpoint('<782px') { + padding-left: 0; + bottom: calc(69px + 16px); + } + + .simple-navigation { + position: absolute; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + } +} diff --git a/plugins/woocommerce-admin/client/products/product-variation-form.tsx b/plugins/woocommerce-admin/client/products/product-variation-form.tsx new file mode 100644 index 00000000000..4bc88c23941 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/product-variation-form.tsx @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useEffect, useRef } from '@wordpress/element'; +import { Form, FormRef } from '@woocommerce/components'; +import { PartialProduct, ProductVariation } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import PostsNavigation from './shared/posts-navigation'; +import { ProductFormLayout } from './layout/product-form-layout'; +import { ProductFormFooter } from './layout/product-form-footer'; +import { ProductFormTab } from './product-form-tab'; +import { PricingSection } from './sections/pricing-section'; +import { ProductInventorySection } from './sections/product-inventory-section'; +import { ProductShippingSection } from './sections/product-shipping-section'; +import { ProductVariationDetailsSection } from './sections/product-variation-details-section'; +import { ProductVariationFormHeader } from './layout/product-variation-form-header'; +import useProductVariationNavigation from './hooks/use-product-variation-navigation'; +import './product-variation-form.scss'; + +export const ProductVariationForm: React.FC< { + product: PartialProduct; + productVariation: Partial< ProductVariation >; +} > = ( { product, productVariation } ) => { + const previousVariationIdRef = useRef< number >(); + const formRef = useRef< FormRef< Partial< ProductVariation > > >( null ); + + const navigationProps = useProductVariationNavigation( { + product, + productVariation, + } ); + + useEffect( () => { + if ( + productVariation && + previousVariationIdRef.current !== productVariation.id + ) { + formRef.current?.resetForm( productVariation ); + previousVariationIdRef.current = productVariation.id; + } + }, [ productVariation ] ); + + return ( + > + initialValues={ productVariation } + errors={ {} } + ref={ formRef } + > + + + + + + + + + + + + + + + + + +
+ +
+ + ); +}; diff --git a/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx b/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx index e2141df7e9c..977798d17d7 100644 --- a/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx +++ b/plugins/woocommerce-admin/client/products/sections/attributes-section.tsx @@ -11,7 +11,7 @@ import { recordEvent } from '@woocommerce/tracks'; */ import './attributes-section.scss'; import { ProductSectionLayout } from '../layout/product-section-layout'; -import { AttributeField } from '../fields/attribute-field'; +import { Attributes } from '../fields/attributes'; export const AttributesSection: React.FC = () => { const { @@ -45,8 +45,10 @@ export const AttributesSection: React.FC = () => { } > - ); diff --git a/plugins/woocommerce-admin/client/products/sections/options-section.scss b/plugins/woocommerce-admin/client/products/sections/options-section.scss new file mode 100644 index 00000000000..8090c6112dd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/sections/options-section.scss @@ -0,0 +1,6 @@ +.woocommerce-product-options-section.woocommerce-form-section { + .woocommerce-form-section__content { + padding: 0; + border: 0; + } +} diff --git a/plugins/woocommerce-admin/client/products/sections/options-section.tsx b/plugins/woocommerce-admin/client/products/sections/options-section.tsx new file mode 100644 index 00000000000..821ffe9e9ab --- /dev/null +++ b/plugins/woocommerce-admin/client/products/sections/options-section.tsx @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Link, useFormContext } from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; +import { recordEvent } from '@woocommerce/tracks'; + +/** + * Internal dependencies + */ +import './options-section.scss'; +import { ProductSectionLayout } from '../layout/product-section-layout'; +import { Options } from '../fields/options'; + +export const OptionsSection: React.FC = () => { + const { + getInputProps, + values: { id: productId }, + } = useFormContext< Product >(); + + return ( + + + { __( + 'Add and manage options, such as size and color, for customers to choose on the product page.', + 'woocommerce' + ) } + + { + recordEvent( 'learn_more_about_options_help' ); + } } + > + { __( 'Learn more about options', 'woocommerce' ) } + + + } + > + + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/sections/product-variation-details-section.tsx b/plugins/woocommerce-admin/client/products/sections/product-variation-details-section.tsx new file mode 100644 index 00000000000..c92c265c1f7 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/sections/product-variation-details-section.tsx @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { BlockInstance, serialize, parse } from '@wordpress/blocks'; +import { + CheckboxControl, + Card, + CardBody, + BaseControl, +} from '@wordpress/components'; +import { MediaItem } from '@wordpress/media-utils'; +import { + useFormContext, + __experimentalRichTextEditor as RichTextEditor, + __experimentalTooltip as Tooltip, +} from '@woocommerce/components'; +import { ProductVariation, ProductVariationImage } from '@woocommerce/data'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getCheckboxTracks } from './utils'; +import { ProductSectionLayout } from '../layout/product-section-layout'; +import { SingleImageField } from '../fields/single-image-field'; + +function parseVariationImage( + media?: MediaItem +): ProductVariationImage | undefined { + if ( ! media ) return undefined; + return { + id: media.id, + src: media.url, + alt: media.alt, + name: media.title, + } as ProductVariationImage; +} + +function formatVariationImage( + image?: ProductVariationImage +): MediaItem | undefined { + if ( ! image ) return undefined; + return { + id: image.id, + url: image.src, + alt: image.alt, + title: image.name, + } as MediaItem; +} + +export const ProductVariationDetailsSection: React.FC = () => { + const { getCheckboxControlProps, getInputProps, values, setValue } = + useFormContext< ProductVariation >(); + + const [ descriptionBlocks, setDescriptionBlocks ] = useState< + BlockInstance[] + >( parse( values.description || '' ) ); + + const imageFieldProps = getInputProps( 'image' ); + + return ( + + + + + { __( 'Visible to customers', 'woocommerce' ) } + + + } + { ...getCheckboxControlProps( + 'status', + getCheckboxTracks< ProductVariation >( 'status' ) + ) } + checked={ values.status === 'publish' } + onChange={ () => + setValue( + 'status', + values.status !== 'publish' + ? 'publish' + : 'private' + ) + } + /> + { + setDescriptionBlocks( blocks ); + if ( ! descriptionBlocks.length ) { + return; + } + setValue( 'description', serialize( blocks ) ); + } } + placeholder={ __( + 'Describe this product. What makes it unique? What are its most important features?', + 'woocommerce' + ) } + /> + + + + setValue( + 'image', + parseVariationImage( media ) + ) + } + /> + + + + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/sections/utils.ts b/plugins/woocommerce-admin/client/products/sections/utils.ts index d2e6e418355..ab56552bcf6 100644 --- a/plugins/woocommerce-admin/client/products/sections/utils.ts +++ b/plugins/woocommerce-admin/client/products/sections/utils.ts @@ -23,22 +23,20 @@ type CurrencyConfig = { /** * Get additional props to be passed to all checkbox inputs. * - * @param {string} name Name of the checkbox - * @return {Object} Props. + * @param name Name of the checkbox. + * @return Props. */ -export const getCheckboxTracks = ( name: string ) => { +export function getCheckboxTracks< T = Product >( name: string ) { return { onChange: ( - isChecked: - | ChangeEvent< HTMLInputElement > - | Product[ keyof Product ] + isChecked: ChangeEvent< HTMLInputElement > | T[ keyof T ] ) => { recordEvent( `product_checkbox_${ name }`, { checked: isChecked, } ); }, }; -}; +} /** * Get input props for currency related values and symbol positions. diff --git a/plugins/woocommerce-admin/client/products/shared/posts-navigation/index.ts b/plugins/woocommerce-admin/client/products/shared/posts-navigation/index.ts new file mode 100644 index 00000000000..9c29a211d11 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/posts-navigation/index.ts @@ -0,0 +1,2 @@ +export { default } from './posts-navigation'; +export type { PostsNavigationProps } from './posts-navigation'; diff --git a/plugins/woocommerce-admin/client/products/shared/posts-navigation/posts-navigation.scss b/plugins/woocommerce-admin/client/products/shared/posts-navigation/posts-navigation.scss new file mode 100644 index 00000000000..74e362b0874 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/posts-navigation/posts-navigation.scss @@ -0,0 +1,40 @@ +.posts-navigation { + display: flex; + align-items: center; + background: $white; + border: 1px solid $gray-400; + border-radius: 2px; + width: fit-content; + height: 48px; + + &__action, + &__prev, + &__next { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 12px; + text-decoration: none; + height: 100%; + color: $gray-900; + + &:focus { + z-index: 1; + } + + &:disabled, + &[aria-disabled='true'] { + color: $gray-400; + } + } + + &__action { + border-right: 1px solid $gray-400; + border-left: 1px solid $gray-400; + } + + .screen-reader-only { + @include screen-reader-only(); + } +} diff --git a/plugins/woocommerce-admin/client/products/shared/posts-navigation/posts-navigation.tsx b/plugins/woocommerce-admin/client/products/shared/posts-navigation/posts-navigation.tsx new file mode 100644 index 00000000000..23eb54417cd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/shared/posts-navigation/posts-navigation.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Icon, arrowLeft, arrowRight } from '@wordpress/icons'; +import { Link } from '@woocommerce/components'; +import classNames from 'classnames'; + +/** + * Internal dependencies + */ +import './posts-navigation.scss'; + +export default function PostsNavigation( { + actionLabel, + actionHref, + prevHref, + prevLabel, + nextHref, + nextLabel, + className, + ...props +}: PostsNavigationProps ) { + const prevNavigationProps = { + className: 'posts-navigation__prev', + 'aria-label': prevLabel ?? __( 'Previous post', 'woocommerce' ), + children: , + }; + + const nextNavigationProps = { + className: 'posts-navigation__next', + 'aria-label': nextLabel ?? __( 'Next post', 'woocommerce' ), + children: , + }; + + return ( + + ); +} + +export type PostsNavigationProps = React.DetailedHTMLProps< + React.HTMLAttributes< HTMLElement >, + HTMLElement +> & { + actionLabel: string; + actionHref: string; + prevHref?: string; + prevLabel?: string; + nextHref?: string; + nextLabel?: string; +}; diff --git a/plugins/woocommerce-admin/client/products/use-product-helper.ts b/plugins/woocommerce-admin/client/products/use-product-helper.ts index 89b52441514..c53ef2c7f4e 100644 --- a/plugins/woocommerce-admin/client/products/use-product-helper.ts +++ b/plugins/woocommerce-admin/client/products/use-product-helper.ts @@ -12,6 +12,8 @@ import { PRODUCTS_STORE_NAME, ReadOnlyProperties, productReadOnlyProperties, + EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, + ProductVariation, } from '@woocommerce/data'; import { recordEvent } from '@woocommerce/tracks'; @@ -23,6 +25,7 @@ import { NUMBERS_AND_DECIMAL_SEPARATOR, ONLY_ONE_DECIMAL_SEPARATOR, } from './constants'; +import { ProductVariationsOrder } from './hooks/use-variations-order'; function removeReadonlyProperties( product: Product @@ -51,6 +54,11 @@ export function useProductHelper() { const { createProduct, updateProduct, deleteProduct } = useDispatch( PRODUCTS_STORE_NAME ) as ProductsStoreActions; + const { + batchUpdateProductVariations, + invalidateResolutionForStoreSelector, + } = useDispatch( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME ); + const { createNotice } = useDispatch( 'core/notices' ); const [ isDeleting, setIsDeleting ] = useState( false ); const [ updating, setUpdating ] = useState( { @@ -129,6 +137,29 @@ export function useProductHelper() { [ updating ] ); + async function updateVariationsOrder( + productId: number, + variationsOrder?: { [ page: number ]: { [ id: number ]: number } } + ) { + if ( ! variationsOrder ) return undefined; + + return batchUpdateProductVariations< + Promise< { update: ProductVariation[] } > + >( + { + product_id: productId, + }, + { + update: Object.values( variationsOrder ) + .flatMap( Object.entries ) + .map( ( [ id, menu_order ] ) => ( { + id, + menu_order, + } ) ), + } + ); + } + /** * Update product with status. * @@ -152,44 +183,57 @@ export function useProductHelper() { return updateProduct( productId, { ...product, status, - } ).then( - ( updatedProduct ) => { - if ( ! skipNotice ) { - const noticeContent = - product.status === 'draft' && - updatedProduct.status === 'publish' - ? __( 'Product published.', 'woocommerce' ) - : __( - 'Product successfully updated.', - 'woocommerce' - ); - createNotice( 'success', `🎉‎ ${ noticeContent }`, { - actions: getNoticePreviewActions( - updatedProduct.status, - updatedProduct.permalink - ), + } ) + .then( async ( updatedProduct ) => + updateVariationsOrder( + updatedProduct.id, + ( product as ProductVariationsOrder ).variationsOrder + ) + .then( () => + invalidateResolutionForStoreSelector( + 'getProductVariations' + ) + ) + .then( () => updatedProduct ) + ) + .then( + ( updatedProduct ) => { + if ( ! skipNotice ) { + const noticeContent = + product.status === 'draft' && + updatedProduct.status === 'publish' + ? __( 'Product published.', 'woocommerce' ) + : __( + 'Product successfully updated.', + 'woocommerce' + ); + createNotice( 'success', `🎉‎ ${ noticeContent }`, { + actions: getNoticePreviewActions( + updatedProduct.status, + updatedProduct.permalink + ), + } ); + } + setUpdating( { + ...updating, + [ status ]: false, } ); + return updatedProduct; + }, + ( error ) => { + if ( ! skipNotice ) { + createNotice( + 'error', + __( 'Failed to update product.', 'woocommerce' ) + ); + } + setUpdating( { + ...updating, + [ status ]: false, + } ); + return error; } - setUpdating( { - ...updating, - [ status ]: false, - } ); - return updatedProduct; - }, - ( error ) => { - if ( ! skipNotice ) { - createNotice( - 'error', - __( 'Failed to update product.', 'woocommerce' ) - ); - } - setUpdating( { - ...updating, - [ status ]: false, - } ); - return error; - } - ); + ); }, [ updating ] ); diff --git a/plugins/woocommerce-admin/client/products/utils/get-product-stock-status.ts b/plugins/woocommerce-admin/client/products/utils/get-product-stock-status.ts index 39a29cd4b50..88ba69a46eb 100644 --- a/plugins/woocommerce-admin/client/products/utils/get-product-stock-status.ts +++ b/plugins/woocommerce-admin/client/products/utils/get-product-stock-status.ts @@ -13,6 +13,15 @@ export enum PRODUCT_STOCK_STATUS_KEYS { outofstock = 'outofstock', } +/** + * Product stock status colors. + */ +export enum PRODUCT_STOCK_STATUS_CLASSES { + instock = 'green', + onbackorder = 'yellow', + outofstock = 'red', +} + /** * Labels for product stock statuses. */ @@ -47,3 +56,27 @@ export const getProductStockStatus = ( return PRODUCT_STOCK_STATUS_LABELS.instock; }; + +/** + * Get the product stock status class. + * + * @param product Product instance. + * @return {PRODUCT_STOCK_STATUS_CLASSES} Product stock status class. + */ +export const getProductStockStatusClass = ( + product: PartialProduct | Partial< ProductVariation > +): string => { + if ( product.manage_stock ) { + const stockQuantity: number = product.stock_quantity || 0; + if ( stockQuantity >= 10 ) { + return PRODUCT_STOCK_STATUS_CLASSES.instock; + } + if ( stockQuantity < 10 && stockQuantity > 2 ) { + return PRODUCT_STOCK_STATUS_CLASSES.onbackorder; + } + return PRODUCT_STOCK_STATUS_CLASSES.outofstock; + } + return product.stock_status + ? PRODUCT_STOCK_STATUS_CLASSES[ product.stock_status ] + : ''; +}; diff --git a/plugins/woocommerce-admin/client/products/utils/get-product-variation-title.ts b/plugins/woocommerce-admin/client/products/utils/get-product-variation-title.ts new file mode 100644 index 00000000000..a15a79436cd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/utils/get-product-variation-title.ts @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { ProductVariation } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { PRODUCT_VARIATION_TITLE_LIMIT } from '../constants'; + +/** + * Get the product variation title for use in the header. + * + * @param productVariation The product variation. + * @return string + */ +export const getProductVariationTitle = ( + productVariation: Partial< ProductVariation > +) => { + if ( ! productVariation?.attributes?.length ) { + return '#' + productVariation.id; + } + + return productVariation.attributes + .map( ( attribute ) => { + return attribute.option; + } ) + .join( ', ' ); +}; + +/** + * Get the truncated product variation title. + * + * @param productVariation The product variation. + * @return string + */ +export const getTruncatedProductVariationTitle = ( + productVariation: Partial< ProductVariation > +) => { + const title = getProductVariationTitle( productVariation ); + + if ( title.length > PRODUCT_VARIATION_TITLE_LIMIT ) { + return title.substring( 0, PRODUCT_VARIATION_TITLE_LIMIT ) + '…'; + } + + return title; +}; diff --git a/plugins/woocommerce-admin/client/products/utils/test/get-product-stock-status.test.ts b/plugins/woocommerce-admin/client/products/utils/test/get-product-stock-status.test.ts new file mode 100644 index 00000000000..78d581504a0 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/utils/test/get-product-stock-status.test.ts @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { PartialProduct } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { + getProductStockStatus, + getProductStockStatusClass, +} from '../get-product-stock-status'; + +const products = [ + { + status: 'publish', + } as PartialProduct, + { + status: 'publish', + stock_status: 'outofstock', + } as PartialProduct, + { + manage_stock: true, + stock_quantity: 15, + status: 'publish', + stock_status: 'instock', + } as PartialProduct, + { + manage_stock: true, + status: 'publish', + stock_status: 'instock', + } as PartialProduct, + { + manage_stock: true, + stock_quantity: 5, + status: 'publish', + stock_status: 'instock', + } as PartialProduct, + { + manage_stock: true, + stock_quantity: 1, + status: 'publish', + stock_status: 'instock', + } as PartialProduct, + { + manage_stock: false, + status: 'publish', + stock_status: 'instock', + } as PartialProduct, + { + manage_stock: false, + status: 'publish', + stock_status: 'onbackorder', + } as PartialProduct, + { + manage_stock: false, + status: 'publish', + stock_status: 'outofstock', + } as PartialProduct, +]; + +describe( 'getProductStockStatus', () => { + it( 'should return `In stock` status when the stock is not being managed and there is no stock status', () => { + const status = getProductStockStatus( products[ 0 ] ); + expect( status ).toBe( 'In stock' ); + } ); + + it( 'should return the stock status when there is a stock status and the stock is not being managed', () => { + const status = getProductStockStatus( products[ 1 ] ); + expect( status ).toBe( 'Out of stock' ); + } ); + + it( 'should return the stock quantity when the stock is being managed', () => { + const status = getProductStockStatus( products[ 2 ] ); + expect( status ).toBe( 15 ); + } ); + + it( 'should return stock quantity = 0 when the stock is being managed but there is no a stock quantity', () => { + const status = getProductStockStatus( products[ 3 ] ); + expect( status ).toBe( 0 ); + } ); +} ); + +describe( 'getProductStockStatusClass', () => { + it( 'should return an emtpy string when the stock is not being managed and there is no stock status', () => { + const status = getProductStockStatusClass( products[ 0 ] ); + expect( status ).toBe( '' ); + } ); + + it( 'should return `green` when the stock is being managed and the stock quantity is higher or equal than 10', () => { + const status = getProductStockStatusClass( products[ 2 ] ); + expect( status ).toBe( 'green' ); + } ); + + it( 'should return `yellow` when the stock is being managed and the stock quantity is lower than 10 but higher than 2', () => { + const status = getProductStockStatusClass( products[ 4 ] ); + expect( status ).toBe( 'yellow' ); + } ); + + it( 'should return `red` when the stock is being managed and the stock quantity is lower or equal than 2', () => { + const status = getProductStockStatusClass( products[ 5 ] ); + expect( status ).toBe( 'red' ); + } ); + + it( 'should return `red` when the stock is being managed but there is no a stock quantity', () => { + const status = getProductStockStatusClass( products[ 3 ] ); + expect( status ).toBe( 'red' ); + } ); + + it( 'should return `green` when the stock is not being managed and the stock status is `instock`', () => { + const status = getProductStockStatusClass( products[ 6 ] ); + expect( status ).toBe( 'green' ); + } ); + + it( 'should return `yellow` when the stock is not being managed and the stock status is `onbackorder`', () => { + const status = getProductStockStatusClass( products[ 7 ] ); + expect( status ).toBe( 'yellow' ); + } ); + + it( 'should return `red` when the stock is not being managed and the stock status is `outofstock`', () => { + const status = getProductStockStatusClass( products[ 8 ] ); + expect( status ).toBe( 'red' ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/utils/test/get-product-variation-title.ts b/plugins/woocommerce-admin/client/products/utils/test/get-product-variation-title.ts new file mode 100644 index 00000000000..ebc0f50a158 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/utils/test/get-product-variation-title.ts @@ -0,0 +1,90 @@ +/** + * Internal dependencies + */ +import { + getProductVariationTitle, + getTruncatedProductVariationTitle, +} from '../get-product-variation-title'; + +describe( 'getProductVariationTitle', () => { + it( 'should return the product variation options in a comma separated list', () => { + const title = getProductVariationTitle( { + id: 123, + attributes: [ + { + id: 0, + name: 'Color', + option: 'Red', + }, + { + id: 0, + name: 'Size', + option: 'Medium', + }, + ], + } ); + expect( title ).toBe( 'Red, Medium' ); + } ); + + it( 'should return the only product variation attribute option name', () => { + const title = getProductVariationTitle( { + id: 123, + attributes: [ + { + id: 0, + name: 'Color', + option: 'Blue', + }, + ], + } ); + expect( title ).toBe( 'Blue' ); + } ); + + it( 'should return the product variation id when no attributes exist', () => { + const title = getProductVariationTitle( { + id: 123, + attributes: [], + } ); + expect( title ).toBe( '#123' ); + } ); +} ); + +describe( 'getTruncatedProductVariationTitle', () => { + it( 'should return the default product variation title when the limit is not met', () => { + const truncatedTitle = getTruncatedProductVariationTitle( { + id: 123, + attributes: [ + { + id: 0, + name: 'Color', + option: 'Red', + }, + { + id: 0, + name: 'Size', + option: 'Medium', + }, + ], + } ); + expect( truncatedTitle ).toBe( 'Red, Medium' ); + } ); + + it( 'should return the truncated product title when the limit is reached', () => { + const truncatedTitle = getTruncatedProductVariationTitle( { + id: 123, + attributes: [ + { + id: 0, + name: 'Color', + option: 'Reddish', + }, + { + id: 0, + name: 'Size', + option: 'MediumLargeSmallishTypeOfSize', + }, + ], + } ); + expect( truncatedTitle ).toBe( 'Reddish, MediumLargeSmallishType…' ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js b/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js index b0d9846d534..506063cae5d 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js +++ b/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js @@ -45,8 +45,9 @@ const Loader = ( props ) => { class Industry extends Component { constructor( props ) { const profileItems = get( props, 'profileItems', {} ); - let selected = profileItems.industry || []; - + let selected = Array.isArray( profileItems.industry ) + ? [ ...profileItems.industry ] + : []; /** * @todo Remove block on `updateProfileItems` refactor to wp.data dataStores. * diff --git a/plugins/woocommerce-admin/client/profile-wizard/steps/product-types/index.js b/plugins/woocommerce-admin/client/profile-wizard/steps/product-types/index.js index 2539edebb59..370a351a453 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/steps/product-types/index.js +++ b/plugins/woocommerce-admin/client/profile-wizard/steps/product-types/index.js @@ -77,7 +77,9 @@ export class ProductTypes extends Component { ); this.setState( { - selected: profileItems.product_types || defaultProductTypes, + selected: Array.isArray( profileItems.product_types ) + ? [ ...profileItems.product_types ] + : defaultProductTypes, }, () => { this.props.trackStepValueChanges( diff --git a/plugins/woocommerce-admin/client/profile-wizard/steps/store-details/test/__snapshots__/index.js.snap b/plugins/woocommerce-admin/client/profile-wizard/steps/store-details/test/__snapshots__/index.js.snap index 93d77575b83..1e7a53cbc51 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/steps/store-details/test/__snapshots__/index.js.snap +++ b/plugins/woocommerce-admin/client/profile-wizard/steps/store-details/test/__snapshots__/index.js.snap @@ -115,6 +115,7 @@ Object { aria-autocomplete="list" aria-expanded="false" aria-haspopup="true" + aria-label="Country / Region *" autocomplete="new-password" class="woocommerce-select-control__control-input" id="woocommerce-select-control-0__control-input" @@ -429,6 +430,7 @@ Object { aria-autocomplete="list" aria-expanded="false" aria-haspopup="true" + aria-label="Country / Region *" autocomplete="new-password" class="woocommerce-select-control__control-input" id="woocommerce-select-control-0__control-input" diff --git a/plugins/woocommerce-admin/client/profile-wizard/style.scss b/plugins/woocommerce-admin/client/profile-wizard/style.scss index a9fa2d2f608..b548f5593b1 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/style.scss +++ b/plugins/woocommerce-admin/client/profile-wizard/style.scss @@ -417,10 +417,10 @@ align-items: center; height: 60px; z-index: 10; - position: relative; position: sticky; top: 0; - margin: 0 -32px 24px; + margin: 0 -32px; + display: flex; } .woocommerce-usage-modal__actions { diff --git a/plugins/woocommerce-admin/client/settings/conflict-error-slotfill.js b/plugins/woocommerce-admin/client/settings/conflict-error-slotfill.js index f6992f76c92..18ceb1deb4d 100644 --- a/plugins/woocommerce-admin/client/settings/conflict-error-slotfill.js +++ b/plugins/woocommerce-admin/client/settings/conflict-error-slotfill.js @@ -15,6 +15,7 @@ import { Icon, closeSmall } from '@wordpress/icons'; import { useEffect, useState } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; +import { recordEvent } from '@woocommerce/tracks'; /** * Internal dependencies @@ -70,6 +71,8 @@ const SettingsErrorFill = () => { 'success', __( 'Recommended settings applied.', 'woocommerce' ) ); + + recordEvent( 'tax_settings_conflict_recommended_settings_clicked' ); }; const ApplyRecommendedSettingsButton = () => ( @@ -125,6 +128,12 @@ const SettingsErrorFill = () => { setIsConflict( false ); } else { setIsConflict( true ); + + recordEvent( 'tax_settings_conflict', { + main: pricesEnteredWithTaxSetting, + shop: displayPricesInShopWithTaxSetting, + cart: displayPricesInCartWithTaxSetting, + } ); } }, [ displayPricesInCartWithTaxSetting, @@ -169,9 +178,13 @@ const SettingsErrorFill = () => {
diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/card-layout.scss b/plugins/woocommerce-admin/client/tasks/fills/experimental-products/card-layout.scss deleted file mode 100644 index e1e3dc5ca6c..00000000000 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/card-layout.scss +++ /dev/null @@ -1,40 +0,0 @@ -.woocommerce-products-card-layout { - margin-top: 8px; - display: flex; - flex-direction: column; - align-items: center; - - .woocommerce-products-card-layout__description { - text-align: center; - } - - .woocommerce-products-card-list { - margin-top: 32px; - - .woocommerce-list { - grid-template-columns: 217px 217px 217px; - } - - .woocommerce-list__item { - width: 217px; - height: 200px; - align-items: stretch; - } - - .woocommerce-list__item-after { - opacity: 0; - } - - .woocommerce-list__item-inner { - padding: 40px 24px; - } - } - - .woocommerce-products-list__item-load-sample-product { - border: 1.5px dashed #dcdcde; - - .woocommerce-list__item-before { - background-color: $gray-100; - } - } -} diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/card-layout.tsx b/plugins/woocommerce-admin/client/tasks/fills/experimental-products/card-layout.tsx deleted file mode 100644 index 8f0028f3c7d..00000000000 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/card-layout.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { Text } from '@woocommerce/experimental'; -import { Link } from '@woocommerce/components'; -import interpolateComponents from '@automattic/interpolate-components'; -import { getAdminLink } from '@woocommerce/settings'; -import { recordEvent } from '@woocommerce/tracks'; - -/** - * Internal dependencies - */ -import { ProductType } from './constants'; -import CardList from '../experimental-import-products/CardList'; -import './card-layout.scss'; -import useRecordCompletionTime from '../use-record-completion-time'; - -type CardProps = { - items: ( ProductType & { - onClick: () => void; - } )[]; -}; - -const CardLayout: React.FC< CardProps > = ( { items } ) => { - const { recordCompletionTime } = useRecordCompletionTime( 'products' ); - - return ( -
- - { interpolateComponents( { - mixedString: __( - '{{sbLink}}Start blank{{/sbLink}} or select a product type:', - 'woocommerce' - ), - components: { - sbLink: ( - { - recordEvent( 'tasklist_add_product', { - method: 'manually', - } ); - recordCompletionTime(); - window.location.href = getAdminLink( - 'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true' - ); - return false; - } } - href="" - type="wc-admin" - > - <> - - ), - }, - } ) } - - -
- ); -}; - -export default CardLayout; diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/test/card-layout.tsx b/plugins/woocommerce-admin/client/tasks/fills/experimental-products/test/card-layout.tsx deleted file mode 100644 index 0ddf8ff161a..00000000000 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/test/card-layout.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * External dependencies - */ -import { fireEvent, render } from '@testing-library/react'; -import { recordEvent } from '@woocommerce/tracks'; - -/** - * Internal dependencies - */ -import CardLayout from '../card-layout'; -import { productTypes } from '../constants'; - -jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) ); -describe( 'CardLayout', () => { - beforeEach( () => { - ( recordEvent as jest.Mock ).mockClear(); - } ); - it( 'should render all products types in CardLayout', () => { - const { queryByText, queryAllByRole } = render( - {}, - }, - ] } - /> - ); - - expect( queryByText( productTypes[ 0 ].title ) ).toBeInTheDocument(); - - expect( queryAllByRole( 'link' ) ).toHaveLength( 1 ); - } ); - - it( 'start blank link should fire the tasklist_add_product and completion events', () => { - const { getByText } = render( - {}, - }, - ] } - /> - ); - fireEvent.click( getByText( 'Start blank' ) ); - expect( recordEvent ).toHaveBeenNthCalledWith( - 1, - 'tasklist_add_product', - { method: 'manually' } - ); - expect( recordEvent ).toHaveBeenNthCalledWith( - 2, - 'task_completion_time', - { task_name: 'products', time: '0-2s' } - ); - } ); -} ); diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/use-product-layout-experiment.ts b/plugins/woocommerce-admin/client/tasks/fills/experimental-products/use-product-layout-experiment.ts deleted file mode 100644 index 7501ec6ef88..00000000000 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/use-product-layout-experiment.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * External dependencies - */ -import { useState, useEffect } from '@wordpress/element'; - -type Layout = 'control' | 'card' | 'stacked'; - -export const getProductLayoutExperiment = async (): Promise< Layout > => { - // Deploy the stacked layout. Todo: cleanup the experiment code. - return 'stacked'; -}; - -export const isProductTaskExperimentTreatment = - async (): Promise< boolean > => { - return ( await getProductLayoutExperiment() ) !== 'control'; - }; - -export const useProductTaskExperiment = () => { - const [ isLoading, setIsLoading ] = useState< boolean >( true ); - const [ experimentLayout, setExperimentLayout ] = - useState< Layout >( 'control' ); - - useEffect( () => { - getProductLayoutExperiment().then( ( layout ) => { - setExperimentLayout( layout ); - setIsLoading( false ); - } ); - }, [ setExperimentLayout ] ); - - return { isLoading, experimentLayout }; -}; - -export default useProductTaskExperiment; diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/CardList.tsx b/plugins/woocommerce-admin/client/tasks/fills/import-products/CardList.tsx similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/CardList.tsx rename to plugins/woocommerce-admin/client/tasks/fills/import-products/CardList.tsx diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/cards.scss b/plugins/woocommerce-admin/client/tasks/fills/import-products/cards.scss similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/cards.scss rename to plugins/woocommerce-admin/client/tasks/fills/import-products/cards.scss diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/importTypes.tsx b/plugins/woocommerce-admin/client/tasks/fills/import-products/importTypes.tsx similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/importTypes.tsx rename to plugins/woocommerce-admin/client/tasks/fills/import-products/importTypes.tsx diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/index.tsx b/plugins/woocommerce-admin/client/tasks/fills/import-products/index.tsx similarity index 92% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/index.tsx rename to plugins/woocommerce-admin/client/tasks/fills/import-products/index.tsx index e53171764d3..4596b97abfb 100644 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/index.tsx +++ b/plugins/woocommerce-admin/client/tasks/fills/import-products/index.tsx @@ -13,12 +13,12 @@ import { recordEvent } from '@woocommerce/tracks'; /** * Internal dependencies */ -import Stacks from '../experimental-products/stack'; +import Stacks from '../products/stack'; import CardList from './CardList'; import { importTypes } from './importTypes'; import './style.scss'; -import useProductTypeListItems from '../experimental-products/use-product-types-list-items'; -import { getProductTypes } from '../experimental-products/utils'; +import useProductTypeListItems from '../products/use-product-types-list-items'; +import { getProductTypes } from '../products/utils'; import LoadSampleProductModal from '../components/load-sample-product-modal'; import useLoadSampleProducts from '../components/use-load-sample-products'; import LoadSampleProductConfirmModal from '../components/load-sample-product-confirm-modal'; @@ -114,7 +114,7 @@ registerPlugin( 'wc-admin-onboarding-task-products', { // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. scope: 'woocommerce-tasks', render: () => ( - + ), diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/style.scss b/plugins/woocommerce-admin/client/tasks/fills/import-products/style.scss similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/style.scss rename to plugins/woocommerce-admin/client/tasks/fills/import-products/style.scss diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/test/index.tsx b/plugins/woocommerce-admin/client/tasks/fills/import-products/test/index.tsx similarity index 99% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/test/index.tsx rename to plugins/woocommerce-admin/client/tasks/fills/import-products/test/index.tsx index 8982298d7bc..981543421f5 100644 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-import-products/test/index.tsx +++ b/plugins/woocommerce-admin/client/tasks/fills/import-products/test/index.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; /** * Internal dependencies */ -import { Products } from '../'; +import { Products } from '..'; jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) ); diff --git a/plugins/woocommerce-admin/client/tasks/fills/index.js b/plugins/woocommerce-admin/client/tasks/fills/index.js index 690a8203b30..e191ba1b103 100644 --- a/plugins/woocommerce-admin/client/tasks/fills/index.js +++ b/plugins/woocommerce-admin/client/tasks/fills/index.js @@ -1,8 +1,7 @@ /** * Internal dependencies */ -import { isProductTaskExperimentTreatment } from './experimental-products/use-product-layout-experiment'; -import { isImportProductExperiment } from './product-task-experiment'; +import { isImportProduct } from './utils'; import './PaymentGatewaySuggestions'; import './shipping'; import './Marketing'; @@ -12,28 +11,15 @@ import './tax'; import './woocommerce-payments'; import './purchase'; -const possiblyImportProductTaskExperiment = async () => { - const isExperiment = await isProductTaskExperimentTreatment(); - if ( isExperiment ) { - if ( isImportProductExperiment() ) { - import( './experimental-import-products' ); - } else { - import( './experimental-products' ); - } +const possiblyImportProductTask = async () => { + if ( isImportProduct() ) { + import( './import-products' ); } else { import( './products' ); } }; -if ( - window.wcAdminFeatures && - ( window.wcAdminFeatures[ 'experimental-import-products-task' ] || - window.wcAdminFeatures[ 'experimental-products-task' ] ) -) { - possiblyImportProductTaskExperiment(); -} else { - import( './products' ); -} +possiblyImportProductTask(); if ( window.wcAdminFeatures && diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/constants.tsx b/plugins/woocommerce-admin/client/tasks/fills/products/constants.tsx similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/constants.tsx rename to plugins/woocommerce-admin/client/tasks/fills/products/constants.tsx diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/footer.tsx b/plugins/woocommerce-admin/client/tasks/fills/products/footer.tsx similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/footer.tsx rename to plugins/woocommerce-admin/client/tasks/fills/products/footer.tsx diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/icon/lightbulb_24px.js b/plugins/woocommerce-admin/client/tasks/fills/products/icon/lightbulb_24px.js similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/icon/lightbulb_24px.js rename to plugins/woocommerce-admin/client/tasks/fills/products/icon/lightbulb_24px.js diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/icon/link_24px.js b/plugins/woocommerce-admin/client/tasks/fills/products/icon/link_24px.js similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/icon/link_24px.js rename to plugins/woocommerce-admin/client/tasks/fills/products/icon/link_24px.js diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/icon/widgets_24px.js b/plugins/woocommerce-admin/client/tasks/fills/products/icon/widgets_24px.js similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/icon/widgets_24px.js rename to plugins/woocommerce-admin/client/tasks/fills/products/icon/widgets_24px.js diff --git a/plugins/woocommerce-admin/client/tasks/fills/products/index.js b/plugins/woocommerce-admin/client/tasks/fills/products/index.js deleted file mode 100644 index 0a1e61adf23..00000000000 --- a/plugins/woocommerce-admin/client/tasks/fills/products/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Internal dependencies - */ -import './products'; -import ProductTemplateModal from './product-template-modal'; - -export { ProductTemplateModal }; diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/index.scss b/plugins/woocommerce-admin/client/tasks/fills/products/index.scss similarity index 100% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/index.scss rename to plugins/woocommerce-admin/client/tasks/fills/products/index.scss diff --git a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/index.tsx b/plugins/woocommerce-admin/client/tasks/fills/products/index.tsx similarity index 64% rename from plugins/woocommerce-admin/client/tasks/fills/experimental-products/index.tsx rename to plugins/woocommerce-admin/client/tasks/fills/products/index.tsx index 7fe7ff3f2db..861e6a5cee3 100644 --- a/plugins/woocommerce-admin/client/tasks/fills/experimental-products/index.tsx +++ b/plugins/woocommerce-admin/client/tasks/fills/products/index.tsx @@ -6,7 +6,7 @@ import { WooOnboardingTask } from '@woocommerce/onboarding'; import { Text } from '@woocommerce/experimental'; import { registerPlugin } from '@wordpress/plugins'; import { useMemo, useState } from '@wordpress/element'; -import { Button, Spinner } from '@wordpress/components'; +import { Button } from '@wordpress/components'; import { getAdminLink } from '@woocommerce/settings'; import { Icon, chevronDown, chevronUp } from '@wordpress/icons'; import { recordEvent } from '@woocommerce/tracks'; @@ -21,14 +21,11 @@ import { getSurfacedProductTypeKeys, getProductTypes } from './utils'; import useProductTypeListItems from './use-product-types-list-items'; import Stack from './stack'; import Footer from './footer'; -import CardLayout from './card-layout'; -import { LoadSampleProductType } from './constants'; import LoadSampleProductModal from '../components/load-sample-product-modal'; import useLoadSampleProducts from '../components/use-load-sample-products'; import LoadSampleProductConfirmModal from '../components/load-sample-product-confirm-modal'; import useRecordCompletionTime from '../use-record-completion-time'; import { getCountryCode } from '~/dashboard/utils'; -import { useProductTaskExperiment } from './use-product-layout-experiment'; const getOnboardingProductType = (): string[] => { const onboardingData = getAdminSetting( 'onboarding' ); @@ -59,8 +56,6 @@ export const Products = () => { isConfirmingLoadSampleProducts, setIsConfirmingLoadSampleProducts, ] = useState( false ); - const { isLoading: isLoadingExperiment, experimentLayout } = - useProductTaskExperiment(); const { isStoreInUS } = useSelect( ( select ) => { const { getSettings } = select( SETTINGS_STORE_NAME ); @@ -120,93 +115,66 @@ export const Products = () => { ! surfacedProductTypes.includes( productType ) && surfacedProductTypes.push( productType ) ); - - if ( experimentLayout === 'card' ) { - surfacedProductTypes.push( { - ...LoadSampleProductType, - onClick: () => setIsConfirmingLoadSampleProducts( true ), - } ); - } } return surfacedProductTypes; - }, [ - surfacedProductTypeKeys, - isExpanded, - productTypesWithTimeRecord, - experimentLayout, - ] ); + }, [ surfacedProductTypeKeys, isExpanded, productTypesWithTimeRecord ] ); return (
- { isLoadingExperiment ? ( - - ) : ( - <> - - { __( - 'What product do you want to add?', - 'woocommerce' - ) } - + + { __( 'What product do you want to add?', 'woocommerce' ) } + -
- { experimentLayout === 'stacked' ? ( - - setIsConfirmingLoadSampleProducts( true ) - } - showOtherOptions={ isExpanded } - /> - ) : ( - - ) } - { - if ( ! isExpanded ) { - recordEvent( - 'tasklist_view_more_product_types_click' - ); - } - setIsExpanded( ! isExpanded ); - } } - /> -
-
- { isLoadingSampleProducts ? ( - - ) : ( - isConfirmingLoadSampleProducts && ( - { - setIsConfirmingLoadSampleProducts( false ); - recordEvent( - 'tasklist_cancel_load_sample_products_click' - ); - } } - onImport={ () => { - setIsConfirmingLoadSampleProducts( false ); - loadSampleProduct(); - } } - /> - ) - ) } - +
+ + setIsConfirmingLoadSampleProducts( true ) + } + showOtherOptions={ isExpanded } + /> + { + if ( ! isExpanded ) { + recordEvent( + 'tasklist_view_more_product_types_click' + ); + } + setIsExpanded( ! isExpanded ); + } } + /> +
+
+ { isLoadingSampleProducts ? ( + + ) : ( + isConfirmingLoadSampleProducts && ( + { + setIsConfirmingLoadSampleProducts( false ); + recordEvent( + 'tasklist_cancel_load_sample_products_click' + ); + } } + onImport={ () => { + setIsConfirmingLoadSampleProducts( false ); + loadSampleProduct(); + } } + /> + ) ) }
); }; -const ExperimentalProductsFill = () => { - const { isLoading, experimentLayout } = useProductTaskExperiment(); - - return isLoading ? null : ( - +const ProductsFill = () => { + return ( + ); @@ -215,5 +183,5 @@ const ExperimentalProductsFill = () => { registerPlugin( 'wc-admin-onboarding-task-products', { // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. scope: 'woocommerce-tasks', - render: () => , + render: () => , } ); diff --git a/plugins/woocommerce-admin/client/tasks/fills/products/product-template-modal.js b/plugins/woocommerce-admin/client/tasks/fills/products/product-template-modal.js deleted file mode 100644 index a04016efd11..00000000000 --- a/plugins/woocommerce-admin/client/tasks/fills/products/product-template-modal.js +++ /dev/null @@ -1,198 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { Button, Modal, RadioControl } from '@wordpress/components'; -import { useState } from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { applyFilters } from '@wordpress/hooks'; -import { - ITEMS_STORE_NAME, - ONBOARDING_STORE_NAME, - PLUGINS_STORE_NAME, - SETTINGS_STORE_NAME, -} from '@woocommerce/data'; -import { getAdminLink } from '@woocommerce/settings'; -import { recordEvent } from '@woocommerce/tracks'; - -/** - * Internal dependencies - */ -import './product-template-modal.scss'; -import { createNoticesFromResponse } from '../../../lib/notices'; -import { getCountryCode } from '../../../dashboard/utils'; - -export const ONBOARDING_PRODUCT_TEMPLATES_FILTER = - 'woocommerce_admin_onboarding_product_templates'; - -const getProductTemplates = () => [ - { - key: 'physical', - title: __( 'Physical product', 'woocommerce' ), - subtitle: __( - 'Tangible items that get delivered to customers', - 'woocommerce' - ), - }, - { - key: 'digital', - title: __( 'Digital product', 'woocommerce' ), - subtitle: __( - 'Items that customers download or access through your website', - 'woocommerce' - ), - }, - { - key: 'variable', - title: __( 'Variable product', 'woocommerce' ), - subtitle: __( - 'Products with several versions that customers can choose from', - 'woocommerce' - ), - }, - { - key: 'subscription', - title: __( 'Subscription product', 'woocommerce' ), - subtitle: __( - 'Products that customers receive or gain access to regularly by paying in advance', - 'woocommerce' - ), - }, -]; - -export default function ProductTemplateModal( { onClose } ) { - const [ selectedTemplate, setSelectedTemplate ] = useState( null ); - const [ isRedirecting, setIsRedirecting ] = useState( false ); - const { createProductFromTemplate } = useDispatch( ITEMS_STORE_NAME ); - const { countryCode, profileItems } = useSelect( ( select ) => { - const { getProfileItems } = select( ONBOARDING_STORE_NAME ); - const { getSettings } = select( SETTINGS_STORE_NAME ); - const { general: settings = {} } = getSettings( 'general' ); - - return { - countryCode: getCountryCode( settings.woocommerce_default_country ), - profileItems: getProfileItems(), - }; - } ); - const { installedPlugins } = useSelect( ( select ) => { - const { getInstalledPlugins } = select( PLUGINS_STORE_NAME ); - - return { - installedPlugins: getInstalledPlugins(), - }; - } ); - - const createTemplate = () => { - setIsRedirecting( true ); - recordEvent( 'tasklist_product_template_selection', { - product_type: selectedTemplate, - } ); - if ( selectedTemplate === 'subscription' ) { - window.location.href = getAdminLink( - 'post-new.php?post_type=product&subscription_pointers=true' - ); - return; - } - if ( selectedTemplate ) { - createProductFromTemplate( - { - template_name: selectedTemplate, - status: 'draft', - }, - { _fields: [ 'id' ] } - ).then( - ( data ) => { - if ( data && data.id ) { - const link = getAdminLink( - `post.php?post=${ data.id }&action=edit&wc_onboarding_active_task=products&tutorial=true` - ); - window.location = link; - } - }, - ( error ) => { - // failed creating product with template - createNoticesFromResponse( error ); - setIsRedirecting( false ); - } - ); - } else if ( onClose ) { - recordEvent( 'tasklist_product_template_dismiss' ); - onClose(); - } - }; - - const removeSubscriptions = - ( window.wcAdminFeatures && ! window.wcAdminFeatures.subscriptions ) || - countryCode !== 'US' || - ! profileItems.product_types?.includes( 'subscriptions' ) || - ! installedPlugins.includes( 'woocommerce-payments' ); - - const productTemplates = removeSubscriptions - ? getProductTemplates().filter( - ( template ) => template.key !== 'subscription' - ) - : getProductTemplates(); - - /** - * An object defining a product template. - * - * @typedef {Object} template - * @property {string} key Icon to render. - * @property {string} title Url. - * @property {string} subtitle Link title. - */ - - /** - * Store product templates. - * - * @filter woocommerce_admin_onboarding_product_templates - * @param {Array.