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

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

View File

@ -15,12 +15,12 @@ on:
default: ''
required: true
skipSlackPing:
description: "Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)"
description: 'Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)'
type: boolean
required: false
default: false
slackChannelOverride:
description: "Slack Channel Override: The channel ID to send the Slack ping about the code freeze violation"
description: 'Slack Channel Override: The channel ID to send the Slack ping about the code freeze violation'
required: false
default: ''
@ -53,9 +53,9 @@ jobs:
const isBot = context.payload.pull_request && ( context.payload.pull_request.user.login == 'github-actions[bot]' || context.payload.pull_request.user.type == 'Bot' );
if ( !isBot && ( isManualTrigger || isMergedMilestonedIssue || isMergedMilestonedPR ) ) {
console.log( "::set-output name=run::true" );
core.setOutput( 'run', 'true' );
} else {
console.log( "::set-output name=run::false" );
core.setOutput( 'run', 'false' );
}
prep:
name: Prep inputs
@ -77,31 +77,31 @@ jobs:
// Means this workflow was triggered manually.
if ( event.inputs && event.inputs.release_branch ) {
const releaseBranch = '${{ inputs.release_branch }}'
const version = releaseBranch.replace( 'release/', '' );
const version = releaseBranch.replace( 'release/', '' )
console.log( "::set-output name=version::" + version )
console.log( "::set-output name=release::${{ inputs.release_branch }}" )
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 )
console.log( "::set-output name=version::" + version )
console.log( "::set-output name=release::release/" + release )
core.setOutput( 'version', version )
core.setOutput( 'release', `release/${release}` )
} else {
const version = '${{ github.event.pull_request.milestone.title }}'
const release = version.substring( 0, 3 )
console.log( "::set-output name=version::" + version )
console.log( "::set-output name=release::release/" + release )
core.setOutput( 'version', version )
core.setOutput( 'release', `release/${release}` )
}
// Means this workflow was triggered manually.
if ( event.inputs && event.inputs.pull_requests ) {
console.log( "::set-output name=pr::${{ inputs.pull_requests }}" )
core.setOutput( 'pr', '${{ inputs.pull_requests }}' )
} else if ( event.action === 'milestoned' ) {
console.log( "::set-output name=pr::${{ github.event.issue.number }}" )
core.setOutput( 'pr', '${{ github.event.issue.number }}' )
} else {
console.log( "::set-output name=pr::${{ github.event.pull_request.number }}" )
core.setOutput( 'pr', '${{ github.event.pull_request.number }}' )
}
check-release-branch-exists:
name: Check for existence of release branch
@ -150,11 +150,11 @@ jobs:
pull_number: '${{ needs.prep.outputs.pr }}'
})
console.log( `::set-output name=sha::${ pr.data.merge_commit_sha }` )
core.setOutput( 'sha', pr.data.merge_commit_sha )
- name: Cherry pick
run: |
git cherry-pick ${{ steps.commit-sha.outputs.sha }}
git cherry-pick ${{ steps.commit-sha.outputs.sha }} -m1
- name: Generate changelog
id: changelog
@ -255,7 +255,7 @@ jobs:
}
}
console.log( `::set-output name=changelogsToBeDeleted::${ changelogsToBeDeleted }` )
core.setOutput( 'changelogsToBeDeleted', changelogsToBeDeleted.join( ' ' ) )
- name: Delete changelog files from cherry pick branch
if: steps.changelog.outputs.changelogsToBeDeleted != '' && steps.changelog.outputs.changelogsToBeDeleted != null
@ -286,7 +286,8 @@ jobs:
body: cherryPickPRBody
})
console.log( `::set-output name=cherry-pick-pr::${ pr.data.html_url }` )
core.setOutput( '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
@ -322,7 +323,7 @@ jobs:
body: "Delete changelog files based on PR #${{ needs.prep.outputs.pr }}"
})
console.log( `::set-output name=deletion-pr::${ pr.data.html_url }` )
core.setOutput( 'deletion-pr', pr.data.html_url )
- name: Notify Slack on failure
if: ${{ failure() && inputs.skipSlackPing != true }}

