Merge remote-tracking branch 'origin/trunk' into tweak/loading-indicator-tax-task-store-location

This commit is contained in:
Ilyas Foo 2023-08-14 10:16:11 +08:00
commit dc2c4e7c48
203 changed files with 45066 additions and 997 deletions

View File

@ -1,50 +0,0 @@
name: Build release asset
on:
release:
types: [published]
permissions: {}
jobs:
build:
name: Build release asset
runs-on: ubuntu-20.04
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build: false
- name: Build zip
working-directory: plugins/woocommerce
run: bash bin/build-zip.sh
- name: Upload release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: plugins/woocommerce/woocommerce.zip
asset_name: woocommerce.zip
asset_content_type: application/zip
update-code-reference:
if: github.event.release.prerelease == false && github.event.release.draft == false && github.repository_owner == 'woocommerce'
name: Update Code Reference
needs: build
runs-on: ubuntu-20.04
steps:
- name: Invoke Code Reference build and deploy workflow
uses: aurelien-baudet/workflow-dispatch@v2
with:
workflow: GitHub Pages deploy
repo: woocommerce/code-reference
token: ${{ secrets.CUSTOM_GH_TOKEN }}
ref: refs/heads/trunk
inputs: '{ "version": "${{ github.event.release.tag_name }}" }'

View File

@ -9,8 +9,6 @@ defaults:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PHPCS: ./plugins/woocommerce/vendor/bin/phpcs # Run WooCommerce phpcs setup in phpcs-changed instead of default
permissions: {}
@ -30,6 +28,7 @@ jobs:
id: changed-files
uses: tj-actions/changed-files@v32
with:
path: plugins/woocommerce
files: |
**/*.php
@ -41,6 +40,7 @@ jobs:
- name: Tool versions
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: plugins/woocommerce
run: |
php --version
composer --version
@ -48,6 +48,7 @@ jobs:
- name: Run PHPCS
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: plugins/woocommerce
run: |
HEAD_REF=$(git rev-parse HEAD)
git checkout $HEAD_REF

View File

@ -20,6 +20,10 @@ jobs:
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
- name: Lint
working-directory: plugins/woocommerce-beta-tester
run: composer run phpcs
- name: Build WooCommerce Beta Tester Zip
working-directory: plugins/woocommerce-beta-tester
run: pnpm build:zip

View File

@ -1,5 +1,97 @@
== Changelog ==
= 8.0.1 2023-08-09 =
* Fix - bump WooCommerce blocks version to 10.6.5 [#39642](https://github.com/woocommerce/woocommerce/pull/39642)
= 8.0.0 2023-08-08 =
**WooCommerce**
* Fix - Set the order table exist options value when its not present for smooth upgradation from lower WC versions. [#39616](https://github.com/woocommerce/woocommerce/pull/39616)
* Fix - Add derivative features to legacy list so that warning is not generated for them. [#39537](https://github.com/woocommerce/woocommerce/pull/39537)
* Fix - Dequeue blocktheme styles on WooCommerce Admin pages when product block editor is enabled. [#39478](https://github.com/woocommerce/woocommerce/pull/39478)
* Fix - Do not disable "Used for variations" checkbox on attribute. [#39502](https://github.com/woocommerce/woocommerce/pull/39502)
* Fix - Adds a grace period during which email verification will not be needed before the order confirmation (or payment) page is rendered. [#39191](https://github.com/woocommerce/woocommerce/pull/39191)
* Fix - Fix turn off the new block experience when tracking is turned off [#39317](https://github.com/woocommerce/woocommerce/pull/39317)
* Fix - Restore woocommerce_variations_added jQuery trigger. [#39301](https://github.com/woocommerce/woocommerce/pull/39301)
* Fix - Add wrapper to the content generated by wc_empty_cart_message [[#38738]](https://github.com/woocommerce/woocommerce/pull/38738)
* Fix - Allow to add custom metabox to custom order edit page by setting the correct screen id. [[#38938]](https://github.com/woocommerce/woocommerce/pull/38938)
* Fix - Bootstrap server side block definitions [[#39027]](https://github.com/woocommerce/woocommerce/pull/39027)
* Fix - Comment: Fix "Used for variations" checkbox being disabled on Variable products [[#39106]](https://github.com/woocommerce/woocommerce/pull/39106)
* Fix - Convert DatabaseUtil::get_index_columns() to use SHOW INDEX FROM instead of INFORMATION_SCHEMA query [[#36427]](https://github.com/woocommerce/woocommerce/pull/36427)
* Fix - Decrease product total sales when an order is reversed [[#37842]](https://github.com/woocommerce/woocommerce/pull/37842)
* Fix - Do not show "Adding new attribute failed" error message when loading of product screens is interrupted by page unload. [[#38815]](https://github.com/woocommerce/woocommerce/pull/38815)
* Fix - do not use image size cache while in customizer [[#38875]](https://github.com/woocommerce/woocommerce/pull/38875)
* Fix - Fix attribute taxonomy templates when a templates for specific product attribute exists. [[#37552]](https://github.com/woocommerce/woocommerce/pull/37552)
* Fix - Fixed failing shipping zones tests and cleaned up locators [[#38949]](https://github.com/woocommerce/woocommerce/pull/38949)
* Fix - Fix grey background when menu is collapsed #38887 [[#38941]](https://github.com/woocommerce/woocommerce/pull/38941)
* Fix - Fix links under "Help" panel on Home screen [[#38817]](https://github.com/woocommerce/woocommerce/pull/38817)
* Fix - Fix Minimum Requirements in readme.txt file [[#39078]](https://github.com/woocommerce/woocommerce/pull/39078)
* Fix - Fix the issue of stores sending frequent Helper API requests when the store databases disk is full. [[#37378]](https://github.com/woocommerce/woocommerce/pull/37378)
* Fix - Fix the layout of View Cart link on the posts/pages [[#38950]](https://github.com/woocommerce/woocommerce/pull/38950)
* Fix - Fix undismissable store alert when using language localization [[#38967]](https://github.com/woocommerce/woocommerce/pull/38967)
* Fix - Fix unexpected gap on ipad and ipad mini [[#39108]](https://github.com/woocommerce/woocommerce/pull/39108)
* Fix - Linting fix to webpack config. [[#38920]](https://github.com/woocommerce/woocommerce/pull/38920)
* Fix - Make `WC_Order::get_item_subtotal()` always return a float. [[#36760]](https://github.com/woocommerce/woocommerce/pull/36760)
* Fix - OrdersTableDataStore: capture and log errors when populating order properties [[#38840]](https://github.com/woocommerce/woocommerce/pull/38840)
* Fix - Revert "Always show pricing group fields, disable if not available for a product type" [[#38964]](https://github.com/woocommerce/woocommerce/pull/38964)
* Fix - Support dynamic prop setting to use in refunds for setting correct props. [[#39219]](https://github.com/woocommerce/woocommerce/pull/39219)
* Fix - Trigger "woocommerce_newly_installed" hook for new installations [[#38694]](https://github.com/woocommerce/woocommerce/pull/38694)
* Fix - When dormant customer accounts are removed, their content should be preserved. [[#38837]](https://github.com/woocommerce/woocommerce/pull/38837)
* Fix - [HPOS] Backfill to post table only after order has persisted in orders table. [[#39196]](https://github.com/woocommerce/woocommerce/pull/39196)
* Add - Add support for BG, CZ, HR, HU, RO and SE in WCPay [[#38109]](https://github.com/woocommerce/woocommerce/pull/38109)
* Add - Add synchronization of deleted orders for HPOS [[#37050]](https://github.com/woocommerce/woocommerce/pull/37050)
* Add - Add Variations tab to Product block editor [[#38921]](https://github.com/woocommerce/woocommerce/pull/38921)
* Add - Allow registered React-powered pages to specify a parent navigation menu item to highlight when active. [[#39116]](https://github.com/woocommerce/woocommerce/pull/39116)
* Add - Introduce the add and edit view Tracks events in the new form [[#39186]](https://github.com/woocommerce/woocommerce/pull/39186)
* Add - Register woocommerce/product-variations-fields block [[#39038]](https://github.com/woocommerce/woocommerce/pull/39038)
* Add - Removed Avalara from Tax task list item [[#39238]](https://github.com/woocommerce/woocommerce/pull/39238)
* Add - Show create campaign button when there are campaign types in marketing page. [[#38825]](https://github.com/woocommerce/woocommerce/pull/38825)
* Update - Update WooCommerce Blocks to 10.6.4 [#39582](https://github.com/woocommerce/woocommerce/pull/39582)
* Update - Update WooCommerce Blocks to 10.6.3 [#39560](https://github.com/woocommerce/woocommerce/pull/39560)
* Update - Update WooCommerce Blocks to 10.6.2 [#39492](https://github.com/woocommerce/woocommerce/pull/39492)
* Update - Fix grammar in data-sharing agreement copy [#39327](https://github.com/woocommerce/woocommerce/pull/39327)
* Update - Update WooCommerce Blocks to 10.6.1 [#39299](https://github.com/woocommerce/woocommerce/pull/39299)
* Update - Add Klaviyo into onboarding marketing task list. [[#39087]](https://github.com/woocommerce/woocommerce/pull/39087)
* Update - Analytics API: A new filter_empty parameter that allows to remove from result customers with given empty fields [[#38827]](https://github.com/woocommerce/woocommerce/pull/38827)
* Update - Analytics API: Search for customers by all of the available fields instead of having to choose one [[#38628]](https://github.com/woocommerce/woocommerce/pull/38628)
* Update - Branding rollout - change WooCommerce Payments to WooPayments [[#39188]](https://github.com/woocommerce/woocommerce/pull/39188)
* Update - Change button text from "Install plugin" to "Install extension" in Marketing page. [[#39130]](https://github.com/woocommerce/woocommerce/pull/39130)
* Update - Remove Tiktok from plugin suggestion list [[#39135]](https://github.com/woocommerce/woocommerce/pull/39135)
* Update - Update the call to marketing extensions recommendations API from version 1.2 to version 1.3 with new recommendation Klaviyo. [[#38974]](https://github.com/woocommerce/woocommerce/pull/38974)
* Update - Update WooCommerce Blocks to 10.6.0 [[#39144]](https://github.com/woocommerce/woocommerce/pull/39144)
* Update - Use wp_json_encode instead of print_r in output for HPOS-related WP CLI commands. [[#38699]](https://github.com/woocommerce/woocommerce/pull/38699)
* Dev - Add more assertions to `can add custom product attributes` E2E test. [[#39139]](https://github.com/woocommerce/woocommerce/pull/39139)
* Dev - Add pnpm commands for easier PHP linting [[#38727]](https://github.com/woocommerce/woocommerce/pull/38727)
* Dev - Cleanup global state after testing `wc_load_cart()`. [[#39136]](https://github.com/woocommerce/woocommerce/pull/39136)
* Dev - Enable HPOS through the `wp option` command. [[#39095]](https://github.com/woocommerce/woocommerce/pull/39095)
* Dev - Ensure `can discard industry changes when navigating back to "Store Details"'` can run independent from previous tests [[#38715]](https://github.com/woocommerce/woocommerce/pull/38715)
* Dev - Fix skipping of core profiler in page-loads.spec.js. [[#39084]](https://github.com/woocommerce/woocommerce/pull/39084)
* Dev - Improvements to the DI related unit testing infrastructure [[#38849]](https://github.com/woocommerce/woocommerce/pull/38849)
* Dev - Replace deprecated page methods. [[#38344]](https://github.com/woocommerce/woocommerce/pull/38344)
* Dev - Skip failing e2e test preventing PRs being merged. GH fails but local works [[#38855]](https://github.com/woocommerce/woocommerce/pull/38855)
* Dev - Update locators in `order-emails` and `order-email-receiving` specs so that they pass on WP 6.3. [[#39159]](https://github.com/woocommerce/woocommerce/pull/39159)
* Dev - Update locators in `order-emails` spec, and use the latest version of WP Mail Logging. [[#39013]](https://github.com/woocommerce/woocommerce/pull/39013)
* Dev - Update pnpm monorepo-wide to 8.6.5 [[#38990]](https://github.com/woocommerce/woocommerce/pull/38990)
* Dev - Update `wp-env` to version 8.2.0. [[#39100]](https://github.com/woocommerce/woocommerce/pull/39100)
* Dev - Use grunt-contrib-uglify-es to handle legacy es6" [[#38342]](https://github.com/woocommerce/woocommerce/pull/38342)
* Tweak - Add autoFocus attribute to product-name-field block [[#39050]](https://github.com/woocommerce/woocommerce/pull/39050)
* Tweak - Corrects a minor issue (incorrect HTML markup) in the System Status Report. [[#39053]](https://github.com/woocommerce/woocommerce/pull/39053)
* Tweak - Remove letter-spacing from the core profiler headers; Use 500 for font-weight. [[#39042]](https://github.com/woocommerce/woocommerce/pull/39042)
* Tweak - Replace "Proceed" with "Continue" to be more consistent [[#38961]](https://github.com/woocommerce/woocommerce/pull/38961)
* Tweak - Restore user's plugin selection when there is an installation error [[#38922]](https://github.com/woocommerce/woocommerce/pull/38922)
* Tweak - Update CSS styles for the core profiler header [[#39059]](https://github.com/woocommerce/woocommerce/pull/39059)
* Tweak - Use existing table instead of 'DUAL' to support hosts which do not support 'DUAL'. [[#39111]](https://github.com/woocommerce/woocommerce/pull/39111)
* Tweak - When detecting which plugins are WooCommerce-aware, improve accuracy by ignoring cached plugin data. [[#38836]](https://github.com/woocommerce/woocommerce/pull/38836)
* Tweak - When displaying partial consumer keys in the REST API settings, replace ellipses with asterisks.
* Enhancement - Add action for 'order_edit_form_top' as a replacement to edit_form_top for HPOS. [[#39165]](https://github.com/woocommerce/woocommerce/pull/39165)
* Enhancement - Add filter `woocommerce_redirect_order_location` for consistency with posts and HPOS. [[#39193]](https://github.com/woocommerce/woocommerce/pull/39193)
* Enhancement - New product block editor modal text enhancements [[#39055]](https://github.com/woocommerce/woocommerce/pull/39055)
* Enhancement - Refresh UX to enable HPOS to make it user friendly. [[#38993]](https://github.com/woocommerce/woocommerce/pull/38993)
= 7.9.0 2023-07-17 =
**WooCommerce**

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add catalog_visibility property to the Product type

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add default attributes property to the Product type

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update ProductAttributeTerms action types and include it as part of @wordpress/data module

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Add new user preference to UserPreferences type.

View File

@ -1,8 +1,16 @@
/**
* External dependencies
*/
import { SelectFromMap } from '@automattic/data-stores';
/**
* Internal dependencies
*/
import { STORE_NAME, WC_PRODUCT_ATTRIBUTE_TERMS_NAMESPACE } from './constants';
import { createCrudDataStore } from '../crud';
import { ActionDispatchers, ProductAttributeTermsSelectors } from './types';
import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
createCrudDataStore( {
storeName: STORE_NAME,
@ -12,3 +20,14 @@ createCrudDataStore( {
} );
export const EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME = STORE_NAME;
declare module '@wordpress/data' {
// TODO: convert action.js to TS
function dispatch( key: typeof STORE_NAME ): ActionDispatchers;
function select(
key: typeof STORE_NAME
): SelectFromMap< ProductAttributeTermsSelectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< ProductAttributeTermsSelectors > >;
}

View File

@ -40,7 +40,7 @@ type MutableProperties = Partial<
type ProductAttributeTermActions = CrudActions<
'ProductAttributeTerm',
ProductAttributeTerm,
ProductAttributeTerm & { attribute_id: string },
MutableProperties,
'name'
>;

View File

@ -36,12 +36,36 @@ export type ProductAttribute = {
options: string[];
};
/**
* Product - Default attributes properties
*/
export type ProductDefaultAttribute = {
/**
* Attribute ID.
*/
id: number;
/**
* Attribute name.
*/
name: string;
/**
* Selected attribute term name.
*/
option: string;
};
export type ProductDimensions = {
width: string;
height: string;
length: string;
};
export type ProductCatalogVisibility =
| 'visible'
| 'catalog'
| 'search'
| 'hidden';
export type Product< Status = ProductStatus, Type = ProductType > = Omit<
Schema.Post,
'status' | 'categories'
@ -53,12 +77,14 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit<
backorders_allowed: boolean;
button_text: string;
categories: Pick< ProductCategory, 'id' | 'name' | 'slug' >[];
catalog_visibility: ProductCatalogVisibility;
date_created: string;
date_created_gmt: string;
date_modified: string;
date_modified_gmt: string;
date_on_sale_from_gmt: string | null;
date_on_sale_to_gmt: string | null;
default_attributes: ProductDefaultAttribute[];
description: string;
dimensions: ProductDimensions;
download_expiry: number;

View File

@ -26,6 +26,7 @@ export type UserPreferences = {
};
taxes_report_columns?: string;
variable_product_tour_shown?: string;
variable_product_block_tour_shown?: string;
variations_report_columns?: string;
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add support to attribute input field for creating global attributes by default and enable this for variation blocks.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Create product search and catalog visibility blocks

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Focus the first attribute field when opening the modal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add set default value checkbox to the edit attribute options modal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product variation items block

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
[Product Block Editor] Remove additional create attribute term modal

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Comment: Small change to fix bug in previous PR.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add isInSelectedTab context to tab blocks for use in nested blocks.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update variation options block to auto create variations upon options update.

View File

@ -0,0 +1,31 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-catalog-visibility-field",
"description": "A checkbox to manage the catalog visibility of the product.",
"title": "Product catalog visibility",
"category": "widgets",
"keywords": [ "products", "catalog" ],
"textdomain": "default",
"attributes": {
"label": {
"type": "string",
"__experimentalRole": "content"
},
"visibilty": {
"type": "string",
"enum": [ "visible", "catalog", "search", "hidden" ],
"default": "visible"
}
},
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false,
"lock": false,
"__experimentalToolbar": false
},
"editorStyle": "file:./editor.css"
}

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import { CheckboxControl } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { createElement } from '@wordpress/element';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { CatalogVisibilityBlockAttributes } from './types';
export function Edit( {
attributes,
}: {
attributes: CatalogVisibilityBlockAttributes;
} ) {
const { label, visibilty } = attributes;
const blockProps = useBlockProps();
const [ catalogVisibility, setCatalogVisibility ] = useEntityProp<
Product[ 'catalog_visibility' ]
>( 'postType', 'product', 'catalog_visibility' );
const checked =
catalogVisibility === visibilty || catalogVisibility === 'hidden';
function handleChange( selected: boolean ) {
if ( selected ) {
if ( catalogVisibility === 'visible' ) {
setCatalogVisibility( visibilty );
return;
}
setCatalogVisibility( 'hidden' );
} else {
if ( catalogVisibility === 'hidden' ) {
if ( visibilty === 'catalog' ) {
setCatalogVisibility( 'search' );
return;
}
if ( visibilty === 'search' ) {
setCatalogVisibility( 'catalog' );
return;
}
return;
}
setCatalogVisibility( 'visible' );
}
}
return (
<div { ...blockProps }>
<CheckboxControl
label={ label }
checked={ checked }
onChange={ handleChange }
/>
</div>
);
}

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { CatalogVisibilityBlockAttributes } from './types';
const { name, ...metadata } =
blockConfiguration as BlockConfiguration< CatalogVisibilityBlockAttributes >;
export { metadata, name };
export const settings: Partial<
BlockConfiguration< CatalogVisibilityBlockAttributes >
> = {
example: {},
edit: Edit,
};
export function init() {
return initBlock( { name, metadata, settings } );
}