View File

@ -6,7 +6,6 @@ on:
issues:
types: [opened]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@ -24,15 +23,18 @@ jobs:
- name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action
- 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"
- 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"
config: '.github/project-community-pr-assigner.yml'
token: ${{ secrets.PR_ASSIGN_TOKEN }}

View File

@ -48,10 +48,10 @@ jobs:
// 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` )
core.setOutput( 'continue', 'false' )
return;
} else {
console.log( `::set-output name=continue::true` )
core.setOutput( 'continue', 'true' )
}
} )
@ -67,7 +67,7 @@ jobs:
// 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` )
core.setOutput( 'continue', 'false' );
console.error( err );
}
@ -77,7 +77,7 @@ jobs:
fs.readFile( './changelog.txt', 'utf-8', function( err, changelog ) {
if ( err ) {
console.log( `::set-output name=continue::false` )
core.setOutput( 'continue', 'false' );
console.error( err );
}
@ -87,11 +87,11 @@ jobs:
fs.writeFile( './changelog.txt', updatedChangelog, err => {
if ( err ) {
console.log( `::set-output name=continue::false` )
core.setOutput( 'continue', 'false' );
console.error( 'Unable to update changelog entries in changelog.txt' );
}
console.log( `::set-output name=continue::true` )
core.setOutput( 'continue', 'true' );
} )
} )
} )

View File

@ -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

View File

@ -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 }}"
}
})

View File