View File

@ -0,0 +1,10 @@
/**
* External dependencies
*/
import { Product } from '@woocommerce/data';
import { BlockAttributes } from '@wordpress/blocks';
export interface CatalogVisibilityBlockAttributes extends BlockAttributes {
label: string;
visibilty: Product[ 'catalog_visibility' ];
}

View File

@ -1,11 +1,16 @@
/**
* External dependencies
*/
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import initBlock from '../../utils/init-block';
import metadata from './block.json';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
const { name } = metadata;
const { name, ...metadata } = blockConfiguration as BlockConfiguration;
export { metadata, name };

View File

@ -1,11 +1,16 @@
/**
* External dependencies
*/
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import initBlock from '../../utils/init-block';
import metadata from './block.json';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
const { name } = metadata;
const { name, ...metadata } = blockConfiguration as BlockConfiguration;
export { metadata, name };

View File

@ -1,3 +1,4 @@
export { init as initCatalogVisibility } from './catalog-visibility';
export { init as initCategory } from './category';
export { init as initCheckbox } from './checkbox';
export { init as initCollapsible } from './collapsible';
@ -22,4 +23,5 @@ export { init as initToggle } from './toggle';
export { init as attributesInit } from './attributes';
export { init as initVariations } from './variations';
export { init as initRequirePassword } from './password';
export { init as initVariationItems } from './variation-items';
export { init as initVariationOptions } from './variation-options';

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { InventoryEmailBlockAttributes } from './types';

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { TrackInventoryBlockAttributes } from './types';

View File

@ -1,11 +1,16 @@
/**
* External dependencies
*/
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import initBlock from '../../utils/init-block';
import metadata from './block.json';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
const { name } = metadata;
const { name, ...metadata } = blockConfiguration as BlockConfiguration;
export { metadata, name };

View File

@ -6,7 +6,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { RequirePasswordBlockAttributes } from './types';

View File

@ -6,7 +6,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { PricingBlockAttributes } from './types';

View File

@ -6,7 +6,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { RadioBlockAttributes } from './types';

View File

@ -6,7 +6,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { SalePriceBlockAttributes } from './types';

View File

@ -6,7 +6,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { SalePriceBlockAttributes } from './types';

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { ScheduleSalePricingBlockAttributes } from './types';

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { SectionBlockAttributes } from './types';

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { ShippingClassBlockAttributes } from './types';

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { ShippingDimensionsBlockAttributes } from './types';

View File

@ -15,4 +15,5 @@
@import 'tab/editor.scss';
@import 'variations/editor.scss';
@import 'password/editor.scss';
@import 'variation-items/editor.scss';
@import 'variation-options/editor.scss';

View File

@ -27,6 +27,9 @@
"lock": false,
"__experimentalToolbar": false
},
"providesContext": {
"isInSelectedTab": "isSelected"
},
"usesContext": [ "selectedTab" ],
"editorStyle": "file:./editor.css",
"templateLock": "contentOnly"

View File

@ -4,25 +4,35 @@
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';
import { createElement } from '@wordpress/element';
import type { BlockAttributes } from '@wordpress/blocks';
import type { BlockAttributes, BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { TabButton } from './tab-button';
export interface TabBlockAttributes extends BlockAttributes {
id: string;
title: string;
order: number;
isSelected?: boolean;
}
export function Edit( {
setAttributes,
attributes,
context,
}: {
attributes: BlockAttributes;
}: BlockEditProps< TabBlockAttributes > & {
context?: {
selectedTab?: string | null;
};
} ) {
const blockProps = useBlockProps();
const { id, title, order } = attributes;
const { id, title, order, isSelected: contextIsSelected } = attributes;
const isSelected = context?.selectedTab === id;
if ( isSelected !== contextIsSelected ) {
setAttributes( { isSelected } );
}
const classes = classnames( 'wp-block-woocommerce-product-tab__content', {
'is-selected': isSelected,

View File

@ -1,17 +1,25 @@
/**
* External dependencies
*/
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import initBlock from '../../utils/init-block';
import metadata from './block.json';
import { Edit } from './edit';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit, TabBlockAttributes } from './edit';
const { name } = metadata;
const { name, ...metadata } =
blockConfiguration as BlockConfiguration< TabBlockAttributes >;
export { metadata, name };
export const settings = {
export const settings: Partial< BlockConfiguration< TabBlockAttributes > > = {
example: {},
edit: Edit,
};
export const init = () => initBlock( { name, metadata, settings } );
export function init() {
initBlock( { name, metadata, settings } );
}

View File

@ -6,7 +6,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { ToggleBlockAttributes } from './types';

View File

@ -0,0 +1,27 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-variation-items-field",
"title": "Product variations items",
"category": "woocommerce",
"description": "The product variations items.",
"keywords": [ "products", "variations" ],
"textdomain": "default",
"attributes": {
"description": {
"type": "string",
"__experimentalRole": "content"
}
},
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false,
"lock": false,
"__experimentalToolbar": false
},
"usesContext": [ "isInSelectedTab" ],
"editorStyle": "file:./editor.css"
}

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { VariationsTable } from '../../components/variations-table';
import { VariationOptionsBlockAttributes } from './types';
import { VariableProductTour } from './variable-product-tour';
export function Edit( {
context,
}: BlockEditProps< VariationOptionsBlockAttributes > & {
context?: {
isInSelectedTab?: boolean;
};
} ) {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<VariationsTable />
{ context?.isInSelectedTab && <VariableProductTour /> }
</div>
);
}

View File

@ -0,0 +1,13 @@
.wp-block-woocommerce-product-variations-items-field {
}
.variation-items-product-tour {
.tour-kit-spotlight {
border-radius: $gap-smaller;
padding: $gap-large;
}
.tour-kit-frame__container,
.woocommerce-tour-kit-step {
border-radius: $gap-smaller;
}
}

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { VariationOptionsBlockAttributes } from './types';
const { name, ...metadata } =
blockConfiguration as BlockConfiguration< VariationOptionsBlockAttributes >;
export { metadata, name };
export const settings: Partial<
BlockConfiguration< VariationOptionsBlockAttributes >
> = {
example: {},
edit: Edit,
};
export function init() {
return initBlock( { name, metadata, settings } );
}

View File

@ -0,0 +1,8 @@
/**
* External dependencies
*/
import { BlockAttributes } from '@wordpress/blocks';
export interface VariationOptionsBlockAttributes extends BlockAttributes {
description: string;
}

View File

@ -0,0 +1,138 @@
/**
* External dependencies
*/
import { createElement, useEffect, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { TourKit, TourKitTypes } from '@woocommerce/components';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
useUserPreferences,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useSelect } from '@wordpress/data';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { DEFAULT_PER_PAGE_OPTION } from '../../constants';
export const VariableProductTour: React.FC = () => {
const [ isTourOpen, setIsTourOpen ] = useState( false );
const productId = useEntityId( 'postType', 'product' );
const prevTotalCount = useRef< undefined | number >();
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const requestParams = {
product_id: productId,
page: 1,
per_page: DEFAULT_PER_PAGE_OPTION,
order: 'asc',
orderby: 'menu_order',
};
return {
totalCount:
getProductVariationsTotalCount< number >( requestParams ),
};
},
[ productId ]
);
const {
updateUserPreferences,
variable_product_block_tour_shown: hasShownTour,
} = useUserPreferences();
const config: TourKitTypes.WooConfig = {
placement: 'top',
steps: [
{
referenceElements: {
desktop:
'.wp-block-woocommerce-product-variation-items-field',
},
focusElement: {
desktop:
'.wp-block-woocommerce-product-variation-items-field',
},
meta: {
name: 'product-variations-2',
heading: __(
'⚡️ This product now has variations',
'woocommerce'
),
descriptions: {
desktop: __(
'From now on, youll manage pricing, shipping, and inventory for each variation individually—just like any other product in your store.',
'woocommerce'
),
},
primaryButton: {
text: __( 'Got it', 'woocommerce' ),
},
},
},
],
options: {
classNames: [ 'variation-items-product-tour' ],
// WooTourKit does not handle merging of default options properly,
// so we need to duplicate the effects options here.
effects: {
arrowIndicator: true,
spotlight: {
interactivity: {
enabled: true,
},
},
},
callbacks: {
onStepViewOnce: () => {
recordEvent( 'variable_product_block_tour_shown', {
variable_count: totalCount,
} );
},
},
popperModifiers: [
{
name: 'offset',
options: {
// 24px for additional padding and 8px for arrow.
offset: [ 0, 32 ],
},
},
],
},
closeHandler: () => {
updateUserPreferences( {
variable_product_block_tour_shown: 'yes',
} );
setIsTourOpen( false );
recordEvent( 'variable_product_block_tour_dismissed' );
},
};
useEffect( () => {
const isFirstVariation =
prevTotalCount.current !== totalCount &&
totalCount > 0 &&
prevTotalCount.current === 0;
prevTotalCount.current = totalCount;
if ( isFirstVariation && ! isTourOpen ) {
setIsTourOpen( true );
}
}, [ totalCount ] );
if ( hasShownTour === 'yes' || ! isTourOpen ) {
return null;
}
return <TourKit config={ config } />;
};

View File