@ -1,17 +1,24 @@
// Note you'll need to install this dependency as part of your workflow.
// Note you'll need to install these dependencies as part of your workflow.
const { Octokit } = require( '@octokit/action' );
const 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;
}
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 {
data: { permission },
} = await octokit.rest.repos.getCollaboratorPermissionLevel( {
owner,
repo,
username,
@ -21,7 +28,7 @@ const isCommunityContributor = async (owner, repo, username) => {
}
return false;
}
};
const addLabel = async ( label, owner, repo, issueNumber ) => {
await octokit.rest.issues.addLabels( {
@ -30,21 +37,26 @@ const addLabel = async(label, owner, repo, issueNumber) => {
issue_number: issueNumber,
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 [ 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' );
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();

View File

@ -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 );
}

View File

@ -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()

11
.gitignore vendored
View File

@ -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

View File

@ -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**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,19 +12,21 @@ import { SortableHandle } from '../sortable';
export type ListItemProps = {
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 (
<div className={ classnames( 'woocommerce-list-item' ) }>
<div className={ classnames( 'woocommerce-list-item', className ) }>
{ isDraggable && <SortableHandle /> }
{ children }
</div>

View File

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

View File

@ -241,6 +241,7 @@ Name | Type | Default | Description
`rows` | Array | `null` | (required) An array of arrays of display/value object pairs
`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

View File

@ -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 }
/>
) }
</CardBody>
@ -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;

View File

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

View File

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

View File

@ -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, {
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,7 +362,8 @@ class Table extends Component {
className="woocommerce-table__empty-item"
colSpan={ headers.length }
>
{ __(
{ 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 );

View File

@ -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(
<TableCard
title="My table"
headers={ mockHeaders }
isLoading={ false }
rows={ [] }
rowsPerPage={ 5 }
/>
);
expect(
screen.queryByText( 'No data to display' )
).toBeInTheDocument();
} );
it( 'should render the custom label set in emptyMessage when there are no data.', () => {
const emptyMessage = 'My no data label';
render(
<TableCard
title="My table"
headers={ mockHeaders }
isLoading={ false }
rows={ [] }
rowsPerPage={ 5 }
emptyMessage={ emptyMessage }
/>
);
expect( screen.queryByText( emptyMessage ) ).toBeInTheDocument();
} );
} );
describe( 'Table', () => {
it( 'should accept className prop and renders it in the HTML output', () => {
render(
<Table
className="class-111"
caption="Table with className"
headers={ mockHeaders }
rows={ mockData }
/>
);
const el = screen.getByLabelText( 'Table with className' );
expect( el ).toHaveClass( 'class-111' );
} );
it( 'should still work with classNames prop and renders it in the HTML output, for backward compatibility reason', () => {
render(
<Table
classNames="class-222"
caption="Table with classNames"
headers={ mockHeaders }
rows={ mockData }
/>
);
const el = screen.getByLabelText( 'Table with classNames' );
expect( el ).toHaveClass( 'class-222' );
} );
} );

View File

@ -25,7 +25,7 @@ const StepNavigation: React.FunctionComponent< Props > = ( {
const isFirstStep = currentStepIndex === 0;
const 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 (
<div className="woocommerce-tour-kit-step-navigation">
<div className="woocommerce-tour-kit-step-navigation__step">

View File

@ -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 */

View File

@ -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 );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,12 @@ createCrudDataStore( {
resourceName: 'MyThing',
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';

View File

@ -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 );

View File

@ -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 },
} );
};

View File

@ -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<

View File

@ -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,

View File

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

View File

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

View File

@ -3,12 +3,16 @@
*/
import { STORE_NAME, WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants';
import { 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;

View File

@ -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[];
};

View File

@ -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;

View File

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

View File

@ -34,7 +34,6 @@ export const trackView = async ( taskId: string, variant?: string ) => {
} );
};
let experimentalVariant: string | undefined;
type WooOnboardingTaskProps = {
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 <Fill name={ 'woocommerce_onboarding_task_' + id } { ...props } />;
};
// We need this here just in case the experiment assignment takes awhile to load, so that we don't fire trackView with a blank experimentalVariant
// Remove all of the code in this file related to experiments and variants when the product task experiment concludes and never speak of the existence of this code to anyone
const pollForExperimentalVariant = ( id: string, count: number ) => {
if ( count > 20 ) {
trackView( id, 'experiment_timed_out' ); // if we can't fetch experiment after 4 seconds, give up
} else if ( experimentalVariant ) {
trackView( id, experimentalVariant );
} else {
setTimeout( () => pollForExperimentalVariant( id, count + 1 ), 200 );
}
};
WooOnboardingTask.Slot = ( { id, fillProps } ) => {
// 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 );
}
}, [ id ] );
return (

View File

@ -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: <FeedbackIcon />,
onClick: () => {
setCurrentTab( 'feedback' );
setIsPanelOpen( true );
showCesModal(
{
action: 'product_feedback',
title: __(
"How's your experience with the product editor?",
'woocommerce'
),
firstQuestion: __(
'The product editing screen is easy to use',
'woocommerce'
),
secondQuestion: __(
"The product editing screen's functionality meets my needs",
'woocommerce'
),
},
{
onRecordScore: () => {
setCurrentTab( '' );
setIsPanelOpen( false );
},
onCloseModal: () => {
setCurrentTab( '' );
setIsPanelOpen( false );
},
},
{
type: 'snackbar',
icon: <span>🌟</span>,
}
);
},
visible: isAddProductPage(),
};
const setup = {
@ -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() }
/>
</Section>
{ isAddProductPage() && (
<ProductFeedbackTour currentTab={ currentTab } />
) }
{ showHelpHighlightTooltip ? (
<HighlightTooltip
delay={ 1000 }

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { dispatch, resolveSelect } from '@wordpress/data';
import { getQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
@ -42,7 +43,7 @@ export const getExitPageData = () => {
* @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,

View File

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

View File

@ -25,7 +25,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
case TYPES.SHOW_CES_MODAL:
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,

View File

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

View File

@ -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 }

View File

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

View File

@ -29,7 +29,6 @@ const EditProductPage = lazy( () =>
/* webpackChunkName: "edit-product-page" */ '../products/edit-product-page'
)
);
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 ) {

View File

@ -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( () => {

View File

@ -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;

View File

@ -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 = () => {
</div>
</ProductFormLayout>
) }
{ product &&
{ productVariation && product && (
<ProductVariationForm
product={ product }
productVariation={ productVariation }
/>
) }
{ ! isProductVariation &&
product &&
( product.status !== 'trash' || wasDeletedUsingAction ) && (
<ProductForm formRef={ formRef } product={ product } />
) }

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}

View File

@ -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,47 +179,49 @@ 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 (
<Card>
<CardBody>
<div className="woocommerce-attribute-field">
<div className="woocommerce-attribute-field__empty-container">
<img
src={ AttributeEmptyStateLogo }
alt="Completed"
className="woocommerce-attribute-field__empty-logo"
/>
<Text
variant="subtitle.small"
weight="600"
size="14"
lineHeight="20px"
className="woocommerce-attribute-field__empty-subtitle"
>
{ __( 'No attributes yet', 'woocommerce' ) }
</Text>
<Button
variant="secondary"
className="woocommerce-attribute-field__add-new"
onClick={ () => {
<>
<AttributeEmptyState
addNewLabel={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onNewClick={ () => {
recordEvent(
'product_add_first_attribute_button_click'
);
setShowAddAttributeModal( true );
} }
>
{ __( 'Add first attribute', 'woocommerce' ) }
</Button>
</div>
subtitle={
isOnlyForVariations
? __( 'No options yet', 'woocommerce' )
: undefined
}
/>
{ showAddAttributeModal && (
<AddAttributeModal
onCancel={ () => {
@ -205,20 +229,20 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false );
} }
onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( value || [] ).map(
selectedAttributeIds={ ( filteredAttributes || [] ).map(
( attr ) => attr.id
) }
/>
) }
<SelectControlMenuSlot />
</div>
</CardBody>
</Card>
</>
);
}
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 (
<div className="woocommerce-attribute-field">
<Sortable
@ -241,66 +279,39 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
);
} }
>
{ sortedAttributes.map( ( attribute ) => (
<ListItem key={ fetchAttributeId( attribute ) }>
<div>{ attribute.name }</div>
<div className="woocommerce-attribute-field__attribute-options">
{ attribute.options
.slice( 0, 2 )
.map( ( option, index ) => (
<div
className="woocommerce-attribute-field__attribute-option-chip"
key={ index }
>
{ option }
</div>
) ) }
{ attribute.options.length > 2 && (
<div className="woocommerce-attribute-field__attribute-option-chip">
{ sprintf(
__( '+ %i more', 'woocommerce' ),
attribute.options.length - 2
) }
</div>
) }
</div>
<div className="woocommerce-attribute-field__attribute-actions">
<Button
variant="tertiary"
onClick={ () =>
setEditingAttributeId(
fetchAttributeId( attribute )
)
{ sortedAttributes.map( ( attr ) => (
<AttributeListItem
attribute={ attr }
key={ fetchAttributeId( attr ) }
onEditClick={ () =>
setEditingAttributeId( fetchAttributeId( attr ) )
}
>
{ __( 'edit', 'woocommerce' ) }
</Button>
<Button
icon={ closeSmall }
label={ __(
'Remove attribute',
'woocommerce'
) }
onClick={ () => onRemove( attribute ) }
></Button>
</div>
</ListItem>
onRemoveClick={ () => onRemove( attr ) }
/>
) ) }
</Sortable>
<ListItem>
<Button
variant="secondary"
className="woocommerce-attribute-field__add-attribute"
onClick={ () => {
recordEvent( 'product_add_attribute_button' );
<AddAttributeListItem
label={
isOnlyForVariations
? __( 'Add option', 'woocommerce' )
: undefined
}
onAddClick={ () => {
recordEvent(
isOnlyForVariations
? 'product_add_option_button'
: 'product_add_attribute_button'
);
setShowAddAttributeModal( true );
} }
>
{ __( 'Add attribute', 'woocommerce' ) }
</Button>
</ListItem>
/>
{ showAddAttributeModal && (
<AddAttributeModal
title={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onCancel={ () => {
recordEvent( CANCEL_BUTTON_EVENT_NAME );
setShowAddAttributeModal( false );
@ -312,12 +323,37 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
<SelectControlMenuSlot />
{ editingAttributeId && (
<EditAttributeModal
title={
/* translators: %s is the attribute name */
sprintf(
__( 'Edit %s', 'woocommerce' ),
attribute.name
)
}
globalAttributeHelperMessage={ interpolateComponents( {
mixedString: editAttributeCopy,
components: {
link: (
<Link
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
target="_blank"
type="wp-admin"
>
<></>
</Link>
),
},
} ) }
onCancel={ () => setEditingAttributeId( null ) }
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 }
/>
) }
</div>

View File

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

View File

@ -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(
<AttributeField
value={ [ ...attributeList ] }
onChange={ () => {} }
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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
.woocommerce-product-variations {
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%;

View File

@ -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 (
<Card className="woocommerce-product-variations is-loading">
@ -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 (
<Card className="woocommerce-product-variations">
<div className="woocommerce-product-variations__header">
@ -83,27 +135,143 @@ export const Variations: React.FC = () => {
</h4>
<h4>{ __( 'Quantity', 'woocommerce' ) }</h4>
</div>
<Sortable>
{ variations.map( ( variation ) => (
<ListItem key={ variation.id }>
<Sortable onOrderChange={ onOrderChange }>
{ sortedVariations.map( ( variation ) => (
<ListItem key={ getVariationKey( variation ) }>
<div className="woocommerce-product-variations__attributes">
{ variation.attributes.map( ( attribute ) => (
{ variation.attributes.map( ( attribute ) => {
const tag = (
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
/* @ts-ignore Additional props are not required. */
<Tag
id={ attribute.id }
className="woocommerce-product-variations__attribute"
key={ attribute.id }
label={ attribute.option }
label={ truncate( attribute.option, {
length: PRODUCT_VARIATION_TITLE_LIMIT,
} ) }
screenReaderLabel={ attribute.option }
/>
) ) }
);
return attribute.option.length <=
PRODUCT_VARIATION_TITLE_LIMIT ? (
tag
) : (
<Tooltip
key={ attribute.id }
text={ attribute.option }
position="top center"
>
<span>{ tag }</span>
</Tooltip>
);
} ) }
</div>
<div className="woocommerce-product-variations__price">
<div
className={ classnames(
'woocommerce-product-variations__price',
{
'woocommerce-product-variations__price--fade':
variation.status === 'private',
}
) }
>
{ formatAmount( variation.price ) }
</div>
<div className="woocommerce-product-variations__quantity">
<div
className={ classnames(
'woocommerce-product-variations__quantity',
{
'woocommerce-product-variations__quantity--fade':
variation.status === 'private',
}
) }
>
<span
className={ classnames(
'woocommerce-product-variations__status-dot',
getProductStockStatusClass( variation )
) }
>
</span>
{ getProductStockStatus( variation ) }
</div>
<div className="woocommerce-product-variations__actions">
<Link
href={ getNewPath(
{},
`/product/${ productId }/variation/${ variation.id }`
) }
type="wc-admin"
className="components-button"
>
{ __( 'Edit', 'woocommerce' ) }
</Link>
{ variation.status === 'private' && (
<Tooltip
position="top center"
text={ NOT_VISIBLE_TEXT }
>
<Button
className="components-button--hidden"
aria-label={
isUpdating[ variation.id ]
? UPDATING_TEXT
: NOT_VISIBLE_TEXT
}
aria-disabled={
isUpdating[ variation.id ]
}
onClick={ () =>
handleCustomerVisibilityClick(
variation.id,
'publish'
)
}
>
{ isUpdating[ variation.id ] ? (
<Spinner />
) : (
<HiddenIcon />
) }
</Button>
</Tooltip>
) }
{ variation.status === 'publish' && (
<Tooltip
position="top center"
text={ VISIBLE_TEXT }
>
<Button
className="components-button--visible"
aria-label={
isUpdating[ variation.id ]
? UPDATING_TEXT
: VISIBLE_TEXT
}
aria-disabled={
isUpdating[ variation.id ]
}
onClick={ () =>
handleCustomerVisibilityClick(
variation.id,
'private'
)
}
>
{ isUpdating[ variation.id ] ? (
<Spinner />
) : (
<VisibleIcon />
) }
</Button>
</Tooltip>
) }
</div>
</ListItem>
) ) }
</Sortable>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,8 @@
*/
import { __ } from '@wordpress/i18n';
import { 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 ? (
<Tooltip
text={ __(
'Manage individual variation details in the Options tab.',
'woocommerce'
) }
>
<span className="woocommerce-product-form-tab__item-inner">
<span className="woocommerce-product-form-tab__item-inner-text">
{ child.props.title }
</span>
</span>
</Tooltip>
) : (
<span className="woocommerce-product-form-tab__item-inner">
<span className="woocommerce-product-form-tab__item-inner-text">
{ child.props.title }
</span>
</span>
),
disabled: child.props.disabled,
};
} );
@ -40,8 +63,16 @@ export const ProductFormLayout: React.FC< {
<TabPanel
className="product-form-layout"
activeClass="is-active"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Disabled properties will be included in newer versions of Gutenberg.
tabs={ tabs }
onSelect={ () => ( window.document.documentElement.scrollTop = 0 ) }
initialTabName={ query.tab ?? tabs[ 0 ].name }
onSelect={ ( tabName: string ) => {
window.document.documentElement.scrollTop = 0;
navigateTo( {
url: getNewPath( { tab: tabName } ),
} );
} }
>
{ ( tab ) => (
<>

View File

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

View File

@ -6,7 +6,7 @@ $gutenberg-blue-darker: var(--wp-admin-theme-color-darker-20);
flex-direction: row;
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;
}
}

View File

@ -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;

View File

@ -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< {
<ImagesSection />
<AttributesSection />
</ProductFormTab>
<ProductFormTab name="pricing" title="Pricing">
<ProductFormTab
name="pricing"
title="Pricing"
disabled={ !! product?.variations?.length }
>
<PricingSection />
</ProductFormTab>
<ProductFormTab name="inventory" title="Inventory">
<ProductFormTab
name="inventory"
title="Inventory"
disabled={ !! product?.variations?.length }
>
<ProductInventorySection />
</ProductFormTab>
<ProductFormTab name="shipping" title="Shipping">
<ProductFormTab
name="shipping"
title="Shipping"
disabled={ !! product?.variations?.length }
>
<ProductShippingSection product={ product } />
</ProductFormTab>
<ProductFormTab name="options" title="Options">
<OptionsSection />
<ProductVariationsSection />
</ProductFormTab>
</ProductFormLayout>

View File

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

View File

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

View File

@ -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 { 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
? select( PRODUCTS_STORE_NAME ).getProduct(
parseInt( productId, 10 ),
undefined
)
? 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 ),
] );
const isVariationLoading =
variationId &&
productId &&
! hasProductVariationFinishedResolution(
'getProductVariation',
[
{
id: parseInt( variationId, 10 ),
product_id: parseInt( productId, 10 ),
},
]
);
return {
persistedName: product?.name,
productVariation: variation,
isLoading: isProductLoading || isVariationLoading,
};
} );
}
);
const breadcrumbs = [
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 }
<ProductStatusBadge />
</>
),
},
productVariationTitle && {
title: (
<span title={ productVariationTitle }>
{ getTruncatedProductVariationTitle( productVariation ) }
</span>
),
},
].filter( ( page ) => !! page ) as {
href: string;
title: string | JSX.Element;
}[];
const current = pageHierarchy.pop();
if ( isLoading ) {
return null;
}
return (
<WooHeaderPageTitle>
<span className="woocommerce-product-title">
<ProductBreadcrumbs breadcrumbs={ breadcrumbs } />
<ProductBreadcrumbs breadcrumbs={ pageHierarchy } />
<span className="woocommerce-product-title__wrapper">
{ title }
<ProductStatusBadge />
{ current?.title }
</span>
</span>
</WooHeaderPageTitle>

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