@ -3,8 +3,12 @@
*/
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { createElement, createInterpolateElement } from '@wordpress/element';
import { ProductAttribute } from '@woocommerce/data';
import {
createElement,
createInterpolateElement,
useMemo,
} from '@wordpress/element';
import { Product, ProductAttribute } from '@woocommerce/data';
import { Link } from '@woocommerce/components';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
@ -14,28 +18,78 @@ import { useEntityProp, useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { useProductAttributes } from '../../hooks/use-product-attributes';
import {
EnhancedProductAttribute,
useProductAttributes,
} from '../../hooks/use-product-attributes';
import { AttributeControl } from '../../components/attribute-control';
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
function manageDefaultAttributes( values: EnhancedProductAttribute[] ) {
return values.reduce< Product[ 'default_attributes' ] >(
( prevDefaultAttributes, currentAttribute ) => {
if ( currentAttribute.isDefault ) {
return [
...prevDefaultAttributes,
{
id: currentAttribute.id,
name: currentAttribute.name,
option: currentAttribute.options[ 0 ],
},
];
}
return prevDefaultAttributes;
},
[]
);
}
export function Edit() {
const blockProps = useBlockProps();
const { generateProductVariations } = useProductVariationsHelper();
const [ entityAttributes, setEntityAttributes ] = useEntityProp<
ProductAttribute[]
>( 'postType', 'product', 'attributes' );
const [ entityDefaultAttributes, setEntityDefaultAttributes ] =
useEntityProp< Product[ 'default_attributes' ] >(
'postType',
'product',
'default_attributes'
);
const { attributes, handleChange } = useProductAttributes( {
allAttributes: entityAttributes,
onChange: setEntityAttributes,
isVariationAttributes: true,
productId: useEntityId( 'postType', 'product' ),
onChange( values ) {
setEntityAttributes( values );
setEntityDefaultAttributes( manageDefaultAttributes( values ) );
generateProductVariations( values );
},
} );
function mapDefaultAttributes() {
return attributes.map( ( attribute ) => ( {
...attribute,
isDefault: entityDefaultAttributes.some(
( defaultAttribute ) =>
defaultAttribute.id === attribute.id ||
defaultAttribute.name === attribute.name
),
} ) );
}
return (
<div { ...blockProps }>
<AttributeControl
value={ attributes }
value={ useMemo( mapDefaultAttributes, [
attributes,
entityDefaultAttributes,
] ) }
onChange={ handleChange }
createNewAttributesAsGlobal={ true }
uiStrings={ {
globalAttributeHelperMessage: '',
customAttributeHelperMessage: '',

View File

@ -1,17 +1,20 @@
.wp-block-woocommerce-product-variations-options-field {
.woocommerce-sortable {
.woocommerce-sortable {
padding: 0;
&__item:not(:last-child) .woocommerce-list-item {
border-bottom: 1px solid $gray-200;
}
}
.woocommerce-list-item {
background: none;
border: none;
border-bottom: 1px solid $gray-200;
padding-left: 0;
grid-template-columns: 26% auto 90px;
grid-template-columns: 26% auto 90px;
}
.woocommerce-sortable__handle {
display: none;
}
.woocommerce-sortable__handle {
display: none;
}
}

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { VariationOptionsBlockAttributes } from './types';

View File

@ -34,6 +34,7 @@ import {
useProductAttributes,
} from '../../hooks/use-product-attributes';
import { getAttributeId } from '../../components/attribute-control/utils';
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
function hasAttributesUsedForVariations(
productAttributes: Product[ 'attributes' ]
@ -41,22 +42,42 @@ function hasAttributesUsedForVariations(
return productAttributes.some( ( { variation } ) => variation );
}
function getFirstOptionFromEachAttribute(
attributes: Product[ 'attributes' ]
): Product[ 'default_attributes' ] {
return attributes.map( ( attribute ) => ( {
id: attribute.id,
name: attribute.name,
option: attribute.options[ 0 ],
} ) );
}
export function Edit( {
attributes,
}: BlockEditProps< VariationsBlockAttributes > ) {
const { description } = attributes;
const { generateProductVariations } = useProductVariationsHelper();
const [ isNewModalVisible, setIsNewModalVisible ] = useState( false );
const [ productAttributes, setProductAttributes ] = useEntityProp<
Product[ 'attributes' ]
>( 'postType', 'product', 'attributes' );
const [ , setDefaultProductAttributes ] = useEntityProp<
Product[ 'default_attributes' ]
>( 'postType', 'product', 'default_attributes' );
const { attributes: variationOptions, handleChange } = useProductAttributes(
{
allAttributes: productAttributes,
onChange: setProductAttributes,
isVariationAttributes: true,
productId: useEntityId( 'postType', 'product' ),
onChange( values ) {
setProductAttributes( values );
setDefaultProductAttributes(
getFirstOptionFromEachAttribute( values )
);
generateProductVariations( values );
},
}
);
@ -133,6 +154,7 @@ export function Edit( {
),
}
) }
createNewAttributesAsGlobal={ true }
notice={ '' }
onCancel={ () => {
closeNewModal();

View File

@ -7,7 +7,7 @@ import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils/init-blocks';
import { initBlock } from '../../utils/init-block';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { VariationsBlockAttributes } from './types';

View File

@ -32,7 +32,7 @@ import { AttributeListItem } from '../attribute-list-item';
import { NewAttributeModal } from './new-attribute-modal';
type AttributeControlProps = {
value: ProductAttribute[];
value: EnhancedProductAttribute[];
onAdd?: ( attribute: EnhancedProductAttribute[] ) => void;
onChange: ( value: ProductAttribute[] ) => void;
onEdit?: ( attribute: ProductAttribute ) => void;
@ -44,6 +44,7 @@ type AttributeControlProps = {
onEditModalCancel?: ( attribute?: ProductAttribute ) => void;
onEditModalClose?: ( attribute?: ProductAttribute ) => void;
onEditModalOpen?: ( attribute?: ProductAttribute ) => void;
createNewAttributesAsGlobal?: boolean;
uiStrings?: {
emptyStateSubtitle?: string;
newAttributeListItemLabel?: string;
@ -71,6 +72,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
onRemove = () => {},
onRemoveCancel = () => {},
uiStrings,
createNewAttributesAsGlobal = false,
} ) => {
uiStrings = {
newAttributeListItemLabel: __( 'Add new', 'woocommerce' ),
@ -189,7 +191,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
const currentAttribute = value.find(
( attr ) => getAttributeId( attr ) === currentAttributeId
) as EnhancedProductAttribute;
);
return (
<div className="woocommerce-attribute-field">
@ -246,6 +248,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
} }
onAdd={ handleAdd }
selectedAttributeIds={ value.map( ( attr ) => attr.id ) }
createNewAttributesAsGlobal={ createNewAttributesAsGlobal }
/>
) }
<SelectControlMenuSlot />

View File

@ -14,7 +14,8 @@
width: 500px;
max-width: 100%;
.woocommerce-experimental-select-control + .woocommerce-experimental-select-control {
.woocommerce-experimental-select-control
+ .woocommerce-experimental-select-control {
margin-top: 1.3em;
}
@ -30,6 +31,10 @@
display: flex;
flex-direction: row;
align-items: center;
.components-checkbox-control .components-base-control__field {
margin-bottom: 0;
}
}
.woocommerce-attribute-term-field {

View File

@ -27,6 +27,8 @@ type EditAttributeModalProps = {
customAttributeHelperMessage?: string;
termsLabel?: string;
termsPlaceholder?: string;
isDefaultLabel?: string;
isDefaultTooltip?: string;
visibleLabel?: string;
visibleTooltip?: string;
cancelAccessibleLabel?: string;
@ -48,6 +50,11 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
),
termsLabel = __( 'Values', 'woocommerce' ),
termsPlaceholder = __( 'Search or create value', 'woocommerce' ),
isDefaultLabel = __( 'Set default value', 'woocommerce' ),
isDefaultTooltip = __(
'Check to preselect the first choice when customers enter the product page.',
'woocommerce'
),
visibleLabel = __( 'Visible to customers', 'woocommerce' ),
visibleTooltip = __(
'Show or hide this attribute on the product page',
@ -120,18 +127,36 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
/>
) }
<div className="woocommerce-edit-attribute-modal__option-container">
<CheckboxControl
onChange={ ( val ) =>
setEditableAttribute( {
...( editableAttribute as EnhancedProductAttribute ),
visible: val,
} )
}
checked={ editableAttribute?.visible }
label={ visibleLabel }
/>
<Tooltip text={ visibleTooltip } />
<div className="woocommerce-edit-attribute-modal__options">
{ attribute.variation && (
<div className="woocommerce-edit-attribute-modal__option-container">
<CheckboxControl
onChange={ ( checked ) =>
setEditableAttribute( {
...( editableAttribute as EnhancedProductAttribute ),
isDefault: checked,
} )
}
checked={ editableAttribute?.isDefault }
label={ isDefaultLabel }
/>
<Tooltip text={ isDefaultTooltip } />
</div>
) }
<div className="woocommerce-edit-attribute-modal__option-container">
<CheckboxControl
onChange={ ( val ) =>
setEditableAttribute( {
...( editableAttribute as EnhancedProductAttribute ),
visible: val,
} )
}
checked={ editableAttribute?.visible }
label={ visibleLabel }
/>
<Tooltip text={ visibleTooltip } />
</div>
</div>
</div>
<div className="woocommerce-edit-attribute-modal__buttons">

View File

@ -2,7 +2,12 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, createElement, Fragment } from '@wordpress/element';
import {
useState,
createElement,
Fragment,
useEffect,
} from '@wordpress/element';
import { trash } from '@wordpress/icons';
import {
Form,
@ -49,6 +54,7 @@ type NewAttributeModalProps = {
onCancel: () => void;
onAdd: ( newCategories: EnhancedProductAttribute[] ) => void;
selectedAttributeIds?: number[];
createNewAttributesAsGlobal?: boolean;
};
type AttributeForm = {
@ -81,6 +87,7 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
onCancel,
onAdd,
selectedAttributeIds = [],
createNewAttributesAsGlobal = false,
} ) => {
const scrollAttributeIntoView = ( index: number ) => {
setTimeout( () => {
@ -201,6 +208,15 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
}
};
useEffect( function focusFirstAttributeField() {
const firstAttributeFieldLabel =
document.querySelector< HTMLLabelElement >(
'.woocommerce-new-attribute-modal__table-row .woocommerce-attribute-input-field label'
);
firstAttributeFieldLabel?.focus();
}, [] );
return (
<>
<Form< AttributeForm >
@ -298,6 +314,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
undefined
),
] }
createNewAttributesAsGlobal={
createNewAttributesAsGlobal
}
/>
</td>
<td className="woocommerce-new-attribute-modal__table-attribute-value-column">

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { sprintf, __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { useDispatch, useSelect } from '@wordpress/data';
import { Spinner, Icon } from '@wordpress/components';
import { plus } from '@wordpress/icons';
import { createElement } from '@wordpress/element';
@ -11,6 +11,8 @@ import {
QueryProductAttribute,
ProductAttribute,
WCDataSelector,
ProductAttributesActions,
WPDataActions,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import {
@ -38,6 +40,7 @@ type AttributeInputFieldProps = {
placeholder?: string;
disabled?: boolean;
ignoredAttributeIds?: number[];
createNewAttributesAsGlobal?: boolean;
};
function isNewAttributeListItem( attribute: NarrowedQueryAttribute ): boolean {
@ -51,7 +54,12 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
label,
disabled,
ignoredAttributeIds = [],
createNewAttributesAsGlobal = false,
} ) => {
const { createErrorNotice } = useDispatch( 'core/notices' );
const { createProductAttribute, invalidateResolution } = useDispatch(
EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME
) as ProductAttributesActions & WPDataActions;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { attributes, isLoading } = useSelect( ( select: WCDataSelector ) => {
@ -99,6 +107,35 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
return filteredItems;
};
const addNewAttribute = ( attribute: NarrowedQueryAttribute ) => {
recordEvent( 'product_attribute_add_custom_attribute', {
source: TRACKS_SOURCE,
} );
if ( createNewAttributesAsGlobal ) {
createProductAttribute( { name: attribute.name } ).then(
( newAttr ) => {
invalidateResolution( 'getProductAttributes' );
onChange( { ...newAttr, options: [] } );
},
( error ) => {
let message = __(
'Failed to create new attribute.',
'woocommerce'
);
if ( error.code === 'woocommerce_rest_cannot_create' ) {
message = error.message;
}
createErrorNotice( message, {
explicitDismiss: true,
} );
}
);
} else {
onChange( attribute.name );
}
};
return (
<SelectControl< NarrowedQueryAttribute >
className="woocommerce-attribute-input-field"
@ -112,19 +149,14 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
selected={ value }
onSelect={ ( attribute ) => {
if ( isNewAttributeListItem( attribute ) ) {
recordEvent( 'product_attribute_add_custom_attribute', {
source: TRACKS_SOURCE,
addNewAttribute( attribute );
} else {
onChange( {
id: attribute.id,
name: attribute.name,
options: [],
} );
}
onChange(
isNewAttributeListItem( attribute )
? attribute.name
: {
id: attribute.id,
name: attribute.name,
options: [],
}
);
} }
onRemove={ () => onChange() }
__experimentalOpenMenuOnFocus

View File

@ -1,8 +1,8 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import { useSelect } from '@wordpress/data';
import { render, waitFor } from '@testing-library/react';
import { useDispatch, useSelect } from '@wordpress/data';
import { useState, createElement } from '@wordpress/element';
import { ProductAttribute, QueryProductAttribute } from '@woocommerce/data';
@ -14,6 +14,11 @@ import { AttributeInputField } from '../attribute-input-field';
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
useDispatch: jest.fn().mockReturnValue( {
createErrorNotice: jest.fn(),
createProductAttribute: jest.fn(),
invalidateResolution: jest.fn(),
} ),
} ) );
jest.mock( '@wordpress/components', () => ( {
@ -265,4 +270,97 @@ describe( 'AttributeInputField', () => {
queryByText( 'Create "Co"' )?.click();
expect( onChangeMock ).toHaveBeenCalledWith( 'Co' );
} );
describe( 'createNewAttributesAsGlobal is true', () => {
it( 'should create a new global attribute and invalidate product attributes', async () => {
const onChangeMock = jest.fn();
( useSelect as jest.Mock ).mockReturnValue( {
isLoading: false,
attributes: [ attributeList[ 0 ] ],
} );
const createProductAttributeMock = jest
.fn()
.mockImplementation(
(
newAttribute: Partial< Omit< ProductAttribute, 'id' > >
) => {
return Promise.resolve( {
name: newAttribute.name,
id: 123,
slug: newAttribute.name?.toLowerCase(),
} );
}
);
const invalidateResolutionMock = jest.fn();
( useDispatch as jest.Mock ).mockReturnValue( {
createErrorNotice: jest.fn(),
createProductAttribute: createProductAttributeMock,
invalidateResolution: invalidateResolutionMock,
} );
const { queryByText } = render(
<AttributeInputField
onChange={ onChangeMock }
createNewAttributesAsGlobal={ true }
/>
);
queryByText( 'Update Input' )?.click();
queryByText( 'Create "Co"' )?.click();
expect( createProductAttributeMock ).toHaveBeenCalledWith( {
name: 'Co',
} );
await waitFor( () => {
expect( invalidateResolutionMock ).toHaveBeenCalledWith(
'getProductAttributes'
);
} );
expect( onChangeMock ).toHaveBeenCalledWith( {
name: 'Co',
slug: 'co',
id: 123,
options: [],
} );
} );
it( 'should show an error notice and not call onChange when creation failed', async () => {
const onChangeMock = jest.fn();
( useSelect as jest.Mock ).mockReturnValue( {
isLoading: false,
attributes: [ attributeList[ 0 ] ],
} );
const createProductAttributeMock = jest
.fn()
.mockImplementation( () => {
return Promise.reject( {
code: 'woocommerce_rest_cannot_create',
message: 'Duplicate slug',
} );
} );
const invalidateResolutionMock = jest.fn();
const createErrorNoticeMock = jest.fn();
( useDispatch as jest.Mock ).mockReturnValue( {
createErrorNotice: createErrorNoticeMock,
createProductAttribute: createProductAttributeMock,
invalidateResolution: invalidateResolutionMock,
} );
const { queryByText } = render(
<AttributeInputField
onChange={ onChangeMock }
createNewAttributesAsGlobal={ true }
/>
);
queryByText( 'Update Input' )?.click();
queryByText( 'Create "Co"' )?.click();
expect( createProductAttributeMock ).toHaveBeenCalledWith( {
name: 'Co',
} );
await waitFor( () => {
expect( createErrorNoticeMock ).toHaveBeenCalledWith(
'Duplicate slug',
{ explicitDismiss: true }
);
} );
expect( invalidateResolutionMock ).not.toHaveBeenCalled();
expect( onChangeMock ).not.toHaveBeenCalled();
} );
} );
} );

View File

@ -3,7 +3,7 @@
*/
import { sprintf, __ } from '@wordpress/i18n';
import { CheckboxControl, Icon, Spinner } from '@wordpress/components';
import { resolveSelect } from '@wordpress/data';
import { resolveSelect, useDispatch } from '@wordpress/data';
import {
useCallback,
useEffect,
@ -12,6 +12,7 @@ import {
createElement,
Fragment,
} from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
import { useDebounce } from '@wordpress/compose';
import { plus } from '@wordpress/icons';
import {
@ -24,11 +25,13 @@ import {
__experimentalSelectControlMenu as Menu,
__experimentalSelectControlMenuItem as MenuItem,
} from '@woocommerce/components';
import { cleanForSlug } from '@wordpress/url';
/**
* Internal dependencies
*/
import { CreateAttributeTermModal } from './create-attribute-term-modal';
import { TRACKS_SOURCE } from '../../constants';
type AttributeTermInputFieldProps = {
value?: ProductAttributeTerm[];
@ -37,8 +40,14 @@ type AttributeTermInputFieldProps = {
placeholder?: string;
disabled?: boolean;
label?: string;
autoCreateOnSelect?: boolean;
};
interface customError extends Error {
code: string;
message: string;
}
let uniqueId = 0;
export const AttributeTermInputField: React.FC<
@ -50,6 +59,7 @@ export const AttributeTermInputField: React.FC<
disabled,
attributeId,
label = '',
autoCreateOnSelect = true,
} ) => {
const attributeTermInputId = useRef(
`woocommerce-attribute-term-field-${ ++uniqueId }`
@ -58,8 +68,12 @@ export const AttributeTermInputField: React.FC<
ProductAttributeTerm[]
>( [] );
const [ isFetching, setIsFetching ] = useState( false );
const [ isCreatingTerm, setIsCreatingTerm ] = useState( false );
const [ addNewAttributeTermName, setAddNewAttributeTermName ] =
useState< string >();
const { createNotice } = useDispatch( 'core/notices' );
const { createProductAttributeTerm, invalidateResolutionForStoreSelector } =
useDispatch( EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME );
const fetchItems = useCallback(
( searchString?: string | undefined ) => {
@ -102,20 +116,6 @@ export const AttributeTermInputField: React.FC<
onChange( value.filter( ( opt ) => opt.slug !== item.slug ) );
};
const onSelect = ( item: ProductAttributeTerm ) => {
// Add new item.
if ( item.id === -99 ) {
setAddNewAttributeTermName( item.name );
return;
}
const isSelected = value.find( ( i ) => i.slug === item.slug );
if ( isSelected ) {
onRemove( item );
return;
}
onChange( [ ...value, item ] );
};
const focusSelectControl = () => {
const selectControlInputField: HTMLInputElement | null =
document.querySelector(
@ -130,6 +130,78 @@ export const AttributeTermInputField: React.FC<
}
};
const createAttributeTerm = async (
attribute: Partial< ProductAttributeTerm >
) => {
recordEvent( 'product_attribute_term_add', {
source: TRACKS_SOURCE,
} );
setIsCreatingTerm( true );
try {
const newAttribute: ProductAttributeTerm =
await createProductAttributeTerm( {
...attribute,
attribute_id: attributeId,
} );
recordEvent( 'product_attribute_term_add_success', {
source: TRACKS_SOURCE,
} );
onChange( [ ...value, newAttribute ] );
invalidateResolutionForStoreSelector( 'getProductAttributes' );
invalidateResolutionForStoreSelector( 'getProductAttributeTerms' );
setIsCreatingTerm( false );
} catch ( err: unknown ) {
let error = {
source: TRACKS_SOURCE,
code: 'Unknown error.',
message: 'An unknown error occurred.',
};
let noticeMessage = __(
'Failed to create attribute term.',
'woocommerce'
);
const errorResponse = err as customError;
if ( errorResponse?.code && errorResponse?.message ) {
error = {
...error,
code: errorResponse.code,
message: errorResponse.message,
};
if ( errorResponse.code === 'term_exists' ) {
noticeMessage = __(
'Attribute term already exists.',
'woocommerce'
);
}
}
recordEvent( 'product_attribute_term_add_failed', error );
createNotice( 'error', noticeMessage );
setIsCreatingTerm( false );
}
};
const onSelect = ( item: ProductAttributeTerm ) => {
// Add new item.
if ( item.id === -99 ) {
if ( autoCreateOnSelect ) {
createAttributeTerm( {
name: item.name,
slug: cleanForSlug( item.name ),
} );
focusSelectControl();
} else {
setAddNewAttributeTermName( item.name );
}
return;
}
const isSelected = value.find( ( i ) => i.slug === item.slug );
if ( isSelected ) {
onRemove( item );
return;
}
onChange( [ ...value, item ] );
};
const selectedTermSlugs = ( value || [] ).map( ( term ) => term.slug );
return (
@ -166,8 +238,12 @@ export const AttributeTermInputField: React.FC<
const { changes, type } = actionAndChanges;
switch ( type ) {
case selectControlStateChangeTypes.ControlledPropUpdatedSelectedItem:
const listIsOpen = isCreatingTerm
? { isOpen: isCreatingTerm }
: {};
return {
...changes,
...listIsOpen,
inputValue: state.inputValue,
};
case selectControlStateChangeTypes.ItemClick:
@ -206,7 +282,7 @@ export const AttributeTermInputField: React.FC<
return (
<Menu isOpen={ isOpen } getMenuProps={ getMenuProps }>
{ [
isFetching ? (
isFetching || isCreatingTerm ? (
<div
key="loading-spinner"
className="woocommerce-attribute-term-field__loading-spinner"
@ -275,21 +351,26 @@ export const AttributeTermInputField: React.FC<
);
} }
</SelectControl>
{ addNewAttributeTermName && attributeId !== undefined && (
<CreateAttributeTermModal
initialAttributeTermName={ addNewAttributeTermName }
onCancel={ () => {
setAddNewAttributeTermName( undefined );
focusSelectControl();
} }
attributeId={ attributeId }
onCreated={ ( newAttribute ) => {
onSelect( newAttribute );
setAddNewAttributeTermName( undefined );
focusSelectControl();
} }
/>
) }
{ ! autoCreateOnSelect &&
addNewAttributeTermName &&
attributeId !== undefined && (
<CreateAttributeTermModal
initialAttributeTermName={ addNewAttributeTermName }
onCancel={ () => {
setAddNewAttributeTermName( undefined );
focusSelectControl();
} }
attributeId={ attributeId }
onCreated={ ( newAttribute ) => {
onSelect( newAttribute );
setAddNewAttributeTermName( undefined );
invalidateResolutionForStoreSelector(
'getProductAttributeTerms'
);
focusSelectControl();
} }
/>
) }
</>
);
};

View File

@ -24,6 +24,13 @@ jest.mock( '@woocommerce/navigation', () => ( {
getQuery: jest.fn().mockReturnValue( {} ),
} ) );
const blockProps = {
setAttributes: () => {},
className: '',
clientId: '',
isSelected: false,
};
function MockTabs( { onChange = jest.fn() } ) {
const [ selected, setSelected ] = useState< string | null >( null );
const mockContext = {
@ -39,15 +46,18 @@ function MockTabs( { onChange = jest.fn() } ) {
} }
/>
<Tab
attributes={ { id: 'test1', title: 'Test button 1' } }
{ ...blockProps }
attributes={ { id: 'test1', title: 'Test button 1', order: 1 } }
context={ mockContext }
/>
<Tab
attributes={ { id: 'test2', title: 'Test button 2' } }
{ ...blockProps }
attributes={ { id: 'test2', title: 'Test button 2', order: 2 } }
context={ mockContext }
/>
<Tab
attributes={ { id: 'test3', title: 'Test button 3' } }
{ ...blockProps }
attributes={ { id: 'test3', title: 'Test button 3', order: 3 } }
context={ mockContext }
/>
</SlotFillProvider>

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
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 @@
export * from './variations-table';

View File

@ -0,0 +1,103 @@
.woocommerce-product-variations {
ol {
@media ( min-width: #{ ($break-medium) } ) {
min-height: 420px;
}
}
display: flex;
flex-direction: column;
> div {
display: flex;
flex-direction: column;
flex-grow: 1;
}
&__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: flex-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: auto 25% 25% 88px;
padding: 0;
min-height: calc($grid-unit * 9);
border: none;
}
.woocommerce-sortable {
margin: 0;
flex: 1 0 auto;
&__item:not(:last-child) .woocommerce-list-item {
border-bottom: 1px solid $gray-200;
}
&__handle {
display: none;
}
}
&.is-loading {
min-height: 476px;
.components-spinner {
width: 34px;
height: 34px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
position: absolute;
margin: 0;
}
}
&__footer {
padding: $gap;
}
}

View File

@ -0,0 +1,288 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Spinner, Tooltip } from '@wordpress/components';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
ProductAttribute,
ProductVariation,
} from '@woocommerce/data';
import {
Link,
ListItem,
Pagination,
Sortable,
Tag,
} from '@woocommerce/components';
import { getNewPath } from '@woocommerce/navigation';
import { useContext, useState, createElement } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import classnames from 'classnames';
import truncate from 'lodash/truncate';
import { CurrencyContext } from '@woocommerce/currency';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityId, useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import HiddenIcon from './hidden-icon';
import VisibleIcon from './visible-icon';
import { getProductStockStatus, getProductStockStatusClass } from '../../utils';
import {
DEFAULT_PER_PAGE_OPTION,
PRODUCT_VARIATION_TITLE_LIMIT,
} from '../../constants';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
const VISIBLE_TEXT = __( 'Visible to customers', 'woocommerce' );
const UPDATING_TEXT = __( 'Updating product variation', 'woocommerce' );
export function VariationsTable() {
const [ currentPage, setCurrentPage ] = useState( 1 );
const [ perPage, setPerPage ] = useState( DEFAULT_PER_PAGE_OPTION );
const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >(
{}
);
const [ entityAttributes ] = useEntityProp< ProductAttribute[] >(
'postType',
'product',
'attributes'
);
const variableAttributeTags = entityAttributes
.filter( ( attr ) => attr.variation )
.map( ( attr ) => attr.options )
.flat();
const productId = useEntityId( 'postType', 'product' );
const context = useContext( CurrencyContext );
const { formatAmount } = context;
const { isLoading, variations, totalCount } = useSelect(
( select ) => {
const {
getProductVariations,
hasFinishedResolution,
getProductVariationsTotalCount,
} = select( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const requestParams = {
product_id: productId,
page: currentPage,
per_page: perPage,
order: 'asc',
orderby: 'menu_order',
};
return {
isLoading: ! hasFinishedResolution( 'getProductVariations', [
requestParams,
] ),
variations:
getProductVariations< ProductVariation[] >( requestParams ),
totalCount:
getProductVariationsTotalCount< number >( requestParams ),
};
},
[ currentPage, perPage, productId ]
);
const { updateProductVariation } = useDispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
if ( ! variations || isLoading ) {
return (
<div className="woocommerce-product-variations is-loading">
<Spinner />
</div>
);
}
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 (
<div className="woocommerce-product-variations">
<Sortable>
{ variations.map( ( variation ) => (
<ListItem key={ `${ variation.id }` }>
<div className="woocommerce-product-variations__attributes">
{ variation.attributes
.filter( ( attribute ) =>
variableAttributeTags.includes(
attribute.option
)
)
.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={ 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={ classnames(
'woocommerce-product-variations__price',
{
'woocommerce-product-variations__price--fade':
variation.status === 'private',
}
) }
>
{ formatAmount( variation.price ) }
</div>
<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">
{ 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>
) }
<Link
href={ getNewPath(
{},
`/product/${ productId }/variation/${ variation.id }`,
{}
) }
type="wc-admin"
className="components-button"
>
{ __( 'Edit', 'woocommerce' ) }
</Link>
</div>
</ListItem>
) ) }
</Sortable>
<Pagination
className="woocommerce-product-variations__footer"
page={ currentPage }
perPage={ perPage }
total={ totalCount }
showPagePicker={ false }
onPageChange={ setCurrentPage }
onPerPageChange={ setPerPage }
/>
</div>
);
}

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
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

@ -52,3 +52,13 @@ export const PRODUCT_DETAILS_SLUG = 'product-details';
export const PRODUCT_SCHEDULED_SALE_SLUG = 'product-scheduled-sale';
export const TRACKS_SOURCE = 'product-block-editor-v1';
/**
* Since the pagination component does not exposes the way of
* changing the per page options which are [25, 50, 75, 100]
* the default per page option will be the min in the list to
* keep compatibility.
*
* @see https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/components/src/pagination/index.js#L12
*/
export const DEFAULT_PER_PAGE_OPTION = 25;

View File

@ -44,6 +44,14 @@ const attributeTerms: Record< number, ProductAttributeTerm[] > = {
menu_order: 0,
count: 2,
},
{
id: 66,
name: 'Yellow',
slug: 'yellow',
description: '',
menu_order: 0,
count: 2,
},
],
3: [
{
@ -353,7 +361,7 @@ describe( 'useProductAttributes', () => {
);
} );
it( 'sets terms for any global attributes and options to empty array', async () => {
it( 'sets terms for any global attributes and leaves options the same', async () => {
const allAttributes = [
{ ...testAttributes[ 0 ] },
{ ...testAttributes[ 1 ] },
@ -375,13 +383,16 @@ describe( 'useProductAttributes', () => {
await waitForNextUpdate();
expect( result.current.attributes.length ).toBe( 3 );
expect( result.current.attributes[ 0 ].terms ).toEqual(
attributeTerms[ result.current.attributes[ 0 ].id ]
attributeTerms[ result.current.attributes[ 0 ].id ].filter(
( t ) => allAttributes[ 1 ].options.includes( t.name )
)
);
expect( result.current.attributes[ 0 ].options ).toEqual(
result.current.attributes[ 0 ].options
);
expect( result.current.attributes[ 0 ].options ).toEqual( [] );
expect( result.current.attributes[ 1 ].terms ).toEqual(
attributeTerms[ result.current.attributes[ 1 ].id ]
);
expect( result.current.attributes[ 1 ].options ).toEqual( [] );
} );
} );
} );

View File

@ -14,6 +14,12 @@ import { useCallback, useEffect, useState } from '@wordpress/element';
*/
import { sift } from '../utils';
export type EnhancedProductAttribute = ProductAttribute & {
isDefault?: boolean;
terms?: ProductAttributeTerm[];
visible?: boolean;
};
type useProductAttributesProps = {
allAttributes: ProductAttribute[];
isVariationAttributes?: boolean;
@ -21,11 +27,6 @@ type useProductAttributesProps = {
productId?: number;
};
export type EnhancedProductAttribute = ProductAttribute & {
terms?: ProductAttributeTerm[];
visible?: boolean;
};
const getFilteredAttributes = (
attr: ProductAttribute[],
isVariationAttributes: boolean
@ -52,7 +53,6 @@ export function useProductAttributes( {
)
.getProductAttributeTerms< ProductAttributeTerm[] >( {
attribute_id: attributeId,
product: productId,
} )
.then(
( attributeTerms ) => {
@ -68,28 +68,29 @@ export function useProductAttributes( {
const enhanceAttribute = (
globalAttribute: ProductAttribute,
terms: ProductAttributeTerm[]
allTerms: ProductAttributeTerm[]
) => {
return {
...globalAttribute,
terms: terms.length > 0 ? terms : undefined,
options: terms.length === 0 ? globalAttribute.options : [],
terms: ( allTerms || [] ).filter( ( term ) =>
globalAttribute.options.includes( term.name )
),
};
};
const getAugmentedAttributes = (
atts: ProductAttribute[],
atts: EnhancedProductAttribute[],
variation: boolean,
startPosition: number
) => {
return atts.map( ( attribute, index ) => ( {
): ProductAttribute[] => {
return atts.map( ( { isDefault, terms, ...attribute }, index ) => ( {
...attribute,
variation,
position: startPosition + index,
} ) );
};
const handleChange = ( newAttributes: ProductAttribute[] ) => {
const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => {
let otherAttributes = isVariationAttributes
? allAttributes.filter( ( attribute ) => ! attribute.variation )
: allAttributes.filter( ( attribute ) => !! attribute.variation );

View File

@ -0,0 +1,77 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { useCallback, useState } from '@wordpress/element';
import {
Product,
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
} from '@woocommerce/data';
/**
* Internal dependencies
*/
import { EnhancedProductAttribute } from './use-product-attributes';
export function useProductVariationsHelper() {
const [ productId ] = useEntityProp< number >(
'postType',
'product',
'id'
);
const { saveEntityRecord } = useDispatch( 'core' );
const {
generateProductVariations: _generateProductVariations,
invalidateResolutionForStoreSelector,
} = useDispatch( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const [ isGenerating, setIsGenerating ] = useState( false );
const generateProductVariations = useCallback(
async ( attributes: EnhancedProductAttribute[] ) => {
setIsGenerating( true );
const updateProductAttributes = async () => {
const hasVariableAttribute = attributes.some(
( attr ) => attr.variation
);
await saveEntityRecord< Promise< Product > >(
'postType',
'product',
{
id: productId,
type: hasVariableAttribute ? 'variable' : 'simple',
attributes,
}
);
};
return updateProductAttributes()
.then( () => {
return _generateProductVariations< { count: number } >( {
product_id: productId,
} );
} )
.then( ( data ) => {
if ( data.count > 0 ) {
invalidateResolutionForStoreSelector(
'getProductVariations'
);
return invalidateResolutionForStoreSelector(
'getProductVariationsTotalCount'
);
}
} )
.finally( () => {
setIsGenerating( false );
} );
},
[]
);
return {
generateProductVariations,
isGenerating,
};
}

View File

@ -31,7 +31,7 @@
@import 'components/attribute-input-field/attribute-input-field.scss';
@import 'components/attribute-list-item/attribute-list-item.scss';
@import 'components/attribute-term-input-field/attribute-term-input-field.scss';
@import 'components/attribute-term-input-field/create-attribute-term-modal.scss';
@import 'components/variations-table/styles.scss';
/* Field Blocks */

View File

@ -22,7 +22,7 @@ import { isValidEmail } from './validate-email';
export * from './create-ordered-children';
export * from './sort-fills-by-order';
export * from './init-blocks';
export * from './init-block';
export * from './product-apifetch-middleware';
export * from './sift';

View File

@ -2,38 +2,30 @@
* External dependencies
*/
import {
Block,
BlockConfiguration,
BlockEditProps,
registerBlockType,
} from '@wordpress/blocks';
import { ComponentType } from 'react';
type BlockRepresentation = {
name: string;
metadata: BlockConfiguration;
settings: Partial< Omit< BlockConfiguration, 'edit' > > & {
readonly edit?:
| ComponentType<
BlockEditProps< object > & {
context?: Record< string, unknown >;
}
>
| undefined;
};
};
interface BlockRepresentation< T extends Record< string, object > > {
name?: string;
metadata: BlockConfiguration< T >;
settings: Partial< BlockConfiguration< T > >;
}
/**
* Function to register an individual block.
*
* @param {Object} block The block to be registered.
*
* @return {WPBlockType|void} The block, if it has been successfully registered;
* otherwise `undefined`.
* @param block The block to be registered.
* @return The block, if it has been successfully registered; otherwise `undefined`.
*/
export default function initBlock( block: BlockRepresentation ) {
export function initBlock<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Record< string, any > = Record< string, any >
>( block: BlockRepresentation< T > ): Block< T > | undefined {
if ( ! block ) {
return;
}
const { metadata, settings, name } = block;
return registerBlockType( { name, ...metadata }, settings );
return registerBlockType< T >( { name, ...metadata }, settings );
}

View File

@ -1,31 +0,0 @@
/**
* External dependencies
*/
import {
Block,
BlockConfiguration,
registerBlockType,
} from '@wordpress/blocks';
interface BlockRepresentation< T extends Record< string, object > > {
name?: string;
metadata: BlockConfiguration< T >;
settings: Partial< BlockConfiguration< T > >;
}
/**
* Function to register an individual block.
*
* @param block The block to be registered.
* @return The block, if it has been successfully registered; otherwise `undefined`.
*/
export function initBlock<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Record< string, any > = Record< string, any >
>( block: BlockRepresentation< T > ): Block< T > | undefined {
if ( ! block ) {
return;
}
const { metadata, settings, name } = block;
return registerBlockType< T >( { name, ...metadata }, settings );
}

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
[Woo AI] Add Store Branding data to product description generation prompt.

View File

@ -29,6 +29,16 @@ class Woo_AI_Settings {
*/
protected $id = 'woo-ai-settings-tab';
/**
* Tone of voice select options.
*
* @var array
*/
private $tone_of_voice_select_options;
private const STORE_DESCRIPTION_OPTION_KEY = 'woo_ai_describe_store_description';
private const TONE_OF_VOICE_OPTION_KEY = 'woo_ai_tone_of_voice_select';
/**
* Main Instance.
*/
@ -44,10 +54,64 @@ class Woo_AI_Settings {
add_action( 'admin_enqueue_scripts', array( $this, 'add_woo_ai_settings_script' ) );
add_action( 'woocommerce_settings_save_advanced', array( $this, 'action_save_woo_ai_settings_tab' ) );
add_action( 'woocommerce_settings_page_init', array( $this, 'add_ui' ) );
add_filter( 'woocommerce_settings_groups', array( $this, 'add_woo_ai_settings_group' ) );
add_filter( 'woocommerce_settings-woo-ai', array( $this, 'add_woo_ai_settings_group_settings' ) );
$this->tone_of_voice_select_options = array(
'informal' => __( 'Informal', 'woocommerce' ),
'humorous' => __( 'Humorous', 'woocommerce' ),
'neutral' => __( 'Neutral', 'woocommerce' ),
'youthful' => __( 'Youthful', 'woocommerce' ),
'formal' => __( 'Formal', 'woocommerce' ),
'motivational' => __( 'Motivational', 'woocommerce' ),
);
$this->add_sanitization_hooks();
}
/**
* Adds settings which can be retrieved via the WooCommerce Settings API.
*
* @see https://github.com/woocommerce/woocommerce/wiki/Settings-API
*
* @param array $settings The original settings array.
* @return array The modified settings array.
*/
public function add_woo_ai_settings_group_settings( $settings ) {
$settings[] = array(
'id' => 'tone-of-voice',
'option_key' => self::TONE_OF_VOICE_OPTION_KEY,
'label' => __( 'Storewide Tone of Voice', 'woocommerce' ),
'description' => __( 'This controls the conversational tone that will be used when generating content.', 'woocommerce' ),
'default' => 'neutral',
'type' => 'select',
'options' => $this->tone_of_voice_select_options,
);
$settings[] = array(
'id' => 'store-description',
'option_key' => self::STORE_DESCRIPTION_OPTION_KEY,
'label' => __( 'Store Description', 'woocommerce' ),
'description' => __( 'This is a short description of your store which could be used to help generate content.', 'woocommerce' ),
'type' => 'textarea',
);
return $settings;
}
/**
* Register our Woo AI plugin group to the WooCommerce Settings API.
*
* @param array $locations The original settings array.
* @return array The modified settings array.
*/
public function add_woo_ai_settings_group( $locations ) {
$locations[] = array(
'id' => 'woo-ai',
'label' => __( 'Woo AI', 'woocommerce' ),
'description' => __( 'Settings for the Woo AI plugin.', 'woocommerce' ),
);
return $locations;
}
/**
* Add UI related hooks.
*/
@ -147,25 +211,18 @@ class Woo_AI_Settings {
array(
'title' => __( 'Tone of voice', 'woocommerce' ),
'id' => 'woo_ai_tone_of_voice_select',
'id' => self::TONE_OF_VOICE_OPTION_KEY,
'css' => 'min-width:300px;',
'default' => 'neutral',
'type' => 'select',
'desc' => $markup_str,
'desc_tip' => __( 'Choose the language style that best resonates with your customers. It\'ll be used in text-based content, like product descriptions.', 'woocommerce' ),
'custom_attributes' => array( 'disabled' => 'true' ),
'options' => array(
'informal' => esc_html__( 'Informal', 'woocommerce' ),
'humorous' => esc_html__( 'Humorous', 'woocommerce' ),
'neutral' => esc_html__( 'Neutral', 'woocommerce' ),
'youthful' => esc_html__( 'Youthful', 'woocommerce' ),
'formal' => esc_html__( 'Formal', 'woocommerce' ),
'motivational' => esc_html__( 'Motivational', 'woocommerce' ),
),
'options' => $this->tone_of_voice_select_options,
),
array(
'id' => 'woo_ai_describe_store_description',
'id' => self::STORE_DESCRIPTION_OPTION_KEY,
'type' => 'textarea',
'custom_attributes' => array( 'disabled' => 'true' ),
'title' => __( 'Describe your business', 'woocommerce' ),

View File

@ -39,9 +39,11 @@
"@wordpress/element": "wp-6.0",
"@wordpress/hooks": "wp-6.0",
"@wordpress/i18n": "wp-6.0",
"@wordpress/notices": "wp-6.0",
"@wordpress/plugins": "wp-6.0",
"debug": "^4.3.3",
"prop-types": "^15.8.1"
"prop-types": "^15.8.1",
"react-query": "^3.39.3"
},
"peerDependencies": {
"@types/react": "^17.0.2",

2
plugins/woo-ai/src/custom.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module '@wordpress/notices';
declare module '@wordpress/data';

View File

@ -1,3 +1,4 @@
export * from './useTinyEditor';
export * from './useFeedbackSnackbar';
export * from './useProductSlug';
export * from './useStoreBranding';

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { useEffect } from 'react';
import { useQuery, UseQueryResult } from 'react-query';
/**
* Internal dependencies
*/
import { getToneOfVoice, getBusinessDescription } from '../utils/branding';
// Define your data type
interface BrandingData {
toneOfVoice: string;
businessDescription: string;
}
// Define your error type
interface BrandingError {
message: string;
}
type UseStoreBrandingOptions = {
onError?: ( error: BrandingError ) => void;
};
// Async function to fetch branding data
async function fetchBrandingData(): Promise< BrandingData > {
const [ toneOfVoice, businessDescription ] = await Promise.all( [
getToneOfVoice(),
getBusinessDescription(),
] );
return { toneOfVoice, businessDescription };
}
export function useStoreBranding( {
onError,
}: UseStoreBrandingOptions = {} ): UseQueryResult<
BrandingData,
BrandingError
> {
const result = useQuery< BrandingData, BrandingError >(
'storeBranding',
fetchBrandingData,
{
refetchOnWindowFocus: false, // Do not refetch when window gains focus
}
);
useEffect( () => {
if ( result.isError && onError ) {
onError( result.error as BrandingError );
}
}, [ result.isError, result.error, onError ] );
return result;
}

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { render, createRoot } from '@wordpress/element';
import { QueryClient, QueryClientProvider } from 'react-query';
/**
* Internal dependencies
@ -11,15 +12,23 @@ import { ProductNameSuggestions } from './product-name';
import './index.scss';
const queryClient = new QueryClient();
const renderComponent = ( Component, rootElement ) => {
if ( ! rootElement ) {
return;
}
const WrappedComponent = () => (
<QueryClientProvider client={ queryClient }>
<Component />
</QueryClientProvider>
);
if ( createRoot ) {
createRoot( rootElement ).render( <Component /> );
createRoot( rootElement ).render( <WrappedComponent /> );
} else {
render( <Component />, rootElement );
render( <WrappedComponent />, rootElement );
}
};

View File

@ -1,13 +1,14 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { __ } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element';
import {
__experimentalUseCompletion as useCompletion,
UseCompletionError,
} from '@woocommerce/ai';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
@ -18,7 +19,7 @@ import {
WOO_AI_PLUGIN_FEATURE_NAME,
} from '../constants';
import { StopCompletionBtn, WriteItForMeBtn } from '../components';
import { useFeedbackSnackbar, useTinyEditor } from '../hooks';
import { useFeedbackSnackbar, useStoreBranding, useTinyEditor } from '../hooks';
import {
getProductName,
getPostId,
@ -65,6 +66,28 @@ export function WriteItForMeButtonContainer() {
const [ productTitle, setProductTitle ] = useState< string >(
titleEl.current?.value || ''
);
const { createErrorNotice } = useDispatch( noticesStore );
const [ errorNoticeDismissed, setErrorNoticeDismissed ] = useState( false );
const { data: brandingData } = useStoreBranding( {
onError: () => {
if ( ! errorNoticeDismissed ) {
createErrorNotice(
__(
'Error fetching branding data, content generation may be degraded.',
'woocommerce'
),
{
id: 'woo-ai-branding-error',
type: 'snackbar',
isDismissible: true,
onDismiss: () => setErrorNoticeDismissed( true ),
}
);
}
},
} );
const tinyEditor = useTinyEditor();
const shortTinyEditor = useTinyEditor( 'excerpt' );
@ -174,7 +197,7 @@ export function WriteItForMeButtonContainer() {
productPropsInstructions.push(
`Tagged with: ${ productTags.join( ', ' ) }.`
);
includedProps.push( 'categories' );
includedProps.push( 'tags' );
}
productAttributes.forEach( ( { name, values } ) => {
productPropsInstructions.push(
@ -183,21 +206,41 @@ export function WriteItForMeButtonContainer() {
includedProps.push( name );
} );
return [
`Compose an engaging product description for a product named "${ productName.slice(
0,
MAX_TITLE_LENGTH
) }".`,
// WooCommerce doesn't set a limit for the product title. Set a limit to control the token usage.
const truncatedProductName = productName.slice( 0, MAX_TITLE_LENGTH );
const instructions = [
`Compose an engaging product description for a product named "${ truncatedProductName }."`,
...productPropsInstructions,
'Identify the language used in the product name, and craft the description in the same language.',
`Use a 9th grade reading level.`,
`Ensure the description is concise, containing no more than ${ DESCRIPTION_MAX_LENGTH } words.`,
'Structure the content into paragraphs using <p> tags, and use HTML elements like <strong> and <em> for emphasis.',
'Only if appropriate, use <ul> and <li> for listing product features.',
`Avoid including the properties (${ includedProps.join(
', '
) }) directly in the description, but utilize them to create an engaging and enticing portrayal of the product.`,
'Do not include a top-level heading at the beginning description.',
].join( ' ' );
'Identify the language used in the product name, and craft the description in the same language.',
'Only if appropriate, use <ul> and <li> tags to list product features.',
'Do not include a top-level heading at the beginning of the description.',
];
if ( includedProps.length > 0 ) {
instructions.push(
`Avoid including the properties (${ includedProps.join(
', '
) }) directly in the description, but utilize them to create an engaging and enticing portrayal of the product.`
);
}
if ( brandingData?.toneOfVoice ) {
instructions.push(
`Generate the description using a ${ brandingData.toneOfVoice } tone.`
);
}
if ( brandingData?.businessDescription ) {
instructions.push(
`For more context on the business, refer to the following business description: "${ brandingData.businessDescription }."`
);
}
return instructions.join( '\n' );
};
const onWriteItForMeClick = async () => {

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
// Define the expected shape of the API response
type ApiResponse = {
value: string;
};
/**
* Fetch the tone of voice setting.
*
* @return {Promise<string>} A promise that resolves with the tone of voice or 'neutral' if the API call fails.
*/
export async function getToneOfVoice(): Promise< string > {
try {
const { value } = await apiFetch< ApiResponse >( {
path: '/wc/v3/settings/woo-ai/tone-of-voice',
} );
return value;
} catch ( error ) {
throw error;
}
}
/**
* Fetch the business description setting.
*
* @return {Promise<string>} A promise that resolves with the business description.
*/
export async function getBusinessDescription(): Promise< string > {
try {
const { value } = await apiFetch< ApiResponse >( {
path: '/wc/v3/settings/woo-ai/store-description',
} );
return value;
} catch ( error ) {
throw error;
}
}

View File

@ -342,22 +342,27 @@ const redirectToJetpackAuthPage = (
window.location.href = event.data.url + '&installed_ext_success=1';
};
const updateTrackingOption = (
_context: CoreProfilerStateMachineContext,
event: IntroOptInEvent
const updateTrackingOption = async (
context: CoreProfilerStateMachineContext
) => {
if (
event.payload.optInDataSharing &&
typeof window.wcTracks.enable === 'function'
) {
window.wcTracks.enable( () => {
initializeExPlat();
} );
} else if ( ! event.payload.optInDataSharing ) {
window.wcTracks.isEnabled = false;
}
await new Promise< void >( ( resolve ) => {
if (
context.optInDataSharing &&
typeof window.wcTracks.enable === 'function'
) {
window.wcTracks.enable( () => {
initializeExPlat();
resolve(); // resolve the promise only after explat is enabled by the callback
} );
} else {
if ( ! context.optInDataSharing ) {
window.wcTracks.isEnabled = false;
}
resolve();
}
} );
const trackingValue = event.payload.optInDataSharing ? 'yes' : 'no';
const trackingValue = context.optInDataSharing ? 'yes' : 'no';
dispatch( OPTIONS_STORE_NAME ).updateOptions( {
woocommerce_allow_tracking: trackingValue,
} );
@ -563,7 +568,6 @@ const coreProfilerMachineActions = {
...recordTracksActions,
handlePlugins,
updateQueryStep,
updateTrackingOption,
handleTrackingOption,
handleGeolocation,
handleStoreNameOption,
@ -591,6 +595,7 @@ const coreProfilerMachineServices = {
getPlugins,
browserPopstateHandler,
updateBusinessInfo,
updateTrackingOption,
};
export const coreProfilerStateMachineDefinition = createMachine( {
id: 'coreProfiler',
@ -725,11 +730,8 @@ export const coreProfilerStateMachineDefinition = createMachine( {
introOptIn: {
on: {
INTRO_COMPLETED: {
target: '#userProfile',
actions: [
'assignOptInDataSharing',
'updateTrackingOption',
],
target: 'postIntroOptIn',
actions: [ 'assignOptInDataSharing' ],
},
INTRO_SKIPPED: {
// if the user skips the intro, we set the optInDataSharing to false and go to the Business Location page
@ -740,35 +742,20 @@ export const coreProfilerStateMachineDefinition = createMachine( {
],
},
},
entry: [
{
type: 'recordTracksStepViewed',
step: 'intro_opt_in',
},
{ type: 'updateQueryStep', step: 'intro-opt-in' },
],
exit: actions.choose( [
{
cond: ( _context, event ) =>
event.type === 'INTRO_COMPLETED',
actions: 'recordTracksIntroCompleted',
},
{
cond: ( _context, event ) =>
event.type === 'INTRO_SKIPPED',
actions: [
{
type: 'recordTracksStepSkipped',
step: 'intro_opt_in',
},
],
},
] ),
meta: {
progress: 20,
component: IntroOptIn,
},
},
postIntroOptIn: {
invoke: {
src: 'updateTrackingOption',
onDone: {
actions: [ 'recordTracksIntroCompleted' ],
target: '#userProfile',
},
},
},
},
},
userProfile: {

View File

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

View File

@ -1,79 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Product, ProductAttribute } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useFormContext } from '@woocommerce/components';
import { AttributeControl } from '@woocommerce/product-editor/src/components/attribute-control';
import { useProductAttributes } from '@woocommerce/product-editor/src/hooks/use-product-attributes';
/**
* Internal dependencies
*/
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
type OptionsProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
};
export const Options: React.FC< OptionsProps > = ( {
value,
onChange,
productId,
} ) => {
const { values } = useFormContext< Product >();
const { generateProductVariations } = useProductVariationsHelper();
const { attributes, handleChange } = useProductAttributes( {
allAttributes: value,
isVariationAttributes: true,
onChange: ( newAttributes ) => {
onChange( newAttributes );
generateProductVariations( {
...values,
attributes: newAttributes,
} );
},
productId,
} );
return (
<AttributeControl
value={ attributes }
onAdd={ () => {
recordEvent( 'product_add_options_modal_add_button_click' );
} }
onChange={ handleChange }
onNewModalCancel={ () => {
recordEvent( 'product_add_options_modal_cancel_button_click' );
} }
onNewModalOpen={ () => {
if ( ! attributes.length ) {
recordEvent( 'product_add_first_option_button_click' );
return;
}
recordEvent( 'product_add_option_button' );
} }
uiStrings={ {
emptyStateSubtitle: __( 'No options yet', 'woocommerce' ),
newAttributeListItemLabel: __( 'Add option', 'woocommerce' ),
newAttributeModalTitle: __( 'Add options', 'woocommerce' ),
globalAttributeHelperMessage: __(
`You can change the option's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
),
} }
onRemove={ () =>
recordEvent(
'product_remove_option_confirmation_confirm_click'
)
}
onRemoveCancel={ () =>
recordEvent( 'product_remove_option_confirmation_cancel_click' )
}
/>
);
};

View File

@ -20,7 +20,6 @@ import { PricingSectionFills } from './pricing-section';
import { InventorySectionFills } from './inventory-section';
import { AttributesSectionFills } from './attributes-section';
import { ImagesSectionFills } from './images-section';
import { OptionsSection } from '../sections/options-section';
import { ProductVariationsSection } from '../sections/product-variations-section';
import {
TAB_GENERAL_ID,
@ -113,7 +112,6 @@ const Tabs = () => {
tabProps={ tabPropData.options }
>
<>
<OptionsSection />
<ProductVariationsSection />
</>
</WooProductTabItem>

View File

@ -1,70 +0,0 @@
/**
* External dependencies
*/
import { AUTO_DRAFT_NAME } from '@woocommerce/product-editor';
import { useDispatch } from '@wordpress/data';
import { useCallback, useState } from '@wordpress/element';
import {
Product,
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
PRODUCTS_STORE_NAME,
} from '@woocommerce/data';
import { useFormContext } from '@woocommerce/components';
export function useProductVariationsHelper() {
const {
generateProductVariations: _generateProductVariations,
invalidateResolutionForStoreSelector,
} = useDispatch( EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME );
const { createProduct, updateProduct } = useDispatch( PRODUCTS_STORE_NAME );
const { resetForm } = useFormContext< Product >();
const [ isGenerating, setIsGenerating ] = useState( false );
const generateProductVariations = useCallback(
async ( product: Partial< Product > ) => {
setIsGenerating( true );
const createOrUpdateProduct = product.id
? () =>
updateProduct< Promise< Product > >(
product.id,
product
)
: () => {
return createProduct< Promise< Product > >( {
...product,
status: 'auto-draft',
name: product.name || AUTO_DRAFT_NAME,
} );
};
return createOrUpdateProduct()
.then( ( createdOrUpdatedProduct ) => {
if ( ! product.id ) {
resetForm( {
...createdOrUpdatedProduct,
name: product.name || '',
} );
}
return _generateProductVariations( {
product_id: createdOrUpdatedProduct.id,
} );
} )
.then( () => {
return invalidateResolutionForStoreSelector(
'getProductVariations'
);
} )
.finally( () => {
setIsGenerating( false );
} );
},
[]
);
return {
generateProductVariations,
isGenerating,
};
}

View File

@ -1,6 +0,0 @@
.woocommerce-product-options-section.woocommerce-form-section {
.woocommerce-form-section__content {
padding: 0;
border: 0;
}
}

View File

@ -1,55 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Link, useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './options-section.scss';
import { ProductSectionLayout } from '../layout/product-section-layout';
import { Options } from '../fields/options';
export const OptionsSection: React.FC = () => {
const {
getInputProps,
values: { id: productId },
} = useFormContext< Product >();
return (
<ProductSectionLayout
title={ __( 'Options', 'woocommerce' ) }
className="woocommerce-product-options-section"
description={
<>
<span>
{ __(
'Add and manage options, such as size and color, for customers to choose on the product page.',
'woocommerce'
) }
</span>
<Link
className="woocommerce-form-section__header-link"
href="https://woocommerce.com/document/managing-product-taxonomies/#product-attributes"
target="_blank"
type="external"
onClick={ () => {
recordEvent( 'learn_more_about_options_help' );
} }
>
{ __( 'Learn more about options', 'woocommerce' ) }
</Link>
</>
}
>
<Options
{ ...getInputProps( 'attributes', {
productId,
} ) }
/>
</ProductSectionLayout>
);
};

View File

@ -0,0 +1,7 @@
{
"phpVersion": "7.4",
"plugins": [
".",
"https://downloads.wordpress.org/plugin/woocommerce.zip"
]
}

View File

@ -1,4 +1,7 @@
<?php
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
register_woocommerce_admin_test_helper_rest_route(
@ -6,21 +9,25 @@ register_woocommerce_admin_test_helper_rest_route(
'admin_notes_add_note'
);
/**
* Adds an admin note.
*
* @param WP_REST_Request $request Full data about the request.
*/
function admin_notes_add_note( $request ) {
$note = new Note();
$note = new Note();
$mock_note_data = get_mock_note_data();
$type = $request->get_param( 'type' );
$layout = $request->get_param( 'layout' );
$type = $request->get_param( 'type' );
$layout = $request->get_param( 'layout' );
$note->set_name( $request->get_param( 'name' ) );
$note->set_title( $request->get_param( 'title' ) );
$note->set_content( $mock_note_data[ 'content' ] );
$note->set_content( $mock_note_data['content'] );
$note->set_image( $mock_note_data[ $type ][ $layout ] );
$note->set_layout( $layout );
$note->set_type( $type );
possibly_add_action( $note );
if ( 'email' === $type ) {
add_email_note_params( $note );
}
@ -30,6 +37,11 @@ function admin_notes_add_note( $request ) {
return true;
}
/**
* Adds an email note parameter.
*
* @param Note $note The note to add parameters to.
*/
function add_email_note_params( $note ) {
$additional_data = array(
'role' => 'administrator',
@ -37,6 +49,11 @@ function add_email_note_params( $note ) {
$note->set_content_data( (object) $additional_data );
}
/**
* Possibly adds an action to a note.
*
* @param Note $note The note to check and add an action to.
*/
function possibly_add_action( $note ) {
if ( $note->get_type() === 'info' ) {
return;
@ -48,20 +65,23 @@ function possibly_add_action( $note ) {
$note->add_action( $action_name, 'Test action', wc_admin_url() );
}
/**
* Gets mock note data.
*/
function get_mock_note_data() {
$plugin_url = site_url() . '/wp-content/plugins/woocommerce-admin-test-helper/';
return array(
'content' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud.',
'info' => array(
'content' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud.',
'info' => array(
'banner' => $plugin_url . 'images/admin-notes/banner.jpg',
'thumbnail' => $plugin_url . 'images/admin-notes/thumbnail.jpg',
'plain' => ''
'plain' => '',
),
'email' => array(
'plain' => $plugin_url . 'images/admin-notes/woocommerce-logo-vector.png'
'email' => array(
'plain' => $plugin_url . 'images/admin-notes/woocommerce-logo-vector.png',
),
'update' => array(
'plain' => '',
),
'update' => array(
'plain' => ''
)
);
}

View File

@ -1,12 +1,18 @@
<?php
defined( 'ABSPATH' ) || exit;
register_woocommerce_admin_test_helper_rest_route(
'/admin-notes/delete-all-notes/v1',
'admin_notes_delete_all_notes'
);
/**
* Deletes all admin notes.
*/
function admin_notes_delete_all_notes() {
global $wpdb;
$deleted_note_count = $wpdb->query( "DELETE FROM {$wpdb->prefix}wc_admin_notes" );
$deleted_action_count = $wpdb->query( "DELETE FROM {$wpdb->prefix}wc_admin_note_actions" );

View File

@ -5,6 +5,8 @@
* @package WC_Beta_Tester
*/
defined( 'ABSPATH' ) || exit;
/**
* Register the test helper route.
*
@ -23,7 +25,7 @@ function register_woocommerce_admin_test_helper_rest_route( $route, $callback, $
if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
return new \WP_Error(
'woocommerce_rest_cannot_edit',
__( 'Sorry, you cannot perform this action', 'woocommerce' )
__( 'Sorry, you cannot perform this action', 'woocommerce-beta-tester' )
);
}
return true;
@ -52,7 +54,7 @@ require 'tools/disable-wc-email.php';
require 'tools/trigger-update-callbacks.php';
require 'tracks/class-tracks-debug-log.php';
require 'features/features.php';
require 'rest-api-filters/rest-api-filters.php';
require 'rest-api-filters/class-wca-test-helper-rest-api-filters.php';
require 'rest-api-filters/hook.php';
require 'live-branches/manifest.php';
require 'live-branches/install.php';

View File

@ -1,12 +1,13 @@
<?php
use Automattic\WooCommerce\Admin\Features\Features;
defined( 'ABSPATH' ) || exit;
const OPTION_NAME_PREFIX = 'wc_admin_helper_feature_values';
register_woocommerce_admin_test_helper_rest_route(
'/features/(?P<feature_name>[a-z0-9_\-]+)/toggle',
'toggle_feature',
array(
array(
'methods' => 'POST',
)
);
@ -27,33 +28,44 @@ register_woocommerce_admin_test_helper_rest_route(
)
);
/**
* Toggles a feature.
*
* @param WP_REST_Request $request Full data about the request.
*/
function toggle_feature( $request ) {
$features = get_features();
$custom_feature_values = get_option( OPTION_NAME_PREFIX, array() );
$feature_name = $request->get_param( 'feature_name' );
$features = get_features();
$custom_feature_values = get_option( OPTION_NAME_PREFIX, array() );
$feature_name = $request->get_param( 'feature_name' );
if ( ! isset( $features[$feature_name ]) ) {
return new WP_REST_Response( $features, 204 );
}
if ( isset( $custom_feature_values[$feature_name] ) ) {
unset( $custom_feature_values[$feature_name] );
} else {
$custom_feature_values[$feature_name] = ! $features[ $feature_name ];
}
if ( ! isset( $features[ $feature_name ] ) ) {
return new WP_REST_Response( $features, 204 );
}
update_option(OPTION_NAME_PREFIX, $custom_feature_values );
if ( isset( $custom_feature_values[ $feature_name ] ) ) {
unset( $custom_feature_values[ $feature_name ] );
} else {
$custom_feature_values[ $feature_name ] = ! $features[ $feature_name ];
}
update_option( OPTION_NAME_PREFIX, $custom_feature_values );
return new WP_REST_Response( get_features(), 200 );
}
/**
* Resets all features to their default values.
*/
function reset_features() {
delete_option( OPTION_NAME_PREFIX );
return new WP_REST_Response( get_features(), 200 );
delete_option( OPTION_NAME_PREFIX );
return new WP_REST_Response( get_features(), 200 );
}
/**
* Gets all features.
*/
function get_features() {
if ( function_exists( 'wc_admin_get_feature_config' ) ) {
return apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
}
return array();
if ( function_exists( 'wc_admin_get_feature_config' ) ) {
return apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
}
return array();
}

View File

@ -1,10 +1,12 @@
<?php // @codingStandardsIgnoreLine I don't know why it thinks the doc comment is missing.
<?php
/**
* REST API endpoints for live branches installation.
*
* @package WC_Beta_Tester
*/
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/../../includes/class-wc-beta-tester-live-branches-installer.php';
register_woocommerce_admin_test_helper_rest_route(
@ -37,7 +39,7 @@ register_woocommerce_admin_test_helper_rest_route(
} else {
return new \WP_Error(
'woocommerce_rest_cannot_edit',
__( 'Sorry, you cannot perform this action', 'woocommerce' )
__( 'Sorry, you cannot perform this action', 'woocommerce-beta-tester' )
);
}
},

View File

@ -1,10 +1,12 @@
<?php // @codingStandardsIgnoreLine
<?php
/**
* Register REST endpoint for fetching live branches manifest.
*
* @package WC_Beta_Tester
*/
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/../../includes/class-wc-beta-tester-live-branches-installer.php';
register_woocommerce_admin_test_helper_rest_route(

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