From 33b2bdc0cd31824a62acdacd724820a9ae66c2f5 Mon Sep 17 00:00:00 2001 From: Florian DANIEL aka Facyla Date: Fri, 23 Sep 2022 12:47:44 +0200 Subject: [PATCH 01/58] Error message in logs on CSV export error Provide a useful feedback message when CSV export fails due to wrong permissions on wp-content/upload/ folder (or any other folder set by WC CSV Exporter module). This helps understand why CSV export fails under some conditions, by providing a hint on the error cause, instead of silently failing. --- .../includes/export/abstract-wc-csv-batch-exporter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php index d65f36bed17..e6f518d269d 100644 --- a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php +++ b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php @@ -127,6 +127,7 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter { protected function write_csv_data( $data ) { if ( ! file_exists( $this->get_file_path() ) || ! is_writeable( $this->get_file_path() ) ) { + error_log(__("ERROR : Cannot create temporary CSV export file : please check permissions on upload directory.", 'woocommerce')); return false; } From 29c9dfce165ea3a18a67f77e31e080b0e1fe1b37 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 10 Oct 2022 09:45:50 +1000 Subject: [PATCH 02/58] Get the first array item for the alt_text. Props galbaras --- plugins/woocommerce/includes/wc-product-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index 188c8096d44..222ad109eb2 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -819,7 +819,7 @@ function wc_get_product_attachment_props( $attachment_id = null, $product = fals } $alt_text = array_filter( $alt_text ); - $props['alt'] = isset( $alt_text[0] ) ? $alt_text[0] : ''; + $props['alt'] = $alt_text ? reset( $alt_text ) : ''; // Large version. $full_size = apply_filters( 'woocommerce_gallery_full_size', apply_filters( 'woocommerce_product_thumbnails_large_size', 'full' ) ); From 2a034c0df4108e7e954a71d3a8917b2b54f79485 Mon Sep 17 00:00:00 2001 From: Phill <38789408+SavPhill@users.noreply.github.com> Date: Sat, 15 Oct 2022 15:43:52 +0700 Subject: [PATCH 03/58] Update tooltip text The tooltip for the Header Image field currently reads: "URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media)." I believe my small edit to the description text makes it more clearer for how the user should do this. --- .../includes/admin/settings/class-wc-settings-emails.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php index 9086d7fd8b8..36705f13c50 100644 --- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php +++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php @@ -120,7 +120,7 @@ class WC_Settings_Emails extends WC_Settings_Page { array( 'title' => __( 'Header image', 'woocommerce' ), - 'desc' => __( 'URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media).', 'woocommerce' ), + 'desc' => __( 'Paste the URL of an image you want to show in the email header. Upload images using the media uploader (Media > Add New).', 'woocommerce' ), 'id' => 'woocommerce_email_header_image', 'type' => 'text', 'css' => 'min-width:400px;', From 1b5bc44c60810413d01e1430c7c8dca4fb990375 Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 2 Nov 2022 20:14:55 +0530 Subject: [PATCH 04/58] Add changelog. --- plugins/woocommerce/changelog/pr-35107 | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 plugins/woocommerce/changelog/pr-35107 diff --git a/plugins/woocommerce/changelog/pr-35107 b/plugins/woocommerce/changelog/pr-35107 new file mode 100644 index 00000000000..4a03f11bfc9 --- /dev/null +++ b/plugins/woocommerce/changelog/pr-35107 @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Only a minor text change. + + From 4647efa84066d353cd2829e3cb9d8e0925a20cf8 Mon Sep 17 00:00:00 2001 From: Timur Gogolev Date: Mon, 26 Dec 2022 16:02:23 +0300 Subject: [PATCH 05/58] Include WC Cart functions for REST API calls --- plugins/woocommerce/changelog/fix-missed-wc-empty-cart | 4 ++++ plugins/woocommerce/includes/class-wc-session-handler.php | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/fix-missed-wc-empty-cart diff --git a/plugins/woocommerce/changelog/fix-missed-wc-empty-cart b/plugins/woocommerce/changelog/fix-missed-wc-empty-cart new file mode 100644 index 00000000000..cb2d0b4ecb1 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-missed-wc-empty-cart @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Load wc_empty_cart function for REST API calls. diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php index 332ecb5e7f6..02cda0a3b0c 100644 --- a/plugins/woocommerce/includes/class-wc-session-handler.php +++ b/plugins/woocommerce/includes/class-wc-session-handler.php @@ -369,7 +369,11 @@ class WC_Session_Handler extends WC_Session { public function forget_session() { wc_setcookie( $this->_cookie, '', time() - YEAR_IN_SECONDS, $this->use_secure_cookie(), true ); - wc_empty_cart(); + if( ! is_admin() ) { + include_once WC_ABSPATH . 'includes/wc-cart-functions.php'; + + wc_empty_cart(); + } $this->_data = array(); $this->_dirty = false; From abd4c8da80909e9bb424d17ff2643c00f14f2657 Mon Sep 17 00:00:00 2001 From: Timur Gogolev Date: Mon, 26 Dec 2022 16:34:20 +0300 Subject: [PATCH 06/58] Fix an issue found by PHPCS --- plugins/woocommerce/includes/class-wc-session-handler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php index 02cda0a3b0c..046583dfe45 100644 --- a/plugins/woocommerce/includes/class-wc-session-handler.php +++ b/plugins/woocommerce/includes/class-wc-session-handler.php @@ -369,7 +369,7 @@ class WC_Session_Handler extends WC_Session { public function forget_session() { wc_setcookie( $this->_cookie, '', time() - YEAR_IN_SECONDS, $this->use_secure_cookie(), true ); - if( ! is_admin() ) { + if ( ! is_admin() ) { include_once WC_ABSPATH . 'includes/wc-cart-functions.php'; wc_empty_cart(); From dc2a0ff774e36cfd2b5c4991e354b3e6b01d62e8 Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Thu, 12 Jan 2023 16:26:54 -0800 Subject: [PATCH 07/58] Changelog. --- plugins/woocommerce/changelog/34071-alt-text-handling | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/woocommerce/changelog/34071-alt-text-handling diff --git a/plugins/woocommerce/changelog/34071-alt-text-handling b/plugins/woocommerce/changelog/34071-alt-text-handling new file mode 100644 index 00000000000..fea6c96482b --- /dev/null +++ b/plugins/woocommerce/changelog/34071-alt-text-handling @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Improve the way we retrieve the alt text property for product attachments. From 2dce575449cb6710ffe0a86561f78d39bb2080c7 Mon Sep 17 00:00:00 2001 From: Joel Thiessen <444632+joelclimbsthings@users.noreply.github.com> Date: Mon, 16 Jan 2023 04:13:55 -0800 Subject: [PATCH 08/58] Adding ProductForm data store (#36430) * Removing overriding functions in Section.php * Adding changelog * Adding product form data store * Adding data package changelog --- .../add-36073-product-form-data-store | 4 ++ packages/js/data/src/index.ts | 6 +- .../js/data/src/product-form/action-types.ts | 8 +++ packages/js/data/src/product-form/actions.ts | 42 +++++++++++++ .../js/data/src/product-form/constants.ts | 1 + packages/js/data/src/product-form/index.ts | 37 +++++++++++ packages/js/data/src/product-form/reducer.ts | 63 +++++++++++++++++++ .../js/data/src/product-form/resolvers.ts | 53 ++++++++++++++++ .../js/data/src/product-form/selectors.ts | 17 +++++ packages/js/data/src/product-form/types.ts | 35 +++++++++++ .../add-36073-product-form-data-store | 4 ++ .../Internal/Admin/ProductForm/Section.php | 22 ------- 12 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 packages/js/data/changelog/add-36073-product-form-data-store create mode 100644 packages/js/data/src/product-form/action-types.ts create mode 100644 packages/js/data/src/product-form/actions.ts create mode 100644 packages/js/data/src/product-form/constants.ts create mode 100644 packages/js/data/src/product-form/index.ts create mode 100644 packages/js/data/src/product-form/reducer.ts create mode 100644 packages/js/data/src/product-form/resolvers.ts create mode 100644 packages/js/data/src/product-form/selectors.ts create mode 100644 packages/js/data/src/product-form/types.ts create mode 100644 plugins/woocommerce/changelog/add-36073-product-form-data-store diff --git a/packages/js/data/changelog/add-36073-product-form-data-store b/packages/js/data/changelog/add-36073-product-form-data-store new file mode 100644 index 00000000000..801045b961d --- /dev/null +++ b/packages/js/data/changelog/add-36073-product-form-data-store @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding Product Form data store. diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index 51c0d88a515..c0af7842a32 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -25,6 +25,7 @@ export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; +export { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form'; export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes'; export { PaymentGateway } from './payment-gateways/types'; @@ -75,6 +76,7 @@ export { // Export types export * from './types'; export * from './countries/types'; +export { ProductForm } from './product-form/types'; export * from './onboarding/types'; export * from './plugins/types'; export * from './products/types'; @@ -122,6 +124,7 @@ import type { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; +import type { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form'; import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes'; @@ -148,7 +151,8 @@ export type WCDataStoreName = | typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME - | typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME; + | typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME + | typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME; /** * Internal dependencies diff --git a/packages/js/data/src/product-form/action-types.ts b/packages/js/data/src/product-form/action-types.ts new file mode 100644 index 00000000000..823b1e267f5 --- /dev/null +++ b/packages/js/data/src/product-form/action-types.ts @@ -0,0 +1,8 @@ +export enum TYPES { + GET_FIELDS_ERROR = 'GET_FIELDS_ERROR', + GET_FIELDS_SUCCESS = 'GET_FIELDS_SUCCESS', + GET_PRODUCT_FORM_ERROR = 'GET_PRODUCT_FORM_ERROR', + GET_PRODUCT_FORM_SUCCESS = 'GET_PRODUCT_FORM_SUCCESS', +} + +export default TYPES; diff --git a/packages/js/data/src/product-form/actions.ts b/packages/js/data/src/product-form/actions.ts new file mode 100644 index 00000000000..e5e2288a838 --- /dev/null +++ b/packages/js/data/src/product-form/actions.ts @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { Field, ProductForm } from './types'; + +export function getFieldsSuccess( fields: Field[] ) { + return { + type: TYPES.GET_FIELDS_SUCCESS as const, + fields, + }; +} + +export function getFieldsError( error: unknown ) { + return { + type: TYPES.GET_FIELDS_ERROR as const, + error, + }; +} + +export function getProductFormSuccess( productForm: ProductForm ) { + return { + type: TYPES.GET_PRODUCT_FORM_SUCCESS as const, + fields: productForm.fields, + sections: productForm.sections, + subsections: productForm.subsections, + }; +} + +export function getProductFormError( error: unknown ) { + return { + type: TYPES.GET_PRODUCT_FORM_ERROR as const, + error, + }; +} + +export type Action = ReturnType< + | typeof getFieldsSuccess + | typeof getFieldsError + | typeof getProductFormSuccess + | typeof getProductFormError +>; diff --git a/packages/js/data/src/product-form/constants.ts b/packages/js/data/src/product-form/constants.ts new file mode 100644 index 00000000000..d295446042e --- /dev/null +++ b/packages/js/data/src/product-form/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'experimental/wc/admin/product-form'; diff --git a/packages/js/data/src/product-form/index.ts b/packages/js/data/src/product-form/index.ts new file mode 100644 index 00000000000..0a6dfbef68d --- /dev/null +++ b/packages/js/data/src/product-form/index.ts @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; +import { SelectFromMap, DispatchFromMap } from '@automattic/data-stores'; +import { Reducer, AnyAction } from 'redux'; +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer, { State } from './reducer'; +import { WPDataSelectors } from '../types'; +export * from './types'; +export type { State }; + +registerStore< State >( STORE_NAME, { + reducer: reducer as Reducer< State, AnyAction >, + actions, + controls, + selectors, + resolvers, +} ); + +export const EXPERIMENTAL_PRODUCT_FORM_STORE_NAME = STORE_NAME; + +declare module '@wordpress/data' { + function dispatch( + key: typeof STORE_NAME + ): DispatchFromMap< typeof actions >; + function select( + key: typeof STORE_NAME + ): SelectFromMap< typeof selectors > & WPDataSelectors; +} diff --git a/packages/js/data/src/product-form/reducer.ts b/packages/js/data/src/product-form/reducer.ts new file mode 100644 index 00000000000..6757619bd1a --- /dev/null +++ b/packages/js/data/src/product-form/reducer.ts @@ -0,0 +1,63 @@ +/** + * External dependencies + */ + +import type { Reducer } from 'redux'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { Action } from './actions'; +import { ProductFormState } from './types'; + +const reducer: Reducer< ProductFormState, Action > = ( + state = { + errors: {}, + fields: [], + sections: [], + subsections: [], + }, + action +) => { + switch ( action.type ) { + case TYPES.GET_FIELDS_SUCCESS: + state = { + ...state, + fields: action.fields, + }; + break; + case TYPES.GET_FIELDS_ERROR: + state = { + ...state, + errors: { + ...state.errors, + fields: action.error, + }, + }; + break; + case TYPES.GET_PRODUCT_FORM_SUCCESS: + state = { + ...state, + fields: action.fields, + sections: action.sections, + subsections: action.subsections, + }; + break; + case TYPES.GET_PRODUCT_FORM_ERROR: + state = { + ...state, + errors: { + ...state.errors, + fields: action.error, + sections: action.error, + subsections: action.error, + }, + }; + break; + } + return state; +}; + +export type State = ReturnType< typeof reducer >; +export default reducer; diff --git a/packages/js/data/src/product-form/resolvers.ts b/packages/js/data/src/product-form/resolvers.ts new file mode 100644 index 00000000000..632a2dc5a57 --- /dev/null +++ b/packages/js/data/src/product-form/resolvers.ts @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { apiFetch, select } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { + getFieldsSuccess, + getFieldsError, + getProductFormSuccess, + getProductFormError, +} from './actions'; +import { WC_ADMIN_NAMESPACE } from '../constants'; +import { Field, ProductForm } from './types'; +import { STORE_NAME } from './constants'; + +const resolveSelect = + controls && controls.resolveSelect ? controls.resolveSelect : select; + +export function* getFields() { + try { + const url = WC_ADMIN_NAMESPACE + '/product-form/fields'; + const results: Field[] = yield apiFetch( { + path: url, + method: 'GET', + } ); + + return getFieldsSuccess( results ); + } catch ( error ) { + return getFieldsError( error ); + } +} + +export function* getCountry() { + yield resolveSelect( STORE_NAME, 'getProductForm' ); +} + +export function* getProductForm() { + try { + const url = WC_ADMIN_NAMESPACE + '/product-form'; + const results: ProductForm = yield apiFetch( { + path: url, + method: 'GET', + } ); + + return getProductFormSuccess( results ); + } catch ( error ) { + return getProductFormError( error ); + } +} diff --git a/packages/js/data/src/product-form/selectors.ts b/packages/js/data/src/product-form/selectors.ts new file mode 100644 index 00000000000..d986b006cc7 --- /dev/null +++ b/packages/js/data/src/product-form/selectors.ts @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { ProductFormState } from './types'; + +export const getFields = ( state: ProductFormState ) => { + return state.fields; +}; + +export const getField = ( state: ProductFormState, id: string ) => { + return state.fields.find( ( field ) => field.id === id ); +}; + +export const getProductForm = ( state: ProductFormState ) => { + const { errors, ...form } = state; + return form; +}; diff --git a/packages/js/data/src/product-form/types.ts b/packages/js/data/src/product-form/types.ts new file mode 100644 index 00000000000..d4dcc8319b1 --- /dev/null +++ b/packages/js/data/src/product-form/types.ts @@ -0,0 +1,35 @@ +type BaseComponent = { + id: string; + plugin_id: string; + order: number; +}; + +type FieldProperties = { + name: string; + label: string; +}; + +export type Field = BaseComponent & { + type: string; + section: string; + properties: FieldProperties; +}; + +export type Section = BaseComponent & { + title: string; + description: string; +}; + +export type Subsection = BaseComponent; + +export type ProductForm = { + fields: Field[]; + sections: Section[]; + subsections: Subsection[]; +}; + +export type ProductFormState = ProductForm & { + errors: { + [ key: string ]: unknown; + }; +}; diff --git a/plugins/woocommerce/changelog/add-36073-product-form-data-store b/plugins/woocommerce/changelog/add-36073-product-form-data-store new file mode 100644 index 00000000000..bb0f8989152 --- /dev/null +++ b/plugins/woocommerce/changelog/add-36073-product-form-data-store @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding JS data store for ProductForm. diff --git a/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php b/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php index 049be3f4ef4..b86512bdb7a 100644 --- a/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php +++ b/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php @@ -54,28 +54,6 @@ class Section extends Component { $this->title = $additional_args['title']; } - /** - * Field arguments. - * - * @return array - */ - public function get_arguments() { - return $this->additional_args; - } - - /** - * Get the section as JSON. - * - * @return array - */ - public function get_json() { - return array( - 'id' => $this->get_id(), - 'plugin_id' => $this->get_plugin_id(), - 'arguments' => $this->get_arguments(), - ); - } - /** * Get missing arguments of args array. * From d935b0137ab84dc2cffb0c59f659de50f6fe16e5 Mon Sep 17 00:00:00 2001 From: Roy Ho Date: Mon, 16 Jan 2023 06:12:25 -0800 Subject: [PATCH 09/58] Add necessary permissions for code freeze workflows (#36399) * Add necessary permissions for code freeze workflows * Checkout pnpm-lock.yaml to prevent issues --- .github/workflows/release-changelog.yml | 7 +++++-- .github/workflows/release-code-freeze.yml | 9 +++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index b8ce8d8572a..ab77d208efb 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -21,8 +21,8 @@ jobs: create-changelog-prs: runs-on: ubuntu-20.04 permissions: - contents: read - pull-requests: write + contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v3 @@ -46,6 +46,9 @@ jobs: - name: 'Generate the changelog file' run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }} + - name: Checkout pnpm-lock.yaml to prevent issues + run: git checkout pnpm-lock.yaml + - name: 'git rm deleted files' run: git rm $(git ls-files --deleted) diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index ffb31eea89c..1a73fddb62b 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -60,7 +60,8 @@ jobs: name: 'Maybe create next milestone and release branch' runs-on: ubuntu-20.04 permissions: - contents: read + contents: write + issues: write needs: verify-code-freeze if: needs.verify-code-freeze.outputs.freeze == 0 outputs: @@ -89,8 +90,8 @@ jobs: name: Preps trunk for next development cycle runs-on: ubuntu-20.04 permissions: - contents: read - pull-requests: write + contents: write + pull-requests: write needs: maybe-create-next-milestone-and-release-branch steps: - name: Checkout code @@ -159,7 +160,7 @@ jobs: name: 'Trigger changelog action' runs-on: ubuntu-20.04 permissions: - actions: write + actions: write needs: maybe-create-next-milestone-and-release-branch steps: - name: 'Trigger changelog action' From 0d6af37a66b8b3a42a96dc654791d84315741321 Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Mon, 16 Jan 2023 07:54:33 -0800 Subject: [PATCH 10/58] Add requesting state, actions, and selectors to CRUD data stores (#36297) * Add requesting state, actions, and selectors to CRUD data stores * Allow resource specific selectors for requesting status * Use constants for action names in reducer * Add tests around new utils * Add changelog entry * Use getResourceName instead of getRequestKey in reducer * Rename and use getRequestIdentifier to avoid ambiguity with resource names * Sanitize and replace ID query with key in resolution selectors * Fix up crud action names and remove camel casing * Fix missing ID in ID query check * Fix up tests around request identifier * Add additional tests around utils * Clean up imports * Add missing selectors to tests --- packages/js/data/changelog/add-36189 | 4 + packages/js/data/src/crud/action-types.ts | 3 + packages/js/data/src/crud/actions.ts | 61 +++++++- packages/js/data/src/crud/reducer.ts | 162 ++++++++++++++++++-- packages/js/data/src/crud/selectors.ts | 62 ++++++-- packages/js/data/src/crud/test/reducer.ts | 79 ++++++++-- packages/js/data/src/crud/test/selectors.ts | 4 +- packages/js/data/src/crud/test/utils.ts | 105 +++++++++++++ packages/js/data/src/crud/utils.ts | 90 +++++++++++ 9 files changed, 524 insertions(+), 46 deletions(-) create mode 100644 packages/js/data/changelog/add-36189 diff --git a/packages/js/data/changelog/add-36189 b/packages/js/data/changelog/add-36189 new file mode 100644 index 00000000000..581419992cd --- /dev/null +++ b/packages/js/data/changelog/add-36189 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add ability to check CRUD dispatch action status diff --git a/packages/js/data/src/crud/action-types.ts b/packages/js/data/src/crud/action-types.ts index fac68896739..903d63535c0 100644 --- a/packages/js/data/src/crud/action-types.ts +++ b/packages/js/data/src/crud/action-types.ts @@ -1,13 +1,16 @@ export enum TYPES { CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR', + CREATE_ITEM_REQUEST = 'CREATE_ITEM_REQUEST', CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS', DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR', + DELETE_ITEM_REQUEST = 'DELETE_ITEM_REQUEST', DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS', GET_ITEM_ERROR = 'GET_ITEM_ERROR', GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS', GET_ITEMS_ERROR = 'GET_ITEMS_ERROR', GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS', UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR', + UPDATE_ITEM_REQUEST = 'UPDATE_ITEM_REQUEST', UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS', GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS', GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR', diff --git a/packages/js/data/src/crud/actions.ts b/packages/js/data/src/crud/actions.ts index 2743c329505..f708955f6a6 100644 --- a/packages/js/data/src/crud/actions.ts +++ b/packages/js/data/src/crud/actions.ts @@ -25,20 +25,41 @@ export function createItemError( query: Partial< ItemQuery >, error: unknown ) { }; } -export function createItemSuccess( key: IdType, item: Item ) { +export function createItemRequest( query: Partial< ItemQuery > ) { + return { + type: TYPES.CREATE_ITEM_REQUEST as const, + query, + }; +} + +export function createItemSuccess( + key: IdType, + item: Item, + query: Partial< ItemQuery > +) { return { type: TYPES.CREATE_ITEM_SUCCESS as const, key, item, + query, }; } -export function deleteItemError( key: IdType, error: unknown ) { +export function deleteItemError( key: IdType, error: unknown, force: boolean ) { return { type: TYPES.DELETE_ITEM_ERROR as const, key, error, errorType: CRUD_ACTIONS.DELETE_ITEM, + force, + }; +} + +export function deleteItemRequest( key: IdType, force: boolean ) { + return { + type: TYPES.DELETE_ITEM_REQUEST as const, + key, + force, }; } @@ -116,20 +137,38 @@ export function getItemsTotalCountError( }; } -export function updateItemError( key: IdType, error: unknown ) { +export function updateItemError( + key: IdType, + error: unknown, + query: Partial< ItemQuery > +) { return { type: TYPES.UPDATE_ITEM_ERROR as const, key, error, errorType: CRUD_ACTIONS.UPDATE_ITEM, + query, }; } -export function updateItemSuccess( key: IdType, item: Item ) { +export function updateItemRequest( key: IdType, query: Partial< ItemQuery > ) { + return { + type: TYPES.UPDATE_ITEM_REQUEST as const, + key, + query, + }; +} + +export function updateItemSuccess( + key: IdType, + item: Item, + query: Partial< ItemQuery > +) { return { type: TYPES.UPDATE_ITEM_SUCCESS as const, key, item, + query, }; } @@ -138,6 +177,7 @@ export const createDispatchActions = ( { resourceName, }: ResolverOptions ) => { const createItem = function* ( query: Partial< ItemQuery > ) { + yield createItemRequest( query ); const urlParameters = getUrlParameters( namespace, query ); try { @@ -151,7 +191,7 @@ export const createDispatchActions = ( { } ); const { key } = parseId( item.id, urlParameters ); - yield createItemSuccess( key, item ); + yield createItemSuccess( key, item, query ); return item; } catch ( error ) { yield createItemError( query, error ); @@ -162,6 +202,7 @@ export const createDispatchActions = ( { const deleteItem = function* ( idQuery: IdQuery, force = true ) { const urlParameters = getUrlParameters( namespace, idQuery ); const { id, key } = parseId( idQuery, urlParameters ); + yield deleteItemRequest( key, force ); try { const item: Item = yield apiFetch( { @@ -176,7 +217,7 @@ export const createDispatchActions = ( { yield deleteItemSuccess( key, force, item ); return item; } catch ( error ) { - yield deleteItemError( key, error ); + yield deleteItemError( key, error, force ); throw error; } }; @@ -187,6 +228,7 @@ export const createDispatchActions = ( { ) { const urlParameters = getUrlParameters( namespace, idQuery ); const { id, key } = parseId( idQuery, urlParameters ); + yield updateItemRequest( key, query ); try { const item: Item = yield apiFetch( { @@ -199,10 +241,10 @@ export const createDispatchActions = ( { data: query, } ); - yield updateItemSuccess( key, item ); + yield updateItemSuccess( key, item, query ); return item; } catch ( error ) { - yield updateItemError( key, error ); + yield updateItemError( key, error, query ); throw error; } }; @@ -216,8 +258,10 @@ export const createDispatchActions = ( { export type Actions = ReturnType< | typeof createItemError + | typeof createItemRequest | typeof createItemSuccess | typeof deleteItemError + | typeof deleteItemRequest | typeof deleteItemSuccess | typeof getItemError | typeof getItemSuccess @@ -226,5 +270,6 @@ export type Actions = ReturnType< | typeof getItemsTotalCountSuccess | typeof getItemsTotalCountError | typeof updateItemError + | typeof updateItemRequest | typeof updateItemSuccess >; diff --git a/packages/js/data/src/crud/reducer.ts b/packages/js/data/src/crud/reducer.ts index 87b12520c98..baa2de63c1f 100644 --- a/packages/js/data/src/crud/reducer.ts +++ b/packages/js/data/src/crud/reducer.ts @@ -8,8 +8,8 @@ import { Reducer } from 'redux'; */ import { Actions } from './actions'; import CRUD_ACTIONS from './crud-actions'; -import { getKey } from './utils'; -import { getResourceName, getTotalCountResourceName } from '../utils'; +import { getKey, getRequestIdentifier } from './utils'; +import { getTotalCountResourceName } from '../utils'; import { IdType, Item, ItemQuery } from './types'; import { TYPES } from './action-types'; @@ -24,6 +24,7 @@ export type ResourceState = { data: Data; itemsCount: Record< string, number >; errors: Record< string, unknown >; + requesting: Record< string, boolean >; }; export const createReducer = () => { @@ -33,19 +34,37 @@ export const createReducer = () => { data: {}, itemsCount: {}, errors: {}, + requesting: {}, }, payload ) => { + const itemData = state.data || {}; + if ( payload && 'type' in payload ) { switch ( payload.type ) { case TYPES.CREATE_ITEM_ERROR: + const createItemErrorRequestId = getRequestIdentifier( + payload.errorType, + payload.query || {} + ); + return { + ...state, + errors: { + ...state.errors, + [ createItemErrorRequestId ]: payload.error, + }, + requesting: { + ...state.requesting, + [ createItemErrorRequestId ]: false, + }, + }; case TYPES.GET_ITEMS_TOTAL_COUNT_ERROR: case TYPES.GET_ITEMS_ERROR: return { ...state, errors: { ...state.errors, - [ getResourceName( + [ getRequestIdentifier( payload.errorType, ( payload.query || {} ) as ItemQuery ) ]: payload.error, @@ -64,9 +83,27 @@ export const createReducer = () => { }; case TYPES.CREATE_ITEM_SUCCESS: + const createItemSuccessRequestId = getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + payload.key, + payload.query + ); + return { + ...state, + data: { + ...itemData, + [ payload.key ]: { + ...( itemData[ payload.key ] || {} ), + ...payload.item, + }, + }, + requesting: { + ...state.requesting, + [ createItemSuccessRequestId ]: false, + }, + }; + case TYPES.GET_ITEM_SUCCESS: - case TYPES.UPDATE_ITEM_SUCCESS: - const itemData = state.data || {}; return { ...state, data: { @@ -78,7 +115,33 @@ export const createReducer = () => { }, }; + case TYPES.UPDATE_ITEM_SUCCESS: + const updateItemSuccessRequestId = getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, + payload.key, + payload.query + ); + return { + ...state, + data: { + ...itemData, + [ payload.key ]: { + ...( itemData[ payload.key ] || {} ), + ...payload.item, + }, + }, + requesting: { + ...state.requesting, + [ updateItemSuccessRequestId ]: false, + }, + }; + case TYPES.DELETE_ITEM_SUCCESS: + const deleteItemSuccessRequestId = getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, + payload.key, + payload.force + ); const itemKeys = Object.keys( state.data ); const nextData = itemKeys.reduce< Data >( ( items: Data, key: string ) => { @@ -98,18 +161,57 @@ export const createReducer = () => { return { ...state, data: nextData, + requesting: { + ...state.requesting, + [ deleteItemSuccessRequestId ]: false, + }, }; case TYPES.DELETE_ITEM_ERROR: - case TYPES.GET_ITEM_ERROR: - case TYPES.UPDATE_ITEM_ERROR: + const deleteItemErrorRequestId = getRequestIdentifier( + payload.errorType, + payload.key, + payload.force + ); return { ...state, errors: { ...state.errors, - [ getResourceName( payload.errorType, { - key: payload.key, - } ) ]: payload.error, + [ deleteItemErrorRequestId ]: payload.error, + }, + requesting: { + ...state.requesting, + [ deleteItemErrorRequestId ]: false, + }, + }; + + case TYPES.GET_ITEM_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ getRequestIdentifier( + payload.errorType, + payload.key + ) ]: payload.error, + }, + }; + + case TYPES.UPDATE_ITEM_ERROR: + const upateItemErrorRequestId = getRequestIdentifier( + payload.errorType, + payload.key, + payload.query + ); + return { + ...state, + errors: { + ...state.errors, + [ upateItemErrorRequestId ]: payload.error, + }, + requesting: { + ...state.requesting, + [ upateItemErrorRequestId ]: false, }, }; @@ -128,7 +230,7 @@ export const createReducer = () => { return result; }, {} ); - const itemQuery = getResourceName( + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS, ( payload.query || {} ) as ItemQuery ); @@ -145,6 +247,44 @@ export const createReducer = () => { }, }; + case TYPES.CREATE_ITEM_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + [ getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + payload.query + ) ]: true, + }, + }; + + case TYPES.DELETE_ITEM_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + [ getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, + payload.key, + payload.force + ) ]: true, + }, + }; + + case TYPES.UPDATE_ITEM_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + [ getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, + payload.key, + payload.query + ) ]: true, + }, + }; + default: return state; } diff --git a/packages/js/data/src/crud/selectors.ts b/packages/js/data/src/crud/selectors.ts index 05d019a7b04..f95d0e31bc3 100644 --- a/packages/js/data/src/crud/selectors.ts +++ b/packages/js/data/src/crud/selectors.ts @@ -6,8 +6,15 @@ import createSelector from 'rememo'; /** * Internal dependencies */ -import { applyNamespace, getUrlParameters, parseId } from './utils'; -import { getResourceName, getTotalCountResourceName } from '../utils'; +import { + applyNamespace, + getGenericActionName, + getRequestIdentifier, + getUrlParameters, + maybeReplaceIdQuery, + parseId, +} from './utils'; +import { getTotalCountResourceName } from '../utils'; import { IdQuery, IdType, Item, ItemQuery } from './types'; import { ResourceState } from './reducer'; import CRUD_ACTIONS from './crud-actions'; @@ -22,7 +29,7 @@ export const getItemCreateError = ( state: ResourceState, query: ItemQuery ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.CREATE_ITEM, query ); return state.errors[ itemQuery ]; }; @@ -33,7 +40,7 @@ export const getItemDeleteError = ( ) => { const urlParameters = getUrlParameters( namespace, idQuery ); const { key } = parseId( idQuery, urlParameters ); - const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { key } ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.DELETE_ITEM, key ); return state.errors[ itemQuery ]; }; @@ -54,13 +61,13 @@ export const getItemError = ( ) => { const urlParameters = getUrlParameters( namespace, idQuery ); const { key } = parseId( idQuery, urlParameters ); - const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key ); return state.errors[ itemQuery ]; }; export const getItems = createSelector( ( state: ResourceState, query?: ItemQuery ) => { - const itemQuery = getResourceName( + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS, query || {} ); @@ -96,7 +103,7 @@ export const getItems = createSelector( return data; }, ( state, query ) => { - const itemQuery = getResourceName( + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS, query || {} ); @@ -129,7 +136,10 @@ export const getItemsTotalCount = ( }; export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEMS, query || {} ); + const itemQuery = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query || {} + ); return state.errors[ itemQuery ]; }; @@ -138,12 +148,8 @@ export const getItemUpdateError = ( idQuery: IdQuery, urlParameters: IdType[] ) => { - const params = parseId( idQuery, urlParameters ); - const { key } = params; - const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { - key, - params, - } ); + const { key } = parseId( idQuery, urlParameters ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.UPDATE_ITEM, key ); return state.errors[ itemQuery ]; }; @@ -154,6 +160,32 @@ export const createSelectors = ( { pluralResourceName, namespace, }: SelectorOptions ) => { + const hasFinishedRequest = ( + state: ResourceState, + action: string, + args = [] + ) => { + const sanitizedArgs = maybeReplaceIdQuery( args, namespace ); + const actionName = getGenericActionName( action, resourceName ); + const requestId = getRequestIdentifier( actionName, ...sanitizedArgs ); + if ( action ) + return ( + state.requesting.hasOwnProperty( requestId ) && + ! state.requesting[ requestId ] + ); + }; + + const isRequesting = ( + state: ResourceState, + action: string, + args = [] + ) => { + const sanitizedArgs = maybeReplaceIdQuery( args, namespace ); + const actionName = getGenericActionName( action, resourceName ); + const requestId = getRequestIdentifier( actionName, ...sanitizedArgs ); + return state.requesting[ requestId ]; + }; + return { [ `get${ resourceName }` ]: applyNamespace( getItem, namespace ), [ `get${ resourceName }Error` ]: applyNamespace( @@ -184,5 +216,7 @@ export const createSelectors = ( { getItemUpdateError, namespace ), + hasFinishedRequest, + isRequesting, }; }; diff --git a/packages/js/data/src/crud/test/reducer.ts b/packages/js/data/src/crud/test/reducer.ts index d0d3391e60a..0b2ec44c7c7 100644 --- a/packages/js/data/src/crud/test/reducer.ts +++ b/packages/js/data/src/crud/test/reducer.ts @@ -5,6 +5,7 @@ import { Actions } from '../actions'; import { createReducer, ResourceState } from '../reducer'; import { CRUD_ACTIONS } from '../crud-actions'; import { getResourceName } from '../../utils'; +import { getRequestIdentifier } from '..//utils'; import { Item, ItemQuery } from '../types'; import TYPES from '../action-types'; @@ -13,6 +14,7 @@ const defaultState: ResourceState = { errors: {}, itemsCount: {}, data: {}, + requesting: {}, }; const reducer = createReducer(); @@ -38,6 +40,7 @@ describe( 'crud reducer', () => { 1: { id: 1, name: 'Donkey', status: 'draft' }, 2: { id: 2, name: 'Sauce', status: 'publish' }, }, + requesting: {}, }; const update: Item = { id: 2, @@ -72,7 +75,10 @@ describe( 'crud reducer', () => { urlParameters: [], } ); - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); @@ -108,7 +114,10 @@ describe( 'crud reducer', () => { urlParameters: [ 5 ], } ); - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] ); @@ -141,7 +150,10 @@ describe( 'crud reducer', () => { urlParameters: [], } ); - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); @@ -157,7 +169,10 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEMS_ERROR', () => { const query: Partial< ItemQuery > = { status: 'draft' }; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEMS_ERROR, @@ -171,7 +186,7 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => { const query: Partial< ItemQuery > = { status: 'draft' }; - const resourceName = getResourceName( + const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT, query ); @@ -188,7 +203,7 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEM_ERROR', () => { const key = 3; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); + const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEM_ERROR, @@ -202,7 +217,7 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEM_ERROR', () => { const key = 3; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); + const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEM_ERROR, @@ -216,7 +231,10 @@ describe( 'crud reducer', () => { it( 'should handle CREATE_ITEM_ERROR', () => { const query = { name: 'Invalid product' }; - const resourceName = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + query + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.CREATE_ITEM_ERROR, @@ -226,22 +244,28 @@ describe( 'crud reducer', () => { } ); expect( state.errors[ resourceName ] ).toBe( error ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); it( 'should handle UPDATE_ITEM_ERROR', () => { const key = 2; - const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { + const query = { property: 'value' }; + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, key, - } ); + query + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.UPDATE_ITEM_ERROR, key, error, errorType: CRUD_ACTIONS.UPDATE_ITEM, + query, } ); expect( state.errors[ resourceName ] ).toBe( error ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); it( 'should handle UPDATE_ITEM_SUCCESS', () => { @@ -258,17 +282,25 @@ describe( 'crud reducer', () => { 1: { id: 1, name: 'Donkey', status: 'draft' }, 2: { id: 2, name: 'Sauce', status: 'publish' }, }, + requesting: {}, }; const item: Item = { id: 2, name: 'Holy smokes!', status: 'draft', }; + const query = { property: 'value' }; + const requestId = getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, + item.id, + query + ); const state = reducer( initialState, { type: TYPES.UPDATE_ITEM_SUCCESS, key: item.id, item, + query, } ); expect( state.items ).toEqual( initialState.items ); @@ -278,6 +310,7 @@ describe( 'crud reducer', () => { expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id ); expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title ); expect( state.data[ 2 ].name ).toEqual( item.name ); + expect( state.requesting[ requestId ] ).toEqual( false ); } ); it( 'should handle CREATE_ITEM_SUCCESS', () => { @@ -286,15 +319,26 @@ describe( 'crud reducer', () => { name: 'Off the hook!', status: 'draft', }; + const query = { + name: 'Off the hook!', + status: 'draft', + }; + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + item.id, + query + ); const state = reducer( defaultState, { type: TYPES.CREATE_ITEM_SUCCESS, key: item.id, item, + query, } ); expect( state.data[ 2 ].name ).toEqual( item.name ); expect( state.data[ 2 ].status ).toEqual( item.status ); + expect( state.requesting[ resourceName ] ).toEqual( false ); } ); it( 'should handle DELETE_ITEM_SUCCESS', () => { @@ -311,6 +355,7 @@ describe( 'crud reducer', () => { 1: { id: 1, name: 'Donkey', status: 'draft' }, 2: { id: 2, name: 'Sauce', status: 'publish' }, }, + requesting: {}, }; const item1Updated: Item = { id: 1, @@ -333,25 +378,35 @@ describe( 'crud reducer', () => { item: item2Updated, force: false, } ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, + item1Updated.id, + true + ); expect( state.errors ).toEqual( initialState.errors ); expect( state.data[ 1 ] ).toEqual( undefined ); expect( state.data[ 2 ].status ).toEqual( 'trash' ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); it( 'should handle DELETE_ITEM_ERROR', () => { const key = 2; - const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, key, - } ); + false + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.DELETE_ITEM_ERROR, key, error, errorType: CRUD_ACTIONS.DELETE_ITEM, + force: false, } ); expect( state.errors[ resourceName ] ).toBe( error ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); } ); diff --git a/packages/js/data/src/crud/test/selectors.ts b/packages/js/data/src/crud/test/selectors.ts index d6f56cc67e9..d6a3ea0b7b4 100644 --- a/packages/js/data/src/crud/test/selectors.ts +++ b/packages/js/data/src/crud/test/selectors.ts @@ -11,7 +11,7 @@ const selectors = createSelectors( { describe( 'crud selectors', () => { it( 'should return methods for the default selectors', () => { - expect( Object.keys( selectors ).length ).toEqual( 8 ); + expect( Object.keys( selectors ).length ).toEqual( 10 ); expect( selectors ).toHaveProperty( 'getProduct' ); expect( selectors ).toHaveProperty( 'getProducts' ); expect( selectors ).toHaveProperty( 'getProductsTotalCount' ); @@ -20,5 +20,7 @@ describe( 'crud selectors', () => { expect( selectors ).toHaveProperty( 'getProductCreateError' ); expect( selectors ).toHaveProperty( 'getProductDeleteError' ); expect( selectors ).toHaveProperty( 'getProductUpdateError' ); + expect( selectors ).toHaveProperty( 'hasFinishedRequest' ); + expect( selectors ).toHaveProperty( 'isRequesting' ); } ); } ); diff --git a/packages/js/data/src/crud/test/utils.ts b/packages/js/data/src/crud/test/utils.ts index 1fb371c9369..d034c6fa69b 100644 --- a/packages/js/data/src/crud/test/utils.ts +++ b/packages/js/data/src/crud/test/utils.ts @@ -1,13 +1,18 @@ /** * Internal dependencies */ +import CRUD_ACTIONS from '../crud-actions'; import { applyNamespace, cleanQuery, + getGenericActionName, getKey, getNamespaceKeys, + getRequestIdentifier, getRestPath, getUrlParameters, + maybeReplaceIdQuery, + isValidIdQuery, parseId, } from '../utils'; @@ -113,4 +118,104 @@ describe( 'utils', () => { expect( params.other_attribute ).toBe( 'a' ); expect( params.my_attribute ).toBeUndefined(); } ); + + it( 'should get the request identifier with no arguments', () => { + const key = getRequestIdentifier( 'CREATE_ITEM' ); + expect( key ).toBe( 'CREATE_ITEM/[]' ); + } ); + + it( 'should get the request identifier with a single argument', () => { + const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg' ); + expect( key ).toBe( 'CREATE_ITEM/["string_arg"]' ); + } ); + + it( 'should get the request identifier with multiple arguments', () => { + const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', { + object_property: 'object_value', + } ); + expect( key ).toBe( + 'CREATE_ITEM/["string_arg","{"object_property":"object_value"}"]' + ); + } ); + + it( 'should sort object properties in the request identifier', () => { + const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', { + b: '2', + a: '1', + } ); + expect( key ).toBe( 'CREATE_ITEM/["string_arg","{"a":"1","b":"2"}"]' ); + } ); + + it( 'should directly return the action when the action does not match the resource name', () => { + const genercActionName = getGenericActionName( + 'createNonThing', + 'Thing' + ); + expect( genercActionName ).toBe( 'createNonThing' ); + } ); + + it( 'should get the generic create action name based on resource name', () => { + const genercActionName = getGenericActionName( 'createThing', 'Thing' ); + expect( genercActionName ).toBe( CRUD_ACTIONS.CREATE_ITEM ); + } ); + + it( 'should get the generic delete action name based on resource name', () => { + const genercActionName = getGenericActionName( 'deleteThing', 'Thing' ); + expect( genercActionName ).toBe( CRUD_ACTIONS.DELETE_ITEM ); + } ); + + it( 'should get the generic update action name based on resource name', () => { + const genercActionName = getGenericActionName( 'updateThing', 'Thing' ); + expect( genercActionName ).toBe( CRUD_ACTIONS.UPDATE_ITEM ); + } ); + + it( 'should return false when a valid ID query is not given', () => { + expect( isValidIdQuery( { some: 'data' }, '/my/namespace' ) ).toBe( + false + ); + } ); + + it( 'should return true when a valid ID is passed in an object', () => { + expect( isValidIdQuery( { id: 22 }, '/my/namespace' ) ).toBe( true ); + } ); + + it( 'should return true when a valid ID is passed directly', () => { + expect( isValidIdQuery( 22, '/my/namespace' ) ).toBe( true ); + } ); + + it( 'should return false when additional non-ID properties are provided', () => { + expect( isValidIdQuery( { id: 22, other: 88 }, '/my/namespace' ) ).toBe( + false + ); + } ); + + it( 'should return true when namespace ID properties are provided', () => { + expect( + isValidIdQuery( + { id: 22, parent_id: 88 }, + '/my/{parent_id}/namespace/' + ) + ).toBe( true ); + } ); + + it( 'should replace the first argument when a valid ID query exists', () => { + const args = [ { id: 22, parent_id: 88 }, 'second' ]; + const sanitizedArgs = maybeReplaceIdQuery( + args, + '/my/{parent_id}/namespace/' + ); + expect( sanitizedArgs ).toEqual( [ '88/22', 'second' ] ); + } ); + + it( 'should remain unchanged when the first argument is a string or number', () => { + const args = [ 'first', 'second' ]; + const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' ); + expect( sanitizedArgs ).toEqual( args ); + } ); + + it( 'should remain unchanged when the first argument is not a valid ID query', () => { + const args = [ { id: 22, parent_id: 88 }, 'second' ]; + const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' ); + expect( sanitizedArgs ).toEqual( args ); + } ); } ); diff --git a/packages/js/data/src/crud/utils.ts b/packages/js/data/src/crud/utils.ts index 09147c8b027..97e8d1e57d7 100644 --- a/packages/js/data/src/crud/utils.ts +++ b/packages/js/data/src/crud/utils.ts @@ -6,6 +6,7 @@ import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ +import CRUD_ACTIONS from './crud-actions'; import { IdQuery, IdType, ItemQuery } from './types'; /** @@ -148,6 +149,52 @@ export const getUrlParameters = ( return params; }; +/** + * Check to see if an argument is a valid type of ID query. + * + * @param arg Unknow argument to check. + * @param namespace The namespace string + * @return boolean + */ +export const isValidIdQuery = ( arg: unknown, namespace: string ) => { + if ( typeof arg === 'string' || typeof arg === 'number' ) { + return true; + } + + const validKeys = [ 'id', ...getNamespaceKeys( namespace ) ]; + + if ( + arg && + typeof arg === 'object' && + arg.hasOwnProperty( 'id' ) && + JSON.stringify( validKeys.sort() ) === + JSON.stringify( Object.keys( arg ).sort() ) + ) { + return true; + } + + return false; +}; + +/** + * Replace the initial argument with a key if it's a valid ID query. + * + * @param args Args to check. + * @param namespace Namespace. + * @return Sanitized arguments. + */ +export const maybeReplaceIdQuery = ( args: unknown[], namespace: string ) => { + const [ firstArgument, ...rest ] = args; + if ( ! firstArgument || ! isValidIdQuery( firstArgument, namespace ) ) { + return args; + } + + const urlParameters = getUrlParameters( namespace, firstArgument ); + const { key } = parseId( firstArgument as IdQuery, urlParameters ); + + return [ key, ...rest ]; +}; + /** * Clean a query of all namespaced params. * @@ -168,3 +215,46 @@ export const cleanQuery = ( return cleaned; }; + +/** + * Get the identifier for a request provided its arguments. + * + * @param name Name of action or selector. + * @param args Arguments for the request. + * @return Key to identify the request. + */ +export const getRequestIdentifier = ( name: string, ...args: unknown[] ) => { + const suffix = JSON.stringify( + args.map( ( arg ) => { + if ( typeof arg === 'object' && arg !== null ) { + return JSON.stringify( arg, Object.keys( arg ).sort() ); + } + return arg; + } ) + ).replace( /\\"/g, '"' ); + + return name + '/' + suffix; +}; + +/** + * Get a generic action name from a resource action name if one exists. + * + * @param action Action name to check. + * @param resourceName Resurce name. + * @return Generic action name if one exists, otherwise the passed action name. + */ +export const getGenericActionName = ( + action: string, + resourceName: string +) => { + switch ( action ) { + case `create${ resourceName }`: + return CRUD_ACTIONS.CREATE_ITEM; + case `delete${ resourceName }`: + return CRUD_ACTIONS.DELETE_ITEM; + case `update${ resourceName }`: + return CRUD_ACTIONS.UPDATE_ITEM; + } + + return action; +}; From 497119b62273eda5a1b15d5367f81e37ad7508ab Mon Sep 17 00:00:00 2001 From: Mahdi Taleghani Date: Mon, 16 Jan 2023 21:23:55 +0330 Subject: [PATCH 11/58] fixing a typo in i18n states file --- plugins/woocommerce/i18n/states.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce/i18n/states.php b/plugins/woocommerce/i18n/states.php index b43d34cd698..d2cf0663755 100644 --- a/plugins/woocommerce/i18n/states.php +++ b/plugins/woocommerce/i18n/states.php @@ -815,7 +815,7 @@ return array( 'LD' => __( 'Lakshadeep', 'woocommerce' ), 'PY' => __( 'Pondicherry (Puducherry)', 'woocommerce' ), ), - 'IR' => array( // Irania states. + 'IR' => array( // Iranian states. 'KHZ' => __( 'Khuzestan (خوزستان)', 'woocommerce' ), 'THR' => __( 'Tehran (تهران)', 'woocommerce' ), 'ILM' => __( 'Ilaam (ایلام)', 'woocommerce' ), From 3719b62dad02f09755df14929e9a3dcd5213d608 Mon Sep 17 00:00:00 2001 From: Priyanka Behera Date: Tue, 17 Jan 2023 01:01:48 +0530 Subject: [PATCH 12/58] Fixed - woocommerce_order_tracking shortcode causes fatal error if a refund ID is entered #31760 issue (#33735) * Fixed #31760 issue * additional space removal after && * Make sure tracking shortcode only tracks orders of type `WC_Order` * Add changelog Co-authored-by: Jorge A. Torres --- plugins/woocommerce/changelog/fix-31760 | 4 ++++ .../includes/shortcodes/class-wc-shortcode-order-tracking.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/fix-31760 diff --git a/plugins/woocommerce/changelog/fix-31760 b/plugins/woocommerce/changelog/fix-31760 new file mode 100644 index 00000000000..e389925f1a9 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-31760 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Make sure the tracking shortcode only operates in orders with billing information. diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php index 6798539ae82..2151c69e9ab 100644 --- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php +++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php @@ -51,7 +51,7 @@ class WC_Shortcode_Order_Tracking { } else { $order = wc_get_order( apply_filters( 'woocommerce_shortcode_order_tracking_order_id', $order_id ) ); - if ( $order && $order->get_id() && strtolower( $order->get_billing_email() ) === strtolower( $order_email ) ) { + if ( $order && $order->get_id() && is_a( $order, 'WC_Order' ) && strtolower( $order->get_billing_email() ) === strtolower( $order_email ) ) { do_action( 'woocommerce_track_order', $order->get_id() ); wc_get_template( 'order/tracking.php', From 8fc9c5cc0973816740fd9adec0988d646c2530c8 Mon Sep 17 00:00:00 2001 From: louwie17 Date: Mon, 16 Jan 2023 16:50:30 -0400 Subject: [PATCH 13/58] Add basic fields for rendering of Product MVP (#36392) --- .../changelog/add-36074_basic_fields | 4 + packages/js/components/src/index.ts | 1 + .../src/product-fields/api/render.tsx | 9 +- .../fields/basic-select-control/index.ts | 15 +++ .../fields/basic-select-control/render.tsx | 34 +++++++ .../product-fields/fields/checkbox/index.ts | 15 +++ .../product-fields/fields/checkbox/render.tsx | 28 ++++++ .../src/product-fields/fields/index.ts | 29 ++++++ .../src/product-fields/fields/radio/index.ts | 15 +++ .../product-fields/fields/radio/render.tsx | 34 +++++++ .../src/product-fields/fields/text/index.ts | 15 +++ .../src/product-fields/fields/text/render.tsx | 24 +++++ .../src/product-fields/fields/toggle/index.ts | 15 +++ .../product-fields/fields/toggle/render.tsx | 40 ++++++++ .../src/product-fields/fields/types.ts | 6 ++ .../js/components/src/product-fields/index.ts | 1 + .../src/product-fields/store/types.ts | 4 +- .../src/product-fields/stories/index.tsx | 97 ++++++++++++++----- 18 files changed, 357 insertions(+), 29 deletions(-) create mode 100644 packages/js/components/changelog/add-36074_basic_fields create mode 100644 packages/js/components/src/product-fields/fields/basic-select-control/index.ts create mode 100644 packages/js/components/src/product-fields/fields/basic-select-control/render.tsx create mode 100644 packages/js/components/src/product-fields/fields/checkbox/index.ts create mode 100644 packages/js/components/src/product-fields/fields/checkbox/render.tsx create mode 100644 packages/js/components/src/product-fields/fields/index.ts create mode 100644 packages/js/components/src/product-fields/fields/radio/index.ts create mode 100644 packages/js/components/src/product-fields/fields/radio/render.tsx create mode 100644 packages/js/components/src/product-fields/fields/text/index.ts create mode 100644 packages/js/components/src/product-fields/fields/text/render.tsx create mode 100644 packages/js/components/src/product-fields/fields/toggle/index.ts create mode 100644 packages/js/components/src/product-fields/fields/toggle/render.tsx create mode 100644 packages/js/components/src/product-fields/fields/types.ts diff --git a/packages/js/components/changelog/add-36074_basic_fields b/packages/js/components/changelog/add-36074_basic_fields new file mode 100644 index 00000000000..cc93a0ca6c4 --- /dev/null +++ b/packages/js/components/changelog/add-36074_basic_fields @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add 6 basic fields to the product fields registry for use in extensibility within the new Product MVP. diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts index 8330ed8b4f2..3430cb01281 100644 --- a/packages/js/components/src/index.ts +++ b/packages/js/components/src/index.ts @@ -87,3 +87,4 @@ export { CollapsibleContent } from './collapsible-content'; export { createOrderedChildren, sortFillsByOrder } from './utils'; export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item'; export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item'; +export * from './product-fields'; diff --git a/packages/js/components/src/product-fields/api/render.tsx b/packages/js/components/src/product-fields/api/render.tsx index 5fc727f08af..4ef208cad63 100644 --- a/packages/js/components/src/product-fields/api/render.tsx +++ b/packages/js/components/src/product-fields/api/render.tsx @@ -3,6 +3,10 @@ */ import { select } from '@wordpress/data'; import { createElement } from '@wordpress/element'; +import { + // @ts-expect-error `__experimentalInputControl` does exist. + __experimentalInputControl as InputControl, +} from '@wordpress/components'; /** * Internal dependencies @@ -19,10 +23,7 @@ export function renderField( name: string, props: Record< string, any > ) { return ; } if ( fieldConfig.type ) { - return createElement( 'input', { - type: fieldConfig.type, - ...props, - } ); + return ; } return null; } diff --git a/packages/js/components/src/product-fields/fields/basic-select-control/index.ts b/packages/js/components/src/product-fields/fields/basic-select-control/index.ts new file mode 100644 index 00000000000..178d735e860 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/basic-select-control/index.ts @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { ComponentType } from 'react'; + +/** + * Internal dependencies + */ +import { ProductFieldDefinition } from '../../store/types'; +import render from './render'; + +export const basicSelectControlSettings: ProductFieldDefinition = { + name: 'basic-select-control', + render: render as ComponentType, +}; diff --git a/packages/js/components/src/product-fields/fields/basic-select-control/render.tsx b/packages/js/components/src/product-fields/fields/basic-select-control/render.tsx new file mode 100644 index 00000000000..b108cfb9b3d --- /dev/null +++ b/packages/js/components/src/product-fields/fields/basic-select-control/render.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { RadioControl, SelectControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { BaseProductFieldProps } from '../types'; + +type SelectControlFieldProps = BaseProductFieldProps< string | string[] > & { + multiple?: boolean; + options: SelectControl.Option[]; +}; +const SelectControlField: React.FC< SelectControlFieldProps > = ( { + label, + value, + onChange, + multiple, + options = [], +} ) => { + return ( + + ); +}; + +export default SelectControlField; diff --git a/packages/js/components/src/product-fields/fields/checkbox/index.ts b/packages/js/components/src/product-fields/fields/checkbox/index.ts new file mode 100644 index 00000000000..b1f0d038176 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/checkbox/index.ts @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { ComponentType } from 'react'; + +/** + * Internal dependencies + */ +import { ProductFieldDefinition } from '../../store/types'; +import render from './render'; + +export const checkboxSettings: ProductFieldDefinition = { + name: 'checkbox', + render: render as ComponentType, +}; diff --git a/packages/js/components/src/product-fields/fields/checkbox/render.tsx b/packages/js/components/src/product-fields/fields/checkbox/render.tsx new file mode 100644 index 00000000000..4a82e8e3b19 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/checkbox/render.tsx @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { CheckboxControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { BaseProductFieldProps } from '../types'; + +type CheckboxFieldProps = BaseProductFieldProps< boolean >; + +const CheckboxField: React.FC< CheckboxFieldProps > = ( { + label, + value, + onChange, +} ) => { + return ( + + ); +}; + +export default CheckboxField; diff --git a/packages/js/components/src/product-fields/fields/index.ts b/packages/js/components/src/product-fields/fields/index.ts new file mode 100644 index 00000000000..2ccb3c83653 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/index.ts @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import { registerProductField } from '../api'; +import { ProductFieldDefinition } from '../store/types'; +import { basicSelectControlSettings } from './basic-select-control'; +import { checkboxSettings } from './checkbox'; +import { radioSettings } from './radio'; +import { textSettings } from './text'; +import { toggleSettings } from './toggle'; + +const getAllProductFields = (): ProductFieldDefinition[] => + [ + ...[ 'number' ].map( ( type ) => ( { + name: type, + type, + } ) ), + textSettings, + toggleSettings, + radioSettings, + basicSelectControlSettings, + checkboxSettings, + ].filter( Boolean ); + +export const registerCoreProductFields = ( fields = getAllProductFields() ) => { + fields.forEach( ( field ) => { + registerProductField( field.name, field ); + } ); +}; diff --git a/packages/js/components/src/product-fields/fields/radio/index.ts b/packages/js/components/src/product-fields/fields/radio/index.ts new file mode 100644 index 00000000000..db56c058126 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/radio/index.ts @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { ComponentType } from 'react'; + +/** + * Internal dependencies + */ +import { ProductFieldDefinition } from '../../store/types'; +import render from './render'; + +export const radioSettings: ProductFieldDefinition = { + name: 'radio', + render: render as ComponentType, +}; diff --git a/packages/js/components/src/product-fields/fields/radio/render.tsx b/packages/js/components/src/product-fields/fields/radio/render.tsx new file mode 100644 index 00000000000..0577f10fe44 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/radio/render.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { RadioControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { BaseProductFieldProps } from '../types'; + +type RadioFieldProps = BaseProductFieldProps< string > & { + options: { + label: string; + value: string; + }[]; +}; +const RadioField: React.FC< RadioFieldProps > = ( { + label, + value, + onChange, + options = [], +} ) => { + return ( + + ); +}; + +export default RadioField; diff --git a/packages/js/components/src/product-fields/fields/text/index.ts b/packages/js/components/src/product-fields/fields/text/index.ts new file mode 100644 index 00000000000..5dd23abeb02 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/text/index.ts @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { ComponentType } from 'react'; + +/** + * Internal dependencies + */ +import { ProductFieldDefinition } from '../../store/types'; +import render from './render'; + +export const textSettings: ProductFieldDefinition = { + name: 'text', + render: render as ComponentType, +}; diff --git a/packages/js/components/src/product-fields/fields/text/render.tsx b/packages/js/components/src/product-fields/fields/text/render.tsx new file mode 100644 index 00000000000..ca441faeac5 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/text/render.tsx @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { TextControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { BaseProductFieldProps } from '../types'; + +type TextFieldProps = BaseProductFieldProps< string >; + +const TextField: React.FC< TextFieldProps > = ( { + label, + value, + onChange, +} ) => { + return ( + + ); +}; + +export default TextField; diff --git a/packages/js/components/src/product-fields/fields/toggle/index.ts b/packages/js/components/src/product-fields/fields/toggle/index.ts new file mode 100644 index 00000000000..22d2aa2dd0f --- /dev/null +++ b/packages/js/components/src/product-fields/fields/toggle/index.ts @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { ComponentType } from 'react'; + +/** + * Internal dependencies + */ +import { ProductFieldDefinition } from '../../store/types'; +import render from './render'; + +export const toggleSettings: ProductFieldDefinition = { + name: 'toggle', + render: render as ComponentType, +}; diff --git a/packages/js/components/src/product-fields/fields/toggle/render.tsx b/packages/js/components/src/product-fields/fields/toggle/render.tsx new file mode 100644 index 00000000000..bbfa33c6e17 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/toggle/render.tsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { createElement, Fragment } from '@wordpress/element'; +import { ToggleControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { BaseProductFieldProps } from '../types'; +import { Tooltip } from '../../../tooltip'; + +type ToggleFieldProps = BaseProductFieldProps< boolean > & { + tooltip?: string; +}; +const ToggleField: React.FC< ToggleFieldProps > = ( { + label, + value, + onChange, + tooltip, + disabled = false, +} ) => { + return ( + + { label } + { tooltip && } + + } + checked={ value } + onChange={ onChange } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore disabled prop exists + disabled={ disabled } + /> + ); +}; + +export default ToggleField; diff --git a/packages/js/components/src/product-fields/fields/types.ts b/packages/js/components/src/product-fields/fields/types.ts new file mode 100644 index 00000000000..c8ed482089b --- /dev/null +++ b/packages/js/components/src/product-fields/fields/types.ts @@ -0,0 +1,6 @@ +export type BaseProductFieldProps< T > = { + value: T; + onChange: ( value: T ) => void; + label: string; + disabled?: boolean; +}; diff --git a/packages/js/components/src/product-fields/index.ts b/packages/js/components/src/product-fields/index.ts index a28dcc4ff74..30d0c89835a 100644 --- a/packages/js/components/src/product-fields/index.ts +++ b/packages/js/components/src/product-fields/index.ts @@ -1,2 +1,3 @@ export { store } from './store'; export * from './api'; +export * from './fields'; diff --git a/packages/js/components/src/product-fields/store/types.ts b/packages/js/components/src/product-fields/store/types.ts index c5de06cb663..ebab49c0509 100644 --- a/packages/js/components/src/product-fields/store/types.ts +++ b/packages/js/components/src/product-fields/store/types.ts @@ -1,11 +1,11 @@ /** * External dependencies */ -import { ComponentType } from 'react'; +import { ComponentType, HTMLInputTypeAttribute } from 'react'; export type ProductFieldDefinition = { name: string; - type?: string; + type?: HTMLInputTypeAttribute; // eslint-disable-next-line @typescript-eslint/no-explicit-any render?: ComponentType; }; diff --git a/packages/js/components/src/product-fields/stories/index.tsx b/packages/js/components/src/product-fields/stories/index.tsx index 04496d889ca..4abf6631d54 100644 --- a/packages/js/components/src/product-fields/stories/index.tsx +++ b/packages/js/components/src/product-fields/stories/index.tsx @@ -3,56 +3,92 @@ */ import React from 'react'; import { useState, createElement } from '@wordpress/element'; -import { createRegistry, RegistryProvider, select } from '@wordpress/data'; -import { - // @ts-expect-error `__experimentalInputControl` does exist. - __experimentalInputControl as InputControl, -} from '@wordpress/components'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; /** * Internal dependencies */ import { store } from '../store'; -import { registerProductField, renderField } from '../api'; +import { renderField } from '../api'; +import { registerCoreProductFields } from '../fields'; const registry = createRegistry(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore No types for this exist yet. registry.register( store ); -registerProductField( 'text', { - name: 'text', - render: ( props ) => { - return ; - }, -} ); +registerCoreProductFields(); -registerProductField( 'number', { - name: 'number', - render: () => { - return ; +const fieldConfigs = [ + { + name: 'text-field', + type: 'text', + label: 'Text field', }, -} ); + { + name: 'number-field', + type: 'number', + label: 'Number field', + }, + { + name: 'toggle-field', + type: 'toggle', + label: 'Toggle field', + }, + { + name: 'checkbox-field', + type: 'checkbox', + label: 'Checkbox field', + }, + { + name: 'radio-field', + type: 'radio', + label: 'Radio field', + options: [ + { label: 'Option', value: 'option' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, + ], + }, + { + name: 'basic-select-control-field', + type: 'basic-select-control', + label: 'Basic select control field', + options: [ + { label: 'Option', value: 'option' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, + ], + }, +]; const RenderField = () => { - const fields: string[] = select( store ).getRegisteredProductFields(); const [ selectedField, setSelectedField ] = useState( - fields ? fields[ 0 ] : undefined + fieldConfigs[ 0 ].name || undefined ); + const [ value, setValue ] = useState(); const handleChange = ( event ) => { setSelectedField( event.target.value ); }; + const selectedFieldConfig = fieldConfigs.find( + ( f ) => f.name === selectedField + ); return (
- { selectedField && renderField( selectedField, { name: 'test' } ) } + { selectedFieldConfig && + renderField( selectedFieldConfig.type, { + value, + onChange: setValue, + ...selectedFieldConfig, + } ) }
); }; @@ -65,6 +101,21 @@ export const Basic: React.FC = () => { ); }; +export const ToggleWithTooltip: React.FC = () => { + const [ value, setValue ] = useState(); + return ( + + { renderField( 'toggle', { + value, + onChange: setValue, + name: 'toggle', + label: 'Toggle with Tooltip', + tooltip: 'This is a sample tooltip', + } ) } + + ); +}; + export default { title: 'WooCommerce Admin/experimental/product-fields', component: Basic, From 6377314b1bb9f516e7e146bdd66a19702a3437df Mon Sep 17 00:00:00 2001 From: Willington Vega Date: Mon, 16 Jan 2023 21:47:00 -0500 Subject: [PATCH 14/58] Remove deprecated usage of `${var}` syntax in strings (#36439) * issue-35763/fix-php-8.2-deprecation-warnings * Declare $mockable_functions property * Declare $mockable_classes property * Fix deprecated usage of ${var} in strings * Add changelog file * Avoid using interpolation to create SQL statement We could ignore the PHPCS error. However, ignoring the error leaves PHPCS unable to detect future changes that may introduce unsafe interpolation. I think the more verbose approach is the safest approach in this case. * Ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared There doesn't seem to be a way to use a variable name for the name of the table without triggering a PHPCS error. * Avoid interpolated passing interpolated variables to __() * End inline comments with a full-stop --- .../fix-deprecated-usage-of-var-syntax-in-strings | 4 ++++ .../settings/class-wc-settings-payment-gateways.php | 2 +- .../admin/views/html-admin-page-status-tools.php | 2 +- plugins/woocommerce/includes/wc-core-functions.php | 7 +++++-- .../src/Admin/API/Reports/Orders/DataStore.php | 2 +- .../src/Admin/API/Reports/Orders/Stats/DataStore.php | 7 +++++-- .../Admin/API/Reports/Products/Stats/DataStore.php | 2 +- .../Admin/API/Reports/Variations/Stats/DataStore.php | 2 +- .../src/Admin/Features/Navigation/Menu.php | 4 ++-- .../Orders/CustomOrdersTableController.php | 12 ++++++++++-- .../src/Internal/DownloadPermissionsAdjuster.php | 2 +- .../src/Internal/Features/FeaturesController.php | 2 +- plugins/woocommerce/src/Utilities/FeaturesUtil.php | 2 +- .../Tools/CodeHacking/Hacks/FunctionsMockerHack.php | 7 +++++++ .../Tools/CodeHacking/Hacks/StaticMockerHack.php | 6 ++++++ .../admin/class-wc-admin-dashboard-setup-test.php | 4 ++-- .../includes/settings/class-wc-settings-example.php | 2 +- .../Internal/Traits/AccessiblePrivateMethodsTest.php | 4 ++-- tools/monorepo/check-changelogger-use.php | 4 ++-- 19 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings diff --git a/plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings b/plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings new file mode 100644 index 00000000000..16c76f1a5dc --- /dev/null +++ b/plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix deprecated usage of ${var} in strings diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php index a519ce26148..809057cc6f3 100644 --- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php +++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php @@ -267,7 +267,7 @@ class WC_Settings_Payment_Gateways extends WC_Settings_Page { foreach ( $plugin_suggestions as $plugin_suggestion ) { $alt = str_replace( '.png', '', basename( $plugin_suggestion->image_72x72 ) ); // phpcs:ignore - echo "${alt}"; + echo "{$alt}"; } echo '& more.'; } diff --git a/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php b/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php index 31489a6ccdd..8da221e34ff 100644 --- a/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php +++ b/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php @@ -39,7 +39,7 @@ foreach ( $tools as $action_name => $tool ) { echo wp_kses_post( $selector['description'] ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo "  "; + echo "  "; } ?>

diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php index 18319fc1230..d4bbe5c944f 100644 --- a/plugins/woocommerce/includes/wc-core-functions.php +++ b/plugins/woocommerce/includes/wc-core-functions.php @@ -1701,8 +1701,11 @@ function wc_get_shipping_method_count( $include_legacy = false, $enabled_only = return absint( $transient_value['value'] ); } - $where_clause = $enabled_only ? 'WHERE is_enabled=1' : ''; - $method_count = absint( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_shipping_zone_methods ${where_clause}" ) ); + if ( $enabled_only ) { + $method_count = absint( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_shipping_zone_methods WHERE is_enabled=1" ) ); + } else { + $method_count = absint( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_shipping_zone_methods" ) ); + } if ( $include_legacy ) { // Count activated methods that don't support shipping zones. diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php index b3bc39a3048..741e2a5d2d1 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php @@ -118,7 +118,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { if ( $query_args['customer_type'] ) { $returning_customer = 'returning' === $query_args['customer_type'] ? 1 : 0; - $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = ${returning_customer}"; + $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}"; } $refund_subquery = $this->get_refund_subquery( $query_args ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php index b4661107704..b2ef6217df0 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php @@ -378,7 +378,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } @@ -697,7 +697,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $wpdb->query( $wpdb->prepare( - "UPDATE ${orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d", + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // TODO: use the %i placeholder to prepare the table name when available in the the minimum required WordPress version. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d", $order_id, $customer_id ) diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php index ec3513938fb..6883d35a891 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php @@ -188,7 +188,7 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php index 5ea01ef6cd8..0b03d0df6ce 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php @@ -226,7 +226,7 @@ class DataStore extends VariationsDataStore implements DataStoreInterface { $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php b/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php index 35ef7c80b9d..27d540fb6e2 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php @@ -463,10 +463,10 @@ class Menu { ? "&post_type={$taxonomy_object->object_type[0]}" : ''; $match_expression = 'term.php'; // Match term.php pages. - $match_expression .= "(?=.*[?|&]taxonomy=${taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. + $match_expression .= "(?=.*[?|&]taxonomy={$taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. $match_expression .= '|'; // Or. $match_expression .= 'edit-tags.php'; // Match edit-tags.php pages. - $match_expression .= "(?=.*[?|&]taxonomy=${taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. + $match_expression .= "(?=.*[?|&]taxonomy={$taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. return array( 'default' => array_merge( diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php index d38305c2e8c..3ceeb399229 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php @@ -146,7 +146,11 @@ class CustomOrdersTableController { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; wc_doing_it_wrong( $class_and_method, - __( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ), + sprintf( + // translators: %1$s the name of the class and method used. + __( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ), + $class_and_method + ), '7.0' ); } @@ -160,7 +164,11 @@ class CustomOrdersTableController { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; wc_doing_it_wrong( $class_and_method, - __( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ), + sprintf( + // translators: %1$s the name of the class and method used. + __( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ), + $class_and_method + ), '7.0' ); } diff --git a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php index c2bbac1c030..a30c504e63e 100644 --- a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php +++ b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php @@ -154,7 +154,7 @@ class DownloadPermissionsAdjuster { 'file' => $file, 'data' => (array) $permission->data, ); - $result['permission_data_by_file_order_user'][ "${file}:${permission_data['user_id']}:${permission_data['order_id']}" ] = $data; + $result['permission_data_by_file_order_user'][ "{$file}:{$permission_data['user_id']}:{$permission_data['order_id']}" ] = $data; } } diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php index 5fe17a80970..9adc5508ce6 100644 --- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php +++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php @@ -404,7 +404,7 @@ class FeaturesController { return NewProductManagementExperience::TOGGLE_OPTION_NAME; } - return "woocommerce_feature_${feature_id}_enabled"; + return "woocommerce_feature_{$feature_id}_enabled"; } /** diff --git a/plugins/woocommerce/src/Utilities/FeaturesUtil.php b/plugins/woocommerce/src/Utilities/FeaturesUtil.php index 6acf0f29918..0eedde641f4 100644 --- a/plugins/woocommerce/src/Utilities/FeaturesUtil.php +++ b/plugins/woocommerce/src/Utilities/FeaturesUtil.php @@ -59,7 +59,7 @@ class FeaturesUtil { if ( ! $plugin_id ) { $logger = wc_get_logger(); - $logger->error( "FeaturesUtil::declare_compatibility: ${plugin_file} is not a known WordPress plugin." ); + $logger->error( "FeaturesUtil::declare_compatibility: {$plugin_file} is not a known WordPress plugin." ); return false; } diff --git a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php index 91f6b3e535a..cf3fc83c6ab 100644 --- a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php +++ b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php @@ -35,6 +35,13 @@ use ReflectionClass; * executed inside tests (and thus the above example won't stack-overflow). */ final class FunctionsMockerHack extends CodeHack { + /** + * An array containing the names of the functions that will become mockable. + * + * @var array + */ + private $mockable_functions; + /** * Tokens that precede a non-standalone-function identifier. * diff --git a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php index 61fd4a68d90..65b5b334aca 100644 --- a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php +++ b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php @@ -35,6 +35,12 @@ namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks; * executed inside tests (and thus the above example won't stack-overflow). */ final class StaticMockerHack extends CodeHack { + /** + * An associative array of class name => array of class methods. + * + * @var array + */ + private $mockable_classes; /** * @var StaticMockerHack Holds the only existing instance of the class. diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php index 1ef4c8c4ae6..637f4cdeaf5 100644 --- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php +++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php @@ -165,7 +165,7 @@ class WC_Admin_Dashboard_Setup_Test extends WC_Unit_Test_Case { ); foreach ( $required_strings as $required_string ) { - $this->assertRegexp( "/${required_string}/", $html ); + $this->assertRegexp( "/{$required_string}/", $html ); } } @@ -191,7 +191,7 @@ class WC_Admin_Dashboard_Setup_Test extends WC_Unit_Test_Case { if ( $completed_tasks_count === $tasks_count ) { $this->assertEmpty( $this->get_widget_output() ); } else { - $this->assertRegexp( "/Step ${step_number} of 6/", $this->get_widget_output() ); + $this->assertRegexp( "/Step {$step_number} of 6/", $this->get_widget_output() ); } } } diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php index 49a0bfbf965..a685a6f514c 100644 --- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php +++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php @@ -26,7 +26,7 @@ class WC_Settings_Example extends WC_Settings_Page { } protected function get_settings_for_section_core( $section_id ) { - return array( "${section_id}_key" => "${section_id}_value" ); + return array( "{$section_id}_key" => "{$section_id}_value" ); } protected function get_own_sections() { diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php index 32a14ea7297..31679b6ac25 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php @@ -419,8 +419,8 @@ class AccessiblePrivateMethodsTest extends \WC_Unit_Test_Case { use AccessiblePrivateMethods; }; - $method_name = "add_${action_or_filter}"; - $proper_method_name = "add_static_${action_or_filter}"; + $method_name = "add_{$action_or_filter}"; + $proper_method_name = "add_static_{$action_or_filter}"; $this->expectException( \Error::class ); $this->expectExceptionMessage( get_class( $sut ) . '::' . "$method_name can't be called statically, did you mean '$proper_method_name'?" ); diff --git a/tools/monorepo/check-changelogger-use.php b/tools/monorepo/check-changelogger-use.php index b2819ef2a48..b9f07dd3c83 100644 --- a/tools/monorepo/check-changelogger-use.php +++ b/tools/monorepo/check-changelogger-use.php @@ -70,9 +70,9 @@ if ( $verbose ) { */ function debug( ...$args ) { if ( getenv( 'CI' ) ) { - $args[0] = "\e[34m${args[0]}\e[0m\n"; + $args[0] = "\e[34m{$args[0]}\e[0m\n"; } else { - $args[0] = "\e[1;30m${args[0]}\e[0m\n"; + $args[0] = "\e[1;30m{$args[0]}\e[0m\n"; } fprintf( STDERR, ...$args ); } From ea64a98f54b1859f161b5d9a538de06c88a5fbb1 Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Tue, 17 Jan 2023 00:34:08 -0800 Subject: [PATCH 15/58] Extract attribute filtering and fetching logic out of attribute components (#36354) * Move attribution fetching to separate hook * Add changelog entry * Set all attributes on update of subset of attributes * Move filtering logic to hook * Remove tests that filter attribute by type inside the component * Rename AttributeField to AttributeControl and props from attributes to value --- .../add-attribute-modal.scss | 0 .../add-attribute-modal.tsx | 10 +- .../attribute-control.tsx} | 149 ++++-------------- .../attribute-field.scss | 0 .../edit-attribute-modal.scss | 0 .../edit-attribute-modal.tsx | 18 +-- .../fields/attribute-control/index.ts | 1 + .../test/add-attribute-modal.spec.tsx | 0 .../test/attribute-field.spec.tsx | 29 ++-- .../test/utils.spec.ts | 0 .../utils.ts | 0 .../products/fields/attribute-field/index.ts | 1 - .../attribute-input-field.tsx | 4 +- .../products/fields/attributes/attributes.tsx | 16 +- .../products/fields/options/options.tsx | 24 ++- .../products/hooks/use-product-attributes.ts | 111 +++++++++++++ plugins/woocommerce/changelog/update-36304 | 4 + 17 files changed, 201 insertions(+), 166 deletions(-) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/add-attribute-modal.scss (100%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/add-attribute-modal.tsx (97%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field/attribute-field.tsx => attribute-control/attribute-control.tsx} (65%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/attribute-field.scss (100%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/edit-attribute-modal.scss (100%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/edit-attribute-modal.tsx (87%) create mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/test/add-attribute-modal.spec.tsx (100%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/test/attribute-field.spec.tsx (90%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/test/utils.spec.ts (100%) rename plugins/woocommerce-admin/client/products/fields/{attribute-field => attribute-control}/utils.ts (100%) delete mode 100644 plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts create mode 100644 plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts create mode 100644 plugins/woocommerce/changelog/update-36304 diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss rename to plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.scss diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx similarity index 97% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx index cf5c61288c8..ea4c9ae9b2f 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx @@ -26,7 +26,7 @@ import { AttributeTermInputField, CustomAttributeTermInputField, } from '../attribute-term-input-field'; -import { HydratedAttributeType } from '../attribute-field'; +import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes'; import { getProductAttributeObject } from './utils'; type AddAttributeModalProps = { @@ -46,12 +46,12 @@ type AddAttributeModalProps = { confirmCancelLabel?: string; confirmConfirmLabel?: string; onCancel: () => void; - onAdd: ( newCategories: HydratedAttributeType[] ) => void; + onAdd: ( newCategories: EnhancedProductAttribute[] ) => void; selectedAttributeIds?: number[]; }; type AttributeForm = { - attributes: Array< HydratedAttributeType | null >; + attributes: Array< EnhancedProductAttribute | null >; }; export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { @@ -92,7 +92,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { }; const onAddingAttributes = ( values: AttributeForm ) => { - const newAttributesToAdd: HydratedAttributeType[] = []; + const newAttributesToAdd: EnhancedProductAttribute[] = []; values.attributes.forEach( ( attr ) => { if ( attr !== null && @@ -105,7 +105,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { ? ( attr.terms || [] ).map( ( term ) => term.name ) : attr.options; newAttributesToAdd.push( { - ...( attr as HydratedAttributeType ), + ...( attr as EnhancedProductAttribute ), options, } ); } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-control.tsx similarity index 65% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-control.tsx index 7c867af4a0d..01e03c2f89b 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-control.tsx @@ -2,13 +2,8 @@ * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { useState, useCallback, useEffect } from '@wordpress/element'; -import { - ProductAttribute, - EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME, - ProductAttributeTerm, -} from '@woocommerce/data'; -import { resolveSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { ProductAttribute } from '@woocommerce/data'; import { Sortable, __experimentalSelectControlMenuSlot as SelectControlMenuSlot, @@ -24,42 +19,31 @@ import { getAdminLink } from '@woocommerce/settings'; import './attribute-field.scss'; import { AddAttributeModal } from './add-attribute-modal'; import { EditAttributeModal } from './edit-attribute-modal'; +import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes'; import { getAttributeKey, reorderSortableProductAttributePositions, } from './utils'; -import { sift } from '../../../utils'; import { AttributeEmptyState } from '../attribute-empty-state'; import { AddAttributeListItem, AttributeListItem, } from '../attribute-list-item'; -type AttributeFieldProps = { +type AttributeControlProps = { value: ProductAttribute[]; onChange: ( value: ProductAttribute[] ) => void; - productId?: number; // TODO: should we support an 'any' option to show all attributes? attributeType?: 'regular' | 'for-variations'; }; -export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & { - options?: string[]; - terms?: ProductAttributeTerm[]; - visible?: boolean; -}; - -export const AttributeField: React.FC< AttributeFieldProps > = ( { +export const AttributeControl: React.FC< AttributeControlProps > = ( { value, - onChange, - productId, attributeType = 'regular', + onChange, } ) => { const [ showAddAttributeModal, setShowAddAttributeModal ] = useState( false ); - const [ hydratedAttributes, setHydratedAttributes ] = useState< - HydratedAttributeType[] - >( [] ); const [ editingAttributeId, setEditingAttributeId ] = useState< null | string >( null ); @@ -72,73 +56,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ? 'product_add_options_modal_cancel_button_click' : 'product_add_attributes_modal_cancel_button_click'; - const fetchTerms = useCallback( - ( attributeId: number ) => { - return resolveSelect( - EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME - ) - .getProductAttributeTerms< ProductAttributeTerm[] >( { - attribute_id: attributeId, - product: productId, - } ) - .then( - ( attributeTerms ) => { - return attributeTerms; - }, - ( error ) => { - return error; - } - ); - }, - [ productId ] - ); - - useEffect( () => { - // I think we'll need to move the hydration out of the individual component - // instance. To where, I do not yet know... maybe in the form context - // somewhere so that a single hydration source can be shared between multiple - // instances? Something like a simple key-value store in the form context - // would be handy. - if ( ! value || hydratedAttributes.length !== 0 ) { - return; - } - - const [ customAttributes, globalAttributes ]: ProductAttribute[][] = - sift( value, ( attr: ProductAttribute ) => attr.id === 0 ); - - Promise.all( - globalAttributes.map( ( attr ) => fetchTerms( attr.id ) ) - ).then( ( allResults ) => { - setHydratedAttributes( [ - ...globalAttributes.map( ( attr, index ) => { - const fetchedTerms = allResults[ index ]; - - const newAttr = { - ...attr, - // I'm not sure this is quite right for handling unpersisted terms, - // but this gets things kinda working for now - terms: - fetchedTerms.length > 0 ? fetchedTerms : undefined, - options: - fetchedTerms.length === 0 - ? attr.options - : undefined, - }; - - return newAttr; - } ), - ...customAttributes, - ] ); - } ); - }, [ fetchTerms, hydratedAttributes, value ] ); - const fetchAttributeId = ( attribute: { id: number; name: string } ) => `${ attribute.id }-${ attribute.name }`; - const updateAttributes = ( attributes: HydratedAttributeType[] ) => { - setHydratedAttributes( attributes ); + const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => { onChange( - attributes.map( ( attr ) => { + newAttributes.map( ( attr ) => { return { ...attr, options: attr.terms @@ -157,8 +80,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { recordEvent( 'product_remove_attribute_confirmation_confirm_click' ); - updateAttributes( - hydratedAttributes.filter( + handleChange( + value.filter( ( attr ) => fetchAttributeId( attr ) !== fetchAttributeId( attribute ) @@ -169,9 +92,11 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { } }; - const onAddNewAttributes = ( newAttributes: HydratedAttributeType[] ) => { - updateAttributes( [ - ...( hydratedAttributes || [] ), + const onAddNewAttributes = ( + newAttributes: EnhancedProductAttribute[] + ) => { + handleChange( [ + ...( value || [] ), ...newAttributes .filter( ( newAttr ) => @@ -193,18 +118,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { setShowAddAttributeModal( false ); }; - const filteredAttributes = value - ? value.filter( - ( attribute: ProductAttribute ) => - attribute.variation === isOnlyForVariations - ) - : false; - - if ( - ! filteredAttributes || - filteredAttributes.length === 0 || - hydratedAttributes.length === 0 - ) { + if ( ! value.length ) { return ( <> = ( { setShowAddAttributeModal( false ); } } onAdd={ onAddNewAttributes } - selectedAttributeIds={ ( filteredAttributes || [] ).map( - ( attr ) => attr.id - ) } + selectedAttributeIds={ [] } /> ) } @@ -242,9 +154,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ); } - const sortedAttributes = filteredAttributes.sort( - ( a, b ) => a.position - b.position - ); + const sortedAttributes = value.sort( ( a, b ) => a.position - b.position ); + const attributeKeyValues = value.reduce( ( keyValue: Record< number | string, ProductAttribute >, @@ -256,9 +167,9 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { {} as Record< number | string, ProductAttribute > ); - const attribute = hydratedAttributes.find( + const editingAttribute = value.find( ( attr ) => fetchAttributeId( attr ) === editingAttributeId - ) as HydratedAttributeType; + ) as EnhancedProductAttribute; const editAttributeCopy = isOnlyForVariations ? __( @@ -332,15 +243,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { /> ) } - { editingAttributeId && ( + { editingAttribute && ( = ( { } ) } onCancel={ () => setEditingAttributeId( null ) } onEdit={ ( changedAttribute ) => { - const newAttributesSet = [ ...hydratedAttributes ]; + const newAttributesSet = [ ...value ]; const changedAttributeIndex: number = newAttributesSet.findIndex( ( attr ) => attr.id !== 0 @@ -373,10 +282,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { changedAttribute ); - updateAttributes( newAttributesSet ); + handleChange( newAttributesSet ); setEditingAttributeId( null ); } } - attribute={ attribute } + attribute={ editingAttribute } /> ) } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss b/plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-field.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss rename to plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-field.scss diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.scss b/plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.scss rename to plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.scss diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.tsx similarity index 87% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.tsx index deaf03ee225..53da1cec567 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.tsx @@ -18,7 +18,7 @@ import { AttributeTermInputField, CustomAttributeTermInputField, } from '../attribute-term-input-field'; -import { HydratedAttributeType } from './attribute-field'; +import { EnhancedProductAttribute } from '../../hooks/use-product-attributes'; import './edit-attribute-modal.scss'; @@ -36,8 +36,8 @@ type EditAttributeModalProps = { updateAccessibleLabel?: string; updateLabel?: string; onCancel: () => void; - onEdit: ( alteredAttribute: HydratedAttributeType ) => void; - attribute: HydratedAttributeType; + onEdit: ( alteredAttribute: EnhancedProductAttribute ) => void; + attribute: EnhancedProductAttribute; }; export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { @@ -64,7 +64,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { attribute, } ) => { const [ editableAttribute, setEditableAttribute ] = useState< - HydratedAttributeType | undefined + EnhancedProductAttribute | undefined >( { ...attribute } ); const isCustomAttribute = editableAttribute?.id === 0; @@ -84,7 +84,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { } onChange={ ( val ) => setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), name: val, } ) } @@ -102,7 +102,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { attributeId={ editableAttribute?.id } onChange={ ( val ) => { setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), terms: val, } ); } } @@ -115,7 +115,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { value={ editableAttribute?.options } onChange={ ( val ) => { setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), options: val, } ); } } @@ -126,7 +126,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), visible: val, } ) } @@ -148,7 +148,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { isPrimary label={ updateAccessibleLabel } onClick={ () => { - onEdit( editableAttribute as HydratedAttributeType ); + onEdit( editableAttribute as EnhancedProductAttribute ); } } > { updateLabel } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts new file mode 100644 index 00000000000..3220152ee6e --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts @@ -0,0 +1 @@ +export * from './attribute-control'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/add-attribute-modal.spec.tsx similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/test/add-attribute-modal.spec.tsx diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/attribute-field.spec.tsx similarity index 90% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/test/attribute-field.spec.tsx index f4a85ee1c7c..8ba9808e619 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/attribute-field.spec.tsx @@ -8,7 +8,7 @@ import { ProductAttribute } from '@woocommerce/data'; /** * Internal dependencies */ -import { AttributeField } from '../attribute-field'; +import { AttributeControl } from '../attribute-control'; const attributeList: ProductAttribute[] = [ { @@ -102,7 +102,7 @@ jest.mock( '@woocommerce/components', () => ( { }, } ) ); -describe( 'AttributeField', () => { +describe( 'AttributeControl', () => { beforeEach( () => { jest.clearAllMocks(); } ); @@ -110,17 +110,17 @@ describe( 'AttributeField', () => { describe( 'empty state', () => { it( 'should show subtitle and "Add first attribute" button', () => { const { queryByText } = render( - {} } /> + {} } /> ); expect( queryByText( 'No attributes yet' ) ).toBeInTheDocument(); expect( queryByText( 'Add first attribute' ) ).toBeInTheDocument(); } ); } ); - it( 'should render the list of existing attributes', async () => { + it( 'should render the list of all attributes', async () => { act( () => { render( - {} } /> @@ -128,20 +128,20 @@ describe( 'AttributeField', () => { } ); expect( - await screen.findByText( 'No attributes yet' ) + await screen.queryByText( 'No attributes yet' ) ).not.toBeInTheDocument(); expect( - await screen.findByText( attributeList[ 0 ].name ) + await screen.queryByText( attributeList[ 0 ].name ) ).toBeInTheDocument(); expect( await screen.queryByText( attributeList[ 1 ].name ) - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); } ); it( 'should render the first two terms of each option, and show "+ n more" for the rest', async () => { act( () => { render( - {} } attributeType="for-variations" @@ -149,9 +149,6 @@ describe( 'AttributeField', () => { ); } ); - expect( - await screen.queryByText( attributeList[ 0 ].options[ 0 ] ) - ).not.toBeInTheDocument(); expect( await screen.findByText( attributeList[ 1 ].options[ 0 ] ) ).toBeInTheDocument(); @@ -173,7 +170,7 @@ describe( 'AttributeField', () => { jest.spyOn( global, 'confirm' ).mockReturnValueOnce( false ); act( () => { render( - {} } /> @@ -191,7 +188,7 @@ describe( 'AttributeField', () => { act( () => { render( - @@ -211,7 +208,7 @@ describe( 'AttributeField', () => { const onChange = jest.fn(); act( () => { render( - @@ -232,7 +229,7 @@ describe( 'AttributeField', () => { act( () => { render( - diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/utils.spec.ts b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/utils.spec.ts similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/test/utils.spec.ts rename to plugins/woocommerce-admin/client/products/fields/attribute-control/test/utils.spec.ts diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/utils.ts b/plugins/woocommerce-admin/client/products/fields/attribute-control/utils.ts similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/utils.ts rename to plugins/woocommerce-admin/client/products/fields/attribute-control/utils.ts diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts deleted file mode 100644 index 938913f453f..00000000000 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './attribute-field'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx index 7f19b7cdff1..8d3eaf35402 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx @@ -22,12 +22,12 @@ import { * Internal dependencies */ import './attribute-input-field.scss'; -import { HydratedAttributeType } from '../attribute-field'; +import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes'; type NarrowedQueryAttribute = Pick< QueryProductAttribute, 'id' | 'name' >; type AttributeInputFieldProps = { - value?: HydratedAttributeType | null; + value?: EnhancedProductAttribute | null; onChange: ( value?: | Omit< ProductAttribute, 'position' | 'visible' | 'variation' > diff --git a/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx b/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx index 85f905ce06d..ff768ee26c4 100644 --- a/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx @@ -6,7 +6,8 @@ import { ProductAttribute } from '@woocommerce/data'; /** * Internal dependencies */ -import { AttributeField } from '../attribute-field'; +import { AttributeControl } from '../attribute-control'; +import { useProductAttributes } from '~/products/hooks/use-product-attributes'; type AttributesProps = { value: ProductAttribute[]; @@ -19,12 +20,17 @@ export const Attributes: React.FC< AttributesProps > = ( { onChange, productId, } ) => { + const { attributes, handleChange } = useProductAttributes( { + allAttributes: value, + onChange, + productId, + } ); + return ( - ); }; diff --git a/plugins/woocommerce-admin/client/products/fields/options/options.tsx b/plugins/woocommerce-admin/client/products/fields/options/options.tsx index c35fa4e2695..bff9c0ff16a 100644 --- a/plugins/woocommerce-admin/client/products/fields/options/options.tsx +++ b/plugins/woocommerce-admin/client/products/fields/options/options.tsx @@ -7,7 +7,8 @@ import { useFormContext } from '@woocommerce/components'; /** * Internal dependencies */ -import { AttributeField } from '../attribute-field'; +import { AttributeControl } from '../attribute-control'; +import { useProductAttributes } from '~/products/hooks/use-product-attributes'; import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper'; type OptionsProps = { @@ -24,17 +25,24 @@ export const Options: React.FC< OptionsProps > = ( { const { values } = useFormContext< Product >(); const { generateProductVariations } = useProductVariationsHelper(); - const handleChange = async ( attributes: ProductAttribute[] ) => { - onChange( attributes ); - generateProductVariations( { ...values, attributes } ); - }; + const { attributes, handleChange } = useProductAttributes( { + allAttributes: value, + isVariationAttributes: true, + onChange: ( newAttributes ) => { + onChange( newAttributes ); + generateProductVariations( { + ...values, + attributes: newAttributes, + } ); + }, + productId, + } ); return ( - ); }; diff --git a/plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts b/plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts new file mode 100644 index 00000000000..f05dc993994 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import { + EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME, + ProductAttribute, + ProductAttributeTerm, +} from '@woocommerce/data'; +import { resolveSelect } from '@wordpress/data'; +import { useCallback, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { sift } from '../../utils'; + +type useProductAttributesProps = { + allAttributes: ProductAttribute[]; + isVariationAttributes?: boolean; + onChange: ( attributes: ProductAttribute[] ) => void; + productId?: number; +}; + +export type EnhancedProductAttribute = ProductAttribute & { + terms?: ProductAttributeTerm[]; + visible?: boolean; +}; + +export function useProductAttributes( { + allAttributes = [], + isVariationAttributes = false, + onChange, + productId, +}: useProductAttributesProps ) { + const getFilteredAttributes = () => { + return isVariationAttributes + ? allAttributes.filter( ( attribute ) => !! attribute.variation ) + : allAttributes.filter( ( attribute ) => ! attribute.variation ); + }; + + const [ attributes, setAttributes ] = useState< + EnhancedProductAttribute[] + >( getFilteredAttributes() ); + const [ localAttributes, globalAttributes ]: ProductAttribute[][] = sift( + attributes, + ( attr: ProductAttribute ) => attr.id === 0 + ); + + const fetchTerms = useCallback( + ( attributeId: number ) => { + return resolveSelect( + EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME + ) + .getProductAttributeTerms< ProductAttributeTerm[] >( { + attribute_id: attributeId, + product: productId, + } ) + .then( + ( attributeTerms ) => { + return attributeTerms; + }, + ( error ) => { + return error; + } + ); + }, + [ productId ] + ); + + const enhanceAttribute = ( + globalAttribute: ProductAttribute, + terms: ProductAttributeTerm[] + ) => { + return { + ...globalAttribute, + terms: terms.length > 0 ? terms : undefined, + options: terms.length === 0 ? globalAttribute.options : [], + }; + }; + + const handleChange = ( newAttributes: ProductAttribute[] ) => { + const otherAttributes = isVariationAttributes + ? allAttributes.filter( ( attribute ) => ! attribute.variation ) + : allAttributes.filter( ( attribute ) => !! attribute.variation ); + setAttributes( newAttributes ); + onChange( [ ...otherAttributes, ...newAttributes ] ); + }; + + useEffect( () => { + if ( ! getFilteredAttributes().length || attributes.length ) { + return; + } + + Promise.all( + globalAttributes.map( ( attr ) => fetchTerms( attr.id ) ) + ).then( ( termData ) => { + setAttributes( [ + ...globalAttributes.map( ( attr, index ) => + enhanceAttribute( attr, termData[ index ] ) + ), + ...localAttributes, + ] ); + } ); + }, [ allAttributes, attributes, fetchTerms ] ); + + return { + attributes, + handleChange, + setAttributes, + }; +} diff --git a/plugins/woocommerce/changelog/update-36304 b/plugins/woocommerce/changelog/update-36304 new file mode 100644 index 00000000000..695487b46d0 --- /dev/null +++ b/plugins/woocommerce/changelog/update-36304 @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Move product attribute fetching logic into a separate hook From d9daad3e9c28272421707940a72f0571f5502855 Mon Sep 17 00:00:00 2001 From: Joel Thiessen <444632+joelclimbsthings@users.noreply.github.com> Date: Tue, 17 Jan 2023 01:04:58 -0800 Subject: [PATCH 16/58] Converting product details section to utilize slot fills (#36368) * Working prototype of product details via slotfill * Raising default order for product slot-fills * Move logic to details field name component * Adding order prop to all fills. * Adding components changelog * Adding changelog * Deleting obsolete product details section files * Reducing spacing between slot filled fields --- .../update-36016-product-details-slotfill | 4 + packages/js/components/src/index.ts | 4 + .../src/product-section-layout/index.ts | 2 + .../product-field-section.tsx | 39 +++ .../product-section-layout.tsx | 35 +++ .../src/product-section-layout/style.scss | 52 ++++ packages/js/components/src/style.scss | 1 + .../woo-product-field-item.tsx | 2 +- .../woo-product-section-item.tsx | 2 +- .../fills/details-section/constants.ts | 2 + .../details-field-categories.tsx | 25 ++ .../details-field-description.tsx | 36 +++ .../details-section/details-field-feature.tsx | 65 +++++ .../details-section/details-field-name.tsx | 104 ++++++++ .../details-section/details-field-summary.tsx | 36 +++ .../details-section/details-section-fills.tsx | 90 +++++++ .../products/fills/details-section/index.ts | 6 + .../product-details-section.scss | 0 .../client/products/fills/index.ts | 1 + .../layout/product-section-layout.scss | 2 +- .../client/products/product-form.tsx | 9 +- .../sections/product-details-section.tsx | 250 ------------------ .../test/product-details-section.spec.tsx | 89 ------- .../update-36016-product-details-slotfill | 4 + 24 files changed, 515 insertions(+), 345 deletions(-) create mode 100644 packages/js/components/changelog/update-36016-product-details-slotfill create mode 100644 packages/js/components/src/product-section-layout/index.ts create mode 100644 packages/js/components/src/product-section-layout/product-field-section.tsx create mode 100644 packages/js/components/src/product-section-layout/product-section-layout.tsx create mode 100644 packages/js/components/src/product-section-layout/style.scss create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/constants.ts create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/index.ts rename plugins/woocommerce-admin/client/products/{sections => fills/details-section}/product-details-section.scss (100%) create mode 100644 plugins/woocommerce-admin/client/products/fills/index.ts delete mode 100644 plugins/woocommerce-admin/client/products/sections/product-details-section.tsx delete mode 100644 plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx create mode 100644 plugins/woocommerce/changelog/update-36016-product-details-slotfill diff --git a/packages/js/components/changelog/update-36016-product-details-slotfill b/packages/js/components/changelog/update-36016-product-details-slotfill new file mode 100644 index 00000000000..d03cd2109ac --- /dev/null +++ b/packages/js/components/changelog/update-36016-product-details-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding ProductSectionLayout component and changing default order for WooProductSectionItem component. diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts index 3430cb01281..94562eb2dce 100644 --- a/packages/js/components/src/index.ts +++ b/packages/js/components/src/index.ts @@ -87,4 +87,8 @@ export { CollapsibleContent } from './collapsible-content'; export { createOrderedChildren, sortFillsByOrder } from './utils'; export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item'; export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item'; +export { + ProductSectionLayout as __experimentalProductSectionLayout, + ProductFieldSection as __experimentalProductFieldSection, +} from './product-section-layout'; export * from './product-fields'; diff --git a/packages/js/components/src/product-section-layout/index.ts b/packages/js/components/src/product-section-layout/index.ts new file mode 100644 index 00000000000..9c670115867 --- /dev/null +++ b/packages/js/components/src/product-section-layout/index.ts @@ -0,0 +1,2 @@ +export * from './product-section-layout'; +export * from './product-field-section'; diff --git a/packages/js/components/src/product-section-layout/product-field-section.tsx b/packages/js/components/src/product-section-layout/product-field-section.tsx new file mode 100644 index 00000000000..5ae1d11b36c --- /dev/null +++ b/packages/js/components/src/product-section-layout/product-field-section.tsx @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { Card, CardBody } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { ProductSectionLayout } from './product-section-layout'; +import { WooProductFieldItem } from '../woo-product-field-item'; + +type ProductFieldSectionProps = { + id: string; + title: string; + description: string | JSX.Element; + className?: string; +}; + +export const ProductFieldSection: React.FC< ProductFieldSectionProps > = ( { + id, + title, + description, + className, + children, +} ) => ( + + + + { children } + + + + +); diff --git a/packages/js/components/src/product-section-layout/product-section-layout.tsx b/packages/js/components/src/product-section-layout/product-section-layout.tsx new file mode 100644 index 00000000000..5c80ab9e426 --- /dev/null +++ b/packages/js/components/src/product-section-layout/product-section-layout.tsx @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { Children, isValidElement, createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { FormSection } from '../form-section'; + +type ProductSectionLayoutProps = { + title: string; + description: string | JSX.Element; + className?: string; +}; + +export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( { + title, + description, + className, + children, +} ) => ( + + { Children.map( children, ( child ) => { + if ( isValidElement( child ) && child.props.onChange ) { + return
{ child }
; + } + return child; + } ) } +
+); diff --git a/packages/js/components/src/product-section-layout/style.scss b/packages/js/components/src/product-section-layout/style.scss new file mode 100644 index 00000000000..acd31a35894 --- /dev/null +++ b/packages/js/components/src/product-section-layout/style.scss @@ -0,0 +1,52 @@ +.woocommerce-form-section { + a { + text-decoration: none; + } + + &__content { + .components-card { + border: 1px solid $gray-400; + border-radius: 2px; + box-shadow: none; + &__body { + padding: $gap-large; + + > .components-base-control, + > .components-dropdown, + > .woocommerce-rich-text-editor { + &:not(:first-child):not(.components-radio-control) { + margin-top: $gap-large - $gap-smaller; + margin-bottom: 0; + } + } + } + } + + .woocommerce-product-form__field:not(:first-child) { + margin-top: $gap-large; + + > .components-base-control { + margin-bottom: 0; + } + } + + .components-radio-control .components-v-stack { + gap: $gap-small; + } + + .woocommerce-collapsible-content { + margin-top: $gap-large; + } + } + + &__header { + p > span { + display: block; + margin-bottom: $gap-smaller; + } + } + + &:not(:first-child) { + margin-top: $gap-largest; + } +} diff --git a/packages/js/components/src/style.scss b/packages/js/components/src/style.scss index 6ee55cde07f..cf6478c655a 100644 --- a/packages/js/components/src/style.scss +++ b/packages/js/components/src/style.scss @@ -55,3 +55,4 @@ @import 'tour-kit/style.scss'; @import 'collapsible-content/style.scss'; @import 'form/style.scss'; +@import 'product-section-layout/style.scss'; diff --git a/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx b/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx index 93bbf4c066d..3a62adf24df 100644 --- a/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx +++ b/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx @@ -23,7 +23,7 @@ type WooProductFieldSlotProps = { export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & { Slot: React.FC< Slot.Props & WooProductFieldSlotProps >; -} = ( { children, order = 1, section } ) => ( +} = ( { children, order = 20, section } ) => ( { ( fillProps: Fill.Props ) => { return createOrderedChildren< Fill.Props >( diff --git a/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx b/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx index ead5114a62c..5a79e5fa075 100644 --- a/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx +++ b/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx @@ -23,7 +23,7 @@ type WooProductFieldSlotProps = { export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & { Slot: React.FC< Slot.Props & WooProductFieldSlotProps >; -} = ( { children, order = 1, location } ) => ( +} = ( { children, order = 20, location } ) => ( { ( fillProps: Fill.Props ) => { return createOrderedChildren< Fill.Props >( diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/constants.ts b/plugins/woocommerce-admin/client/products/fills/details-section/constants.ts new file mode 100644 index 00000000000..4404df12093 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/constants.ts @@ -0,0 +1,2 @@ +export const PRODUCT_DETAILS_SLUG = 'product-details'; +export const DETAILS_SECTION_ID = 'general/details'; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx new file mode 100644 index 00000000000..87e080ba5e3 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useFormContext } from '@woocommerce/components'; +import { Product, ProductCategory } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { CategoryField } from '../../fields/category-field'; + +export const DetailsCategoriesField = () => { + const { getInputProps } = useFormContext< Product >(); + + return ( + [] >( + 'categories' + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx new file mode 100644 index 00000000000..36f445ab3c0 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + __experimentalRichTextEditor as RichTextEditor, +} from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; +import { BlockInstance, serialize, parse } from '@wordpress/blocks'; +import { useState } from '@wordpress/element'; + +export const DetailsDescriptionField = () => { + const { setValue, values } = useFormContext< Product >(); + const [ descriptionBlocks, setDescriptionBlocks ] = useState< + BlockInstance[] + >( parse( values.description || '' ) ); + + return ( + { + setDescriptionBlocks( blocks ); + if ( ! descriptionBlocks.length ) { + return; + } + setValue( 'description', serialize( blocks ) ); + } } + placeholder={ __( + 'Describe this product. What makes it unique? What are its most important features?', + 'woocommerce' + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx new file mode 100644 index 00000000000..350871b4c03 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + Link, + __experimentalTooltip as Tooltip, +} from '@woocommerce/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { Product } from '@woocommerce/data'; +import { recordEvent } from '@woocommerce/tracks'; + +/** + * Internal dependencies + */ +import { getCheckboxTracks } from '../../sections/utils'; +import { PRODUCT_DETAILS_SLUG } from './index'; + +export const DetailsFeatureField = () => { + const { getCheckboxControlProps } = useFormContext< Product >(); + + return ( + + { __( 'Feature this product', 'woocommerce' ) } + + recordEvent( + 'add_product_learn_more', + { + category: + PRODUCT_DETAILS_SLUG, + } + ) + } + > + { __( 'Learn more', 'woocommerce' ) } + + ), + }, + } ) } + /> + + } + { ...getCheckboxControlProps( + 'featured', + getCheckboxTracks( 'featured' ) + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx new file mode 100644 index 00000000000..c8513e6c755 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { Button, TextControl } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { cleanForSlug } from '@wordpress/url'; +import { useFormContext } from '@woocommerce/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { + Product, + PRODUCTS_STORE_NAME, + WCDataSelector, +} from '@woocommerce/data'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { EditProductLinkModal } from '../../shared/edit-product-link-modal'; +import { PRODUCT_DETAILS_SLUG } from './index'; + +export const DetailsNameField = ( {} ) => { + const [ showProductLinkEditModal, setShowProductLinkEditModal ] = + useState( false ); + const { getInputProps, values, touched, errors, setValue } = + useFormContext< Product >(); + + const { permalinkPrefix, permalinkSuffix } = useSelect( + ( select: WCDataSelector ) => { + const { getPermalinkParts } = select( PRODUCTS_STORE_NAME ); + if ( values.id ) { + const parts = getPermalinkParts( values.id ); + return { + permalinkPrefix: parts?.prefix, + permalinkSuffix: parts?.suffix, + }; + } + return {}; + } + ); + + const hasNameError = () => { + return Boolean( touched.name ) && Boolean( errors.name ); + }; + + const setSkuIfEmpty = () => { + if ( values.sku || ! values.name?.length ) { + return; + } + setValue( 'sku', cleanForSlug( values.name ) ); + }; + return ( +
+ + { __( '(required)', 'woocommerce' ) } + + ), + }, + } ) } + name={ `${ PRODUCT_DETAILS_SLUG }-name` } + placeholder={ __( 'e.g. 12 oz Coffee Mug', 'woocommerce' ) } + { ...getInputProps( 'name', { + onBlur: setSkuIfEmpty, + } ) } + /> + { values.id && ! hasNameError() && permalinkPrefix && ( + + { __( 'Product link', 'woocommerce' ) } + :  + + { permalinkPrefix } + { values.slug || cleanForSlug( values.name ) } + { permalinkSuffix } + + + + ) } + { showProductLinkEditModal && ( + setShowProductLinkEditModal( false ) } + onSaved={ () => setShowProductLinkEditModal( false ) } + /> + ) } +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx new file mode 100644 index 00000000000..aa1304e5d36 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + __experimentalRichTextEditor as RichTextEditor, +} from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; +import { BlockInstance, serialize, parse } from '@wordpress/blocks'; +import { useState } from '@wordpress/element'; + +export const DetailsSummaryField = () => { + const { setValue, values } = useFormContext< Product >(); + const [ summaryBlocks, setSummaryBlocks ] = useState< BlockInstance[] >( + parse( values.short_description || '' ) + ); + + return ( + { + setSummaryBlocks( blocks ); + if ( ! summaryBlocks.length ) { + return; + } + setValue( 'short_description', serialize( blocks ) ); + } } + placeholder={ __( + "Summarize this product in 1-2 short sentences. We'll show it at the top of the page.", + 'woocommerce' + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx new file mode 100644 index 00000000000..dae3909e352 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + __experimentalWooProductSectionItem as WooProductSectionItem, + __experimentalWooProductFieldItem as WooProductFieldItem, + __experimentalProductFieldSection as ProductFieldSection, +} from '@woocommerce/components'; +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import { + DetailsNameField, + DetailsCategoriesField, + DetailsFeatureField, + DetailsSummaryField, + DetailsDescriptionField, + DETAILS_SECTION_ID, +} from './index'; +import './product-details-section.scss'; + +const DetailsSection = () => ( + <> + + + + + + + + + + + + + + + + + + + +); + +registerPlugin( 'wc-admin-product-editor-details-section', { + // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. + scope: 'woocommerce-product-editor', + render: () => { + return ; + }, +} ); diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/index.ts b/plugins/woocommerce-admin/client/products/fills/details-section/index.ts new file mode 100644 index 00000000000..ece2335fc8d --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/index.ts @@ -0,0 +1,6 @@ +export * from './details-field-name'; +export * from './details-field-categories'; +export * from './details-field-feature'; +export * from './details-field-summary'; +export * from './details-field-description'; +export * from './constants'; diff --git a/plugins/woocommerce-admin/client/products/sections/product-details-section.scss b/plugins/woocommerce-admin/client/products/fills/details-section/product-details-section.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/sections/product-details-section.scss rename to plugins/woocommerce-admin/client/products/fills/details-section/product-details-section.scss diff --git a/plugins/woocommerce-admin/client/products/fills/index.ts b/plugins/woocommerce-admin/client/products/fills/index.ts new file mode 100644 index 00000000000..3bdc560ed80 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/index.ts @@ -0,0 +1 @@ +export * from './details-section/details-section-fills'; diff --git a/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss b/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss index acd31a35894..83d72682e05 100644 --- a/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss +++ b/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss @@ -23,7 +23,7 @@ } .woocommerce-product-form__field:not(:first-child) { - margin-top: $gap-large; + margin-top: $gap-large - $gap-smaller; > .components-base-control { margin-bottom: 0; diff --git a/plugins/woocommerce-admin/client/products/product-form.tsx b/plugins/woocommerce-admin/client/products/product-form.tsx index 6be4974c9e8..655e10006d2 100644 --- a/plugins/woocommerce-admin/client/products/product-form.tsx +++ b/plugins/woocommerce-admin/client/products/product-form.tsx @@ -8,13 +8,13 @@ import { } from '@woocommerce/components'; import { PartialProduct, Product } from '@woocommerce/data'; import { Ref } from 'react'; +import { PluginArea } from '@wordpress/plugins'; /** * Internal dependencies */ import { ProductFormHeader } from './layout/product-form-header'; import { ProductFormLayout } from './layout/product-form-layout'; -import { ProductDetailsSection } from './sections/product-details-section'; import { ProductInventorySection } from './sections/product-inventory-section'; import { PricingSection } from './sections/pricing-section'; import { ProductShippingSection } from './sections/product-shipping-section'; @@ -26,6 +26,8 @@ import { OptionsSection } from './sections/options-section'; import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormTab } from './product-form-tab'; +import './fills'; + export const ProductForm: React.FC< { product?: PartialProduct; formRef?: Ref< FormRef< Partial< Product > > >; @@ -48,10 +50,9 @@ export const ProductForm: React.FC< { - + - + { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } + ); }; diff --git a/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx b/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx deleted file mode 100644 index db2abe0848f..00000000000 --- a/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/** - * External dependencies - */ -import { - CheckboxControl, - Button, - TextControl, - Card, - CardBody, -} from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; -import { cleanForSlug } from '@wordpress/url'; -import { - Link, - useFormContext, - __experimentalRichTextEditor as RichTextEditor, - __experimentalTooltip as Tooltip, - __experimentalWooProductFieldItem as WooProductFieldItem, -} from '@woocommerce/components'; -import interpolateComponents from '@automattic/interpolate-components'; -import { - Product, - ProductCategory, - PRODUCTS_STORE_NAME, - WCDataSelector, -} from '@woocommerce/data'; -import { recordEvent } from '@woocommerce/tracks'; -import { BlockInstance, serialize, parse } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import './product-details-section.scss'; -import { CategoryField } from '../fields/category-field'; -import { EditProductLinkModal } from '../shared/edit-product-link-modal'; -import { getCheckboxTracks } from './utils'; -import { ProductSectionLayout } from '../layout/product-section-layout'; - -const PRODUCT_DETAILS_SLUG = 'product-details'; - -export const ProductDetailsSection: React.FC = () => { - const { - getCheckboxControlProps, - getInputProps, - values, - touched, - errors, - setValue, - } = useFormContext< Product >(); - const [ showProductLinkEditModal, setShowProductLinkEditModal ] = - useState( false ); - const [ descriptionBlocks, setDescriptionBlocks ] = useState< - BlockInstance[] - >( parse( values.description || '' ) ); - const [ summaryBlocks, setSummaryBlocks ] = useState< BlockInstance[] >( - parse( values.short_description || '' ) - ); - const { permalinkPrefix, permalinkSuffix } = useSelect( - ( select: WCDataSelector ) => { - const { getPermalinkParts } = select( PRODUCTS_STORE_NAME ); - if ( values.id ) { - const parts = getPermalinkParts( values.id ); - return { - permalinkPrefix: parts?.prefix, - permalinkSuffix: parts?.suffix, - }; - } - return {}; - } - ); - - const hasNameError = () => { - return Boolean( touched.name ) && Boolean( errors.name ); - }; - - const setSkuIfEmpty = () => { - if ( values.sku || ! values.name?.length ) { - return; - } - setValue( 'sku', cleanForSlug( values.name ) ); - }; - - return ( - - - -
- - { __( - '(required)', - 'woocommerce' - ) } - - ), - }, - } ) } - name={ `${ PRODUCT_DETAILS_SLUG }-name` } - placeholder={ __( - 'e.g. 12 oz Coffee Mug', - 'woocommerce' - ) } - { ...getInputProps( 'name', { - onBlur: setSkuIfEmpty, - } ) } - /> - { values.id && ! hasNameError() && permalinkPrefix && ( - - { __( 'Product link', 'woocommerce' ) } - :  - - { permalinkPrefix } - { values.slug || - cleanForSlug( values.name ) } - { permalinkSuffix } - - - - ) } -
- [] - >( 'categories' ) } - /> - - { __( 'Feature this product', 'woocommerce' ) } - - recordEvent( - 'add_product_learn_more', - { - category: - PRODUCT_DETAILS_SLUG, - } - ) - } - > - { __( - 'Learn more', - 'woocommerce' - ) } - - ), - }, - } ) } - /> - - } - { ...getCheckboxControlProps( - 'featured', - getCheckboxTracks( 'featured' ) - ) } - /> - { showProductLinkEditModal && ( - - setShowProductLinkEditModal( false ) - } - onSaved={ () => - setShowProductLinkEditModal( false ) - } - /> - ) } - { - setSummaryBlocks( blocks ); - if ( ! summaryBlocks.length ) { - return; - } - setValue( - 'short_description', - serialize( blocks ) - ); - } } - placeholder={ __( - "Summarize this product in 1-2 short sentences. We'll show it at the top of the page.", - 'woocommerce' - ) } - /> - { - setDescriptionBlocks( blocks ); - if ( ! descriptionBlocks.length ) { - return; - } - setValue( 'description', serialize( blocks ) ); - } } - placeholder={ __( - 'Describe this product. What makes it unique? What are its most important features?', - 'woocommerce' - ) } - /> - -
-
-
- ); -}; diff --git a/plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx b/plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx deleted file mode 100644 index fed5d6a532a..00000000000 --- a/plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * External dependencies - */ -import { createRegistry, RegistryProvider, useSelect } from '@wordpress/data'; -import { Form } from '@woocommerce/components'; -import { Product } from '@woocommerce/data'; -import { render, screen } from '@testing-library/react'; -// 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 { store as blockEditorStore } from '@wordpress/block-editor'; -// 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 { store as coreDataStore } from '@wordpress/core-data'; -// eslint-disable-next-line @woocommerce/dependency-group -import userEvent from '@testing-library/user-event'; - -/** - * Internal dependencies - */ -import { ProductDetailsSection } from '../product-details-section'; -import { validate } from '../../product-validation'; - -jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) ); -jest.mock( '@wordpress/data', () => ( { - ...jest.requireActual( '@wordpress/data' ), - useSelect: jest.fn(), -} ) ); - -const registry = createRegistry(); -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore No types for this exist yet. -registry.register( coreDataStore ); -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore No types for this exist yet. -registry.register( blockEditorStore ); - -describe( 'ProductDetailsSection', () => { - const useSelectMock = useSelect as jest.Mock; - - beforeEach( () => { - jest.clearAllMocks(); - } ); - - describe( 'when editing a product', () => { - const product: Partial< Product > = { - id: 1, - name: 'Lorem', - slug: 'lorem', - }; - const permalinkPrefix = 'http://localhost/'; - const linkUrl = permalinkPrefix + product.slug; - - beforeEach( () => { - useSelectMock.mockReturnValue( { - permalinkPrefix, - } ); - } ); - - it( 'should render the product link', () => { - render( - -
- - -
- ); - - expect( screen.queryByText( linkUrl ) ).toBeInTheDocument(); - } ); - - it( 'should hide the product link if field name has errors', () => { - render( - -
- - -
- ); - userEvent.clear( - screen.getByLabelText( 'Name', { exact: false } ) - ); - userEvent.tab(); - - expect( screen.queryByText( linkUrl ) ).not.toBeInTheDocument(); - } ); - } ); -} ); diff --git a/plugins/woocommerce/changelog/update-36016-product-details-slotfill b/plugins/woocommerce/changelog/update-36016-product-details-slotfill new file mode 100644 index 00000000000..aa1c65b371e --- /dev/null +++ b/plugins/woocommerce/changelog/update-36016-product-details-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Reimplementing product details fields in product editor as slot fills. From 0f78e322167d7724c8a2594c60677c052596f7fb Mon Sep 17 00:00:00 2001 From: Matthias Kittsteiner Date: Tue, 17 Jan 2023 10:39:10 +0100 Subject: [PATCH 17/58] Add context to countries shipping to prefix (#36254) Closes https://github.com/woocommerce/woocommerce/issues/36242 --- .../changelog/countries-shipping-to-prefix-context | 4 ++++ plugins/woocommerce/includes/class-wc-countries.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/countries-shipping-to-prefix-context diff --git a/plugins/woocommerce/changelog/countries-shipping-to-prefix-context b/plugins/woocommerce/changelog/countries-shipping-to-prefix-context new file mode 100644 index 00000000000..2caa9243557 --- /dev/null +++ b/plugins/woocommerce/changelog/countries-shipping-to-prefix-context @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Add context to countries shipping to prefix diff --git a/plugins/woocommerce/includes/class-wc-countries.php b/plugins/woocommerce/includes/class-wc-countries.php index a1a9aa8507f..bfaf83a6b06 100644 --- a/plugins/woocommerce/includes/class-wc-countries.php +++ b/plugins/woocommerce/includes/class-wc-countries.php @@ -410,7 +410,7 @@ class WC_Countries { public function shipping_to_prefix( $country_code = '' ) { $country_code = $country_code ? $country_code : WC()->customer->get_shipping_country(); $countries = array( 'AE', 'CZ', 'DO', 'GB', 'NL', 'PH', 'US', 'USAF' ); - $return = in_array( $country_code, $countries, true ) ? __( 'to the', 'woocommerce' ) : __( 'to', 'woocommerce' ); + $return = in_array( $country_code, $countries, true ) ? _x( 'to the', 'shipping country prefix', 'woocommerce' ) : _x( 'to', 'shipping country prefix', 'woocommerce' ); return apply_filters( 'woocommerce_countries_shipping_to_prefix', $return, $country_code ); } From 2bf9f577952d7a225365fe7728b3187b5be7b701 Mon Sep 17 00:00:00 2001 From: Barry Hughes <3594411+barryhughes@users.noreply.github.com> Date: Tue, 17 Jan 2023 08:06:25 -0800 Subject: [PATCH 18/58] Restore pre-7.2.0 quantity selector behavior (#36460) Prior to 7.2.0 the quantity input was hidden if input min and max were identical (either because the product was sold individually, or because of min/max products config). This change restores that behavior, but makes it possible to render the input in readonly mode if desired (via filters). --- .../changelog/fix-36007-quantity-selector | 4 ++++ .../changelog/fix-36007-sold-individually | 4 ---- .../fix-36007-sold-individually-amendment | 5 ----- .../client/legacy/css/_common.scss | 8 -------- .../client/legacy/css/twenty-nineteen.scss | 1 - .../client/legacy/css/twenty-seventeen.scss | 1 - .../client/legacy/css/twenty-twenty-one.scss | 1 - .../legacy/css/twenty-twenty-three.scss | 1 - .../client/legacy/css/twenty-twenty-two.scss | 1 - .../client/legacy/css/twenty-twenty.scss | 1 - .../legacy/css/woocommerce-blocktheme.scss | 1 - .../client/legacy/css/woocommerce.scss | 1 - .../includes/wc-template-functions.php | 20 ++++++++++++++++++- .../templates/global/quantity-input.php | 18 +++++++---------- 14 files changed, 30 insertions(+), 37 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-36007-quantity-selector delete mode 100644 plugins/woocommerce/changelog/fix-36007-sold-individually delete mode 100644 plugins/woocommerce/changelog/fix-36007-sold-individually-amendment delete mode 100644 plugins/woocommerce/client/legacy/css/_common.scss diff --git a/plugins/woocommerce/changelog/fix-36007-quantity-selector b/plugins/woocommerce/changelog/fix-36007-quantity-selector new file mode 100644 index 00000000000..e3d2f700ea1 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-36007-quantity-selector @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Restore the pre-7.2.0 behavior for single product quantity inputs. diff --git a/plugins/woocommerce/changelog/fix-36007-sold-individually b/plugins/woocommerce/changelog/fix-36007-sold-individually deleted file mode 100644 index 727cdd0ccc2..00000000000 --- a/plugins/woocommerce/changelog/fix-36007-sold-individually +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: tweak - -By default, hide the quantity selector within the single product page if a product is sold individually. diff --git a/plugins/woocommerce/changelog/fix-36007-sold-individually-amendment b/plugins/woocommerce/changelog/fix-36007-sold-individually-amendment deleted file mode 100644 index 6b14c30ea53..00000000000 --- a/plugins/woocommerce/changelog/fix-36007-sold-individually-amendment +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: tweak -Comment: We're tweaking an unreleased change which is already covered by a changelog entry added in PR#36350. - - diff --git a/plugins/woocommerce/client/legacy/css/_common.scss b/plugins/woocommerce/client/legacy/css/_common.scss deleted file mode 100644 index 9737c39c898..00000000000 --- a/plugins/woocommerce/client/legacy/css/_common.scss +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Contains rules common to all supported frontend themes. - */ - -/* We do not wish to display the quantity selector (within single product pages) if the product is sold individually. */ -.woocommerce.single-product .product.sold-individually .quantity { - display: none; -} diff --git a/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss b/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss index 41630ae0b43..fd257184f74 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss @@ -1,4 +1,3 @@ -@import "common"; @import 'mixins'; /** diff --git a/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss b/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss index 7e77f25c820..f2c0af5bb9f 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss @@ -1,7 +1,6 @@ /** * Twenty Seventeen integration styles */ -@import "common"; @import "mixins"; @import "animation"; diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss index e7bbab55a2f..199deae3fc8 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss @@ -1,4 +1,3 @@ -@import "common"; @import "mixins"; /** diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss index 6c6a8fa48d9..0db0c019bc0 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss @@ -25,7 +25,6 @@ font-style: normal; } -@import "common"; @import "mixins"; @import "animation"; diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss index 7a54fe2318d..3105e01ce15 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss @@ -25,7 +25,6 @@ font-style: normal; } -@import "common"; @import "mixins"; @import "animation"; @import "variables"; diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty.scss index 0e758990c06..6f91033da6c 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty.scss @@ -1,4 +1,3 @@ -@import "common"; @import "mixins"; /** diff --git a/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss b/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss index 41b17b64088..1701887309a 100644 --- a/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss +++ b/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss @@ -2,7 +2,6 @@ * woocommerce-blocktheme.scss * Block theme default styles to ensure WooCommerce looks better out of the box with block themes that are not optimised for WooCommerce specifically. */ -@import "common"; @import "fonts"; @import "variables"; diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss index 9b475942929..efa4f5432d6 100644 --- a/plugins/woocommerce/client/legacy/css/woocommerce.scss +++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss @@ -7,7 +7,6 @@ /** * Imports */ -@import "common"; @import "mixins"; @import "variables"; @import "animation"; diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php index 4b3ef3ec827..e07f77e4134 100644 --- a/plugins/woocommerce/includes/wc-template-functions.php +++ b/plugins/woocommerce/includes/wc-template-functions.php @@ -1797,6 +1797,7 @@ if ( ! function_exists( 'woocommerce_quantity_input' ) ) { // When autocomplete is enabled in firefox, it will overwrite actual value with what user entered last. So we default to off. // See @link https://github.com/woocommerce/woocommerce/issues/30733. 'autocomplete' => apply_filters( 'woocommerce_quantity_input_autocomplete', 'off', $product ), + 'readonly' => false, ); $args = apply_filters( 'woocommerce_quantity_input_args', wp_parse_args( $args, $defaults ), $product ); @@ -1810,8 +1811,25 @@ if ( ! function_exists( 'woocommerce_quantity_input' ) ) { $args['max_value'] = $args['min_value']; } - ob_start(); + /** + * The input type attribute will generally be 'number' unless the quantity cannot be changed, in which case + * it will be set to 'hidden'. An exception is made for non-hidden readonly inputs: in this case we set the + * type to 'text' (this prevents most browsers from rendering increment/decrement arrows, which are useless + * and/or confusing in this context). + */ + $type = $args['min_value'] > 0 && $args['min_value'] === $args['max_value'] ? 'hidden' : 'number'; + $type = $args['readonly'] && 'hidden' !== $type ? 'text' : $type; + /** + * Controls the quantity input's type attribute. + * + * @since 7.4.0 + * + * @param string $type A valid input type attribute value, usually 'number' or 'hidden'. + */ + $args['type'] = apply_filters( 'woocommerce_quantity_input_type', $type ); + + ob_start(); wc_get_template( 'global/quantity-input.php', $args ); if ( $echo ) { diff --git a/plugins/woocommerce/templates/global/quantity-input.php b/plugins/woocommerce/templates/global/quantity-input.php index 5c346ecd307..07f865bf071 100644 --- a/plugins/woocommerce/templates/global/quantity-input.php +++ b/plugins/woocommerce/templates/global/quantity-input.php @@ -12,7 +12,10 @@ * * @see https://docs.woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 7.2.1 + * @version 7.4.0 + * + * @var bool $readonly If the input should be set to readonly mode. + * @var string $type The input type attribute. */ defined( 'ABSPATH' ) || exit; @@ -20,13 +23,6 @@ defined( 'ABSPATH' ) || exit; /* translators: %s: Quantity. */ $label = ! empty( $args['product_name'] ) ? sprintf( esc_html__( '%s quantity', 'woocommerce' ), wp_strip_all_tags( $args['product_name'] ) ) : esc_html__( 'Quantity', 'woocommerce' ); -// In some cases we wish to display the quantity but not allow for it to be changed. -if ( $max_value && $min_value === $max_value ) { - $is_readonly = true; - $input_value = $min_value; -} else { - $is_readonly = false; -} ?>
+ type="" + id="" class="" name="" @@ -49,7 +45,7 @@ if ( $max_value && $min_value === $max_value ) { size="4" min="" max="" - + step="" placeholder="" inputmode="" From 1fb60677fcb6a75eb5ef3d96c1d354bffd4400b7 Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Tue, 17 Jan 2023 13:47:32 -0800 Subject: [PATCH 19/58] Changelog. --- plugins/woocommerce/changelog/fix-i18n-states-comment-typo | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/woocommerce/changelog/fix-i18n-states-comment-typo diff --git a/plugins/woocommerce/changelog/fix-i18n-states-comment-typo b/plugins/woocommerce/changelog/fix-i18n-states-comment-typo new file mode 100644 index 00000000000..835d4d724bd --- /dev/null +++ b/plugins/woocommerce/changelog/fix-i18n-states-comment-typo @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Corrects a typo in the i18n/states.php file, relating to our list of Iranian states. From 4bb137637f8d1a9f777705ea1dd9bd1739211ae0 Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Tue, 17 Jan 2023 13:59:36 -0800 Subject: [PATCH 20/58] Update API tests re revised email image header tooltip text. --- .../tests/api-core-tests/tests/settings/settings-crud.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js b/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js index 78853b7e380..77edca5c84a 100644 --- a/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js +++ b/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js @@ -1370,10 +1370,10 @@ test.describe('Settings API tests: CRUD', () => { expect.objectContaining({ "id": "woocommerce_email_header_image", "label": "Header image", - "description": "URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media).", + "description": "Paste the URL of an image you want to show in the email header. Upload images using the media uploader (Media > Add New).", "type": "text", "default": "", - "tip": "URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media).", + "tip": "Paste the URL of an image you want to show in the email header. Upload images using the media uploader (Media > Add New).", "value": "", }) ])); From c41eccc58ef65a00def17f6adaed946dc657425b Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Fri, 13 Jan 2023 14:38:56 -0800 Subject: [PATCH 21/58] Check a valid product object was obtained before calling its methods. --- .../changelog/fix-36325-download-permissions | 4 ++++ .../src/Internal/DownloadPermissionsAdjuster.php | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/fix-36325-download-permissions diff --git a/plugins/woocommerce/changelog/fix-36325-download-permissions b/plugins/woocommerce/changelog/fix-36325-download-permissions new file mode 100644 index 00000000000..c8e65c7fc0d --- /dev/null +++ b/plugins/woocommerce/changelog/fix-36325-download-permissions @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +When adjusting download permissions, confirm the child products have not been removed. diff --git a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php index a30c504e63e..b631f92e489 100644 --- a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php +++ b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php @@ -99,7 +99,21 @@ class DownloadPermissionsAdjuster { $children_with_downloads = array(); foreach ( $children_ids as $child_id ) { - $child = wc_get_product( $child_id ); + $child = wc_get_product( $child_id ); + + // Ensure we have a valid child product. + if ( ! $child ) { + wc_get_logger()->warning( + sprintf( + /* translators: 1: child product ID 2: parent product ID. */ + __( 'Unable to load child product %1$d while adjusting download permissions for product %2$d.', 'woocommerce' ), + $child_id, + $product_id + ) + ); + continue; + } + $children_with_downloads[ $child_id ] = $this->get_download_files_and_permissions( $child ); } From 9bfd8535f7c15cb1eb528e028ed5a9571512a9bc Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Tue, 17 Jan 2023 15:54:59 -0800 Subject: [PATCH 22/58] Be more specific: we only expect WC_Product types at this point. --- .../woocommerce/src/Internal/DownloadPermissionsAdjuster.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php index b631f92e489..f2f3adb5949 100644 --- a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php +++ b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php @@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal; use Automattic\WooCommerce\Proxies\LegacyProxy; +use WC_Product; defined( 'ABSPATH' ) || exit; @@ -102,7 +103,7 @@ class DownloadPermissionsAdjuster { $child = wc_get_product( $child_id ); // Ensure we have a valid child product. - if ( ! $child ) { + if ( ! $child instanceof WC_Product ) { wc_get_logger()->warning( sprintf( /* translators: 1: child product ID 2: parent product ID. */ From e9a4d6c9ba3fc038f20e14288f21a8938a997735 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 18 Jan 2023 11:56:33 +0800 Subject: [PATCH 23/58] Add an optional "InputProps" to experimental SelectControl component (#36470) * Add inputProps to experimental SelectControl component so we can pass custom props to the input * Add changelog --- .../js/components/changelog/add-input-props-to-exp-select | 4 ++++ .../src/experimental-select-control/select-control.tsx | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 packages/js/components/changelog/add-input-props-to-exp-select diff --git a/packages/js/components/changelog/add-input-props-to-exp-select b/packages/js/components/changelog/add-input-props-to-exp-select new file mode 100644 index 00000000000..a4123d1f6ea --- /dev/null +++ b/packages/js/components/changelog/add-input-props-to-exp-select @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add an optional "InputProps" to experimental SelectControl component diff --git a/packages/js/components/src/experimental-select-control/select-control.tsx b/packages/js/components/src/experimental-select-control/select-control.tsx index e811d1d3e4e..9fece1fa36d 100644 --- a/packages/js/components/src/experimental-select-control/select-control.tsx +++ b/packages/js/components/src/experimental-select-control/select-control.tsx @@ -7,6 +7,7 @@ import { UseComboboxState, UseComboboxStateChangeOptions, useMultipleSelection, + GetInputPropsOptions, } from 'downshift'; import { useState, @@ -65,6 +66,7 @@ export type SelectControlProps< ItemType > = { selected: ItemType | ItemType[] | null; className?: string; disabled?: boolean; + inputProps?: GetInputPropsOptions; suffix?: JSX.Element | null; /** * This is a feature already implemented in downshift@7.0.0 through the @@ -119,6 +121,7 @@ function SelectControl< ItemType = DefaultItemType >( { selected, className, disabled, + inputProps = {}, suffix = , __experimentalOpenMenuOnFocus = false, }: SelectControlProps< ItemType > ) { @@ -268,6 +271,7 @@ function SelectControl< ItemType = DefaultItemType >( { onBlur: () => setIsFocused( false ), placeholder, disabled, + ...inputProps, } ) } suffix={ suffix } > From b1f80c1271a4d09e3b339266fe012078c960b02c Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Wed, 18 Jan 2023 18:42:46 +1300 Subject: [PATCH 24/58] Create a manually triggered workflow to release WooCommerce Beta Tester (#36387) --- .github/workflows/release-wc-beta-tester.yml | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/release-wc-beta-tester.yml diff --git a/.github/workflows/release-wc-beta-tester.yml b/.github/workflows/release-wc-beta-tester.yml new file mode 100644 index 00000000000..c2e6c7adf45 --- /dev/null +++ b/.github/workflows/release-wc-beta-tester.yml @@ -0,0 +1,34 @@ +name: WooCommerce Beta Tester Release +permissions: {} + +on: + workflow_dispatch: + inputs: + version: + description: 'The version number for the release' + required: true + +jobs: + release: + name: Run release scripts + runs-on: ubuntu-20.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + + - name: Setup WooCommerce Monorepo + uses: ./.github/actions/setup-woocommerce-monorepo + + - name: Build WooCommerce Beta Tester Zip + working-directory: plugins/woocommerce-beta-tester + run: pnpm build:zip + + - name: Create release + id: create_release + uses: woocommerce/action-gh-release@master + with: + tag_name: wc-beta-tester-${{ inputs.version }} + name: WooCommerce Beta Tester Release ${{ inputs.version }} + draft: false + files: plugins/woocommerce-beta-tester/woocommerce-beta-tester.zip From 141a0bd6b419d7f96f7f566e1217b0d272c2b968 Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Wed, 18 Jan 2023 18:43:00 +1300 Subject: [PATCH 25/58] Fix release post template syntax errors and add prettierignore (#36411) Fix some template issues, add prettierignore to disable prettier on ejs files. --- tools/release-posts/.prettierignore | 1 + tools/release-posts/templates/database.ejs | 1 + tools/release-posts/templates/oauth.ejs | 2 +- tools/release-posts/templates/release.ejs | 1 + tools/release-posts/templates/templates.ejs | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 tools/release-posts/.prettierignore diff --git a/tools/release-posts/.prettierignore b/tools/release-posts/.prettierignore new file mode 100644 index 00000000000..6e551732dd9 --- /dev/null +++ b/tools/release-posts/.prettierignore @@ -0,0 +1 @@ +*.ejs diff --git a/tools/release-posts/templates/database.ejs b/tools/release-posts/templates/database.ejs index 6f5f9016bf2..de3fea44d6f 100644 --- a/tools/release-posts/templates/database.ejs +++ b/tools/release-posts/templates/database.ejs @@ -49,6 +49,7 @@ + <% } else { %>

There are no database changes in this release.

diff --git a/tools/release-posts/templates/oauth.ejs b/tools/release-posts/templates/oauth.ejs index 5228670644e..248ea63d171 100644 --- a/tools/release-posts/templates/oauth.ejs +++ b/tools/release-posts/templates/oauth.ejs @@ -3,5 +3,5 @@ Authentication Success - Authentication successful, please return the console to complete the process. + Authentication successful, please return to the console to complete the process. diff --git a/tools/release-posts/templates/release.ejs b/tools/release-posts/templates/release.ejs index 8b442aaddb1..00621bf1c3f 100644 --- a/tools/release-posts/templates/release.ejs +++ b/tools/release-posts/templates/release.ejs @@ -5,6 +5,7 @@

We are pleased to announce the release of WooCommerce <%= displayVersion %>. This release should be backwards compatible with the previous version.

+

This release contains:

diff --git a/tools/release-posts/templates/templates.ejs b/tools/release-posts/templates/templates.ejs index ec7d93ecf95..e5d1bd5019b 100644 --- a/tools/release-posts/templates/templates.ejs +++ b/tools/release-posts/templates/templates.ejs @@ -22,6 +22,7 @@ + <% } else { %>

There are no template changes in this release.

From d181008ac4352515808e36ac7e125e10265d9963 Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Wed, 18 Jan 2023 18:43:22 +1300 Subject: [PATCH 26/58] Fix TS issues and lint the release post tool by running `tsc` (#36412) --- .github/workflows/pr-lint-test-js.yml | 2 +- pnpm-lock.yaml | 117 ++++++++++++--------- tools/cli-core/package.json | 1 + tools/release-posts/lib/environment.ts | 1 - tools/release-posts/lib/github-api.ts | 4 + tools/release-posts/lib/logger.ts | 62 ----------- tools/release-posts/lib/render-template.ts | 4 +- tools/release-posts/package.json | 6 ++ 8 files changed, 84 insertions(+), 113 deletions(-) delete mode 100644 tools/release-posts/lib/logger.ts diff --git a/.github/workflows/pr-lint-test-js.yml b/.github/workflows/pr-lint-test-js.yml index c5a58e444a6..972367f0abd 100644 --- a/.github/workflows/pr-lint-test-js.yml +++ b/.github/workflows/pr-lint-test-js.yml @@ -23,7 +23,7 @@ jobs: uses: ./.github/actions/setup-woocommerce-monorepo - name: Lint - run: pnpm run -r --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint + run: pnpm run -r --filter='release-posts' --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint - name: Test run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbcbbc7aa92..f69f4a8dfa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1862,6 +1862,7 @@ importers: tools/cli-core: specifiers: '@tsconfig/node16': ^1.0.3 + '@types/uuid': ^9.0.0 chalk: ^4.1.2 dotenv: ^10.0.0 ora: ^5.4.1 @@ -1874,10 +1875,11 @@ importers: dotenv: 10.0.0 ora: 5.4.1 simple-git: 3.14.0 - ts-node: 10.9.1_suuodkax7fygvcgfx5vhk45yei + ts-node: 10.9.1_vqcafhj4xvr2nzknlrdklk55zm uuid: 8.3.2 devDependencies: '@tsconfig/node16': 1.0.3 + '@types/uuid': 9.0.0 typescript: 4.8.4 tools/code-analyzer: @@ -1996,7 +1998,12 @@ importers: '@commander-js/extra-typings': ^0.1.0 '@octokit/rest': ^19.0.4 '@tsconfig/node16': ^1.0.3 + '@types/ejs': ^3.1.1 '@types/express': ^4.17.13 + '@types/lodash.shuffle': ^4.2.7 + '@types/node': ^18.11.18 + '@types/node-fetch': ^2.6.2 + '@types/semver': ^7.3.10 cli-core: workspace:* code-analyzer: workspace:* commander: 9.4.0 @@ -2024,10 +2031,15 @@ importers: node-fetch: 2.6.7 open: 8.4.0 semver: 7.3.7 - ts-node: 10.9.1_suuodkax7fygvcgfx5vhk45yei + ts-node: 10.9.1_vqcafhj4xvr2nzknlrdklk55zm devDependencies: '@tsconfig/node16': 1.0.3 + '@types/ejs': 3.1.1 '@types/express': 4.17.14 + '@types/lodash.shuffle': 4.2.7 + '@types/node': 18.11.18 + '@types/node-fetch': 2.6.2 + '@types/semver': 7.3.12 typescript: 4.8.4 tools/storybook: @@ -2108,7 +2120,7 @@ importers: express: 4.18.1 ora: 5.4.1 semver: 7.3.7 - ts-node: 10.9.1_suuodkax7fygvcgfx5vhk45yei + ts-node: 10.9.1_vqcafhj4xvr2nzknlrdklk55zm devDependencies: '@tsconfig/node16': 1.0.3 '@types/express': 4.17.14 @@ -3347,7 +3359,7 @@ packages: dependencies: '@babel/core': 7.12.9 '@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.12.9 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.18.9 transitivePeerDependencies: - supports-color dev: true @@ -3360,7 +3372,7 @@ packages: dependencies: '@babel/core': 7.16.12 '@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.16.12 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.18.9 transitivePeerDependencies: - supports-color dev: false @@ -6320,9 +6332,9 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.16.12 - '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 - babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.16.12 + '@babel/helper-module-imports': 7.16.0 + '@babel/helper-plugin-utils': 7.14.5 + babel-plugin-polyfill-corejs2: 0.3.0_@babel+core@7.16.12 babel-plugin-polyfill-corejs3: 0.4.0_@babel+core@7.16.12 babel-plugin-polyfill-regenerator: 0.3.0_@babel+core@7.16.12 semver: 6.3.0 @@ -6337,9 +6349,9 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.17.8 - '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 - babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.17.8 + '@babel/helper-module-imports': 7.16.0 + '@babel/helper-plugin-utils': 7.14.5 + babel-plugin-polyfill-corejs2: 0.3.0_@babel+core@7.17.8 babel-plugin-polyfill-corejs3: 0.4.0_@babel+core@7.17.8 babel-plugin-polyfill-regenerator: 0.3.0_@babel+core@7.17.8 semver: 6.3.0 @@ -7251,8 +7263,8 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.16.12 - '@babel/helper-plugin-utils': 7.19.0 - '@babel/helper-validator-option': 7.18.6 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-validator-option': 7.16.7 '@babel/plugin-transform-typescript': 7.16.8_@babel+core@7.16.12 transitivePeerDependencies: - supports-color @@ -8043,7 +8055,7 @@ packages: engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 chalk: 4.1.2 jest-message-util: 26.6.2 jest-util: 26.6.2 @@ -9789,7 +9801,7 @@ packages: resolution: {integrity: sha512-OkIJpiU2fz6HOJujhlhfIGrc8hB4ibqtf7nnbJQDerG0BqwZCfmgtK5sWzZ0TkXVRBKD5MpLrTmCYyMxoMCgPw==} engines: {node: '>= 8.9.0', npm: '>= 5.5.1'} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: false /@slack/logger/3.0.0: @@ -9816,7 +9828,7 @@ packages: '@slack/logger': 2.0.0 '@slack/types': 1.10.0 '@types/is-stream': 1.1.0 - '@types/node': 17.0.21 + '@types/node': 18.11.18 axios: 0.21.4 eventemitter3: 3.1.2 form-data: 2.5.1 @@ -13118,7 +13130,7 @@ packages: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/cacheable-request/6.0.2: @@ -13151,7 +13163,7 @@ packages: /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/cookie/0.4.1: @@ -13180,6 +13192,10 @@ packages: '@types/trusted-types': 2.0.2 dev: true + /@types/ejs/3.1.1: + resolution: {integrity: sha512-RQul5wEfY7BjWm0sYY86cmUN/pcXWGyVxWX93DFFJvcrxax5zKlieLwA3T77xJGwNcZW0YW6CYG70p1m8xPFmA==} + dev: true + /@types/eslint-scope/3.7.3: resolution: {integrity: sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==} dependencies: @@ -13209,7 +13225,7 @@ packages: /@types/express-serve-static-core/4.17.31: resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 dev: true @@ -13227,7 +13243,7 @@ packages: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 3.0.5 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/graceful-fs/4.1.5: @@ -13312,6 +13328,12 @@ packages: dependencies: '@types/node': 17.0.21 + /@types/lodash.shuffle/4.2.7: + resolution: {integrity: sha512-b+K0NBpB4WcNoQTfifuTmi5nm5mJXRw9DBdbFfBr1q1+EVoTKkClDxq/7r1sq2GZcRelMFRsFcGGHrHQgxRySg==} + dependencies: + '@types/lodash': 4.14.184 + dev: true + /@types/lodash/4.14.180: resolution: {integrity: sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==} @@ -13357,7 +13379,14 @@ packages: /@types/node-fetch/2.6.1: resolution: {integrity: sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 + form-data: 3.0.1 + dev: true + + /@types/node-fetch/2.6.2: + resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==} + dependencies: + '@types/node': 18.11.18 form-data: 3.0.1 dev: true @@ -13383,6 +13412,9 @@ packages: /@types/node/17.0.21: resolution: {integrity: sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==} + /@types/node/18.11.18: + resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} + /@types/normalize-package-data/2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -13527,7 +13559,7 @@ packages: resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} dependencies: '@types/mime': 3.0.1 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/sizzle/2.3.3: @@ -13583,11 +13615,15 @@ packages: /@types/uuid/8.3.4: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + /@types/uuid/9.0.0: + resolution: {integrity: sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==} + dev: true + /@types/vinyl/2.0.6: resolution: {integrity: sha512-ayJ0iOCDNHnKpKTgBG6Q6JOnHTj9zFta+3j2b8Ejza0e4cvRyMn0ZoLEmbPrTHe5YYRlDYPvPWVdV4cTaRyH7g==} dependencies: '@types/expect': 1.20.4 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/webpack-env/1.16.3: @@ -13597,7 +13633,7 @@ packages: /@types/webpack-sources/0.1.9: resolution: {integrity: sha512-bvzMnzqoK16PQIC8AYHNdW45eREJQMd6WG/msQWX5V2+vZmODCOPb4TJcbgRljTZZTwTM4wUMcsI8FftNA7new==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 '@types/source-list-map': 0.1.2 source-map: 0.6.1 dev: true @@ -13767,7 +13803,7 @@ packages: resolution: {integrity: sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==} requiresBuild: true dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true optional: true @@ -18859,19 +18895,6 @@ packages: - supports-color dev: true - /babel-plugin-polyfill-corejs2/0.3.3_@babel+core@7.16.12: - resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/compat-data': 7.19.3 - '@babel/core': 7.16.12 - '@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.16.12 - semver: 6.3.0 - transitivePeerDependencies: - - supports-color - dev: false - /babel-plugin-polyfill-corejs2/0.3.3_@babel+core@7.17.8: resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==} peerDependencies: @@ -21243,7 +21266,7 @@ packages: postcss-value-parser: 4.2.0 schema-utils: 2.7.1 semver: 6.3.0 - webpack: 5.70.0 + webpack: 5.70.0_webpack-cli@3.3.12 /css-loader/5.2.7_webpack@5.70.0: resolution: {integrity: sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==} @@ -28495,7 +28518,7 @@ packages: '@jest/environment': 26.6.2 '@jest/fake-timers': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 jest-mock: 26.6.2 jest-util: 26.6.2 jsdom: 16.7.0 @@ -28758,7 +28781,7 @@ packages: '@jest/source-map': 26.6.2 '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 chalk: 4.1.2 co: 4.6.0 expect: 26.6.2 @@ -29447,7 +29470,7 @@ packages: resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==} engines: {node: '>= 10.14.2'} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 graceful-fs: 4.2.9 /jest-serializer/27.5.1: @@ -29684,7 +29707,7 @@ packages: dependencies: '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 ansi-escapes: 4.3.2 chalk: 4.1.2 jest-util: 26.6.2 @@ -36855,7 +36878,7 @@ packages: neo-async: 2.6.2 schema-utils: 3.1.1 semver: 7.3.5 - webpack: 5.70.0 + webpack: 5.70.0_webpack-cli@3.3.12 /sass-loader/12.6.0_sass@1.49.9+webpack@5.70.0: resolution: {integrity: sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==} @@ -38792,7 +38815,7 @@ packages: serialize-javascript: 6.0.0 source-map: 0.6.1 terser: 5.10.0_acorn@8.8.1 - webpack: 5.70.0 + webpack: 5.70.0_webpack-cli@3.3.12 transitivePeerDependencies: - acorn @@ -39295,7 +39318,7 @@ packages: yn: 3.1.1 dev: true - /ts-node/10.9.1_suuodkax7fygvcgfx5vhk45yei: + /ts-node/10.9.1_vqcafhj4xvr2nzknlrdklk55zm: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -39314,7 +39337,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 17.0.21 + '@types/node': 18.11.18 acorn: 8.7.0 acorn-walk: 8.2.0 arg: 4.1.3 diff --git a/tools/cli-core/package.json b/tools/cli-core/package.json index 986dfbd8a02..21ce499f80a 100644 --- a/tools/cli-core/package.json +++ b/tools/cli-core/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@tsconfig/node16": "^1.0.3", + "@types/uuid": "^9.0.0", "typescript": "^4.8.3" }, "dependencies": { diff --git a/tools/release-posts/lib/environment.ts b/tools/release-posts/lib/environment.ts index f683c13e963..93b871d27c8 100644 --- a/tools/release-posts/lib/environment.ts +++ b/tools/release-posts/lib/environment.ts @@ -3,7 +3,6 @@ */ import { Logger } from 'cli-core/src/logger'; - export const getEnvVar = ( varName: string, isRequired = false ) => { const value = process.env[ varName ]; diff --git a/tools/release-posts/lib/github-api.ts b/tools/release-posts/lib/github-api.ts index 3b844ee7d08..ebd3504f2f7 100644 --- a/tools/release-posts/lib/github-api.ts +++ b/tools/release-posts/lib/github-api.ts @@ -3,6 +3,10 @@ */ import { Octokit } from '@octokit/rest'; import shuffle from 'lodash.shuffle'; + +/** + * Internal dependencies + */ import { getEnvVar } from './environment'; export type ContributorData = { diff --git a/tools/release-posts/lib/logger.ts b/tools/release-posts/lib/logger.ts deleted file mode 100644 index 9f3553b8c76..00000000000 --- a/tools/release-posts/lib/logger.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import ora, { Ora } from 'ora'; -import chalk from 'chalk'; - -/** - * Internal dependencies - */ -import { getEnvVar } from './environment'; - -const { log, error, warn } = console; -export class Logger { - private static lastSpinner: Ora | null; - private static get loggingLevel() { - return { - warn: 2, - silent: 1, - }[ getEnvVar( 'LOGGER_LEVEL' ) || 'warn' ] as number; - } - - static error( message: string ) { - Logger.failTask(); - error( chalk.red( message ) ); - process.exit( 1 ); - } - - static warn( message: string ) { - if ( Logger.loggingLevel >= 2 ) { - warn( chalk.yellow( message ) ); - } - } - - static notice( message: string ) { - if ( Logger.loggingLevel >= 1 ) { - log( chalk.green( message ) ); - } - } - - static startTask( message: string ) { - if ( Logger.loggingLevel >= 1 ) { - const spinner = ora( chalk.green( `${ message }...` ) ).start(); - Logger.lastSpinner = spinner; - } - } - - static failTask() { - if ( Logger.lastSpinner ) { - Logger.lastSpinner.fail( `${ Logger.lastSpinner.text } failed.` ); - Logger.lastSpinner = null; - } - } - - static endTask() { - if ( Logger.loggingLevel > 1 && Logger.lastSpinner ) { - Logger.lastSpinner.succeed( - `${ Logger.lastSpinner.text } complete.` - ); - Logger.lastSpinner = null; - } - } -} diff --git a/tools/release-posts/lib/render-template.ts b/tools/release-posts/lib/render-template.ts index bac0b561eb7..e9a77ffbf41 100644 --- a/tools/release-posts/lib/render-template.ts +++ b/tools/release-posts/lib/render-template.ts @@ -8,14 +8,14 @@ const TEMPLATE_DIR = join( __dirname, '..', 'templates' ); export const renderTemplate = ( templateFile: string, - templateData: unknown + templateData: ejs.Data ) => { return new Promise< string >( ( resolve, reject ) => { ejs.renderFile( join( TEMPLATE_DIR, templateFile ), templateData, {}, - function ( err: Error, str: string ) { + function ( err: Error | null, str: string ) { if ( err ) { reject( err ); } else { diff --git a/tools/release-posts/package.json b/tools/release-posts/package.json index 7fa345e8bdc..5aa9f43503a 100644 --- a/tools/release-posts/package.json +++ b/tools/release-posts/package.json @@ -4,6 +4,7 @@ "description": "Automate release post generation for Wordpress plugins", "main": " ", "scripts": { + "lint": "tsc --noEmit", "release-post": "node -r ts-node/register ./commands/release-post/index.ts" }, "author": "Automattic", @@ -14,7 +15,12 @@ }, "devDependencies": { "@tsconfig/node16": "^1.0.3", + "@types/ejs": "^3.1.1", "@types/express": "^4.17.13", + "@types/lodash.shuffle": "^4.2.7", + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.2", + "@types/semver": "^7.3.10", "typescript": "^4.8.3" }, "dependencies": { From cee68e2543ec647471f85dc6333e8275de4a5c54 Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 18 Jan 2023 12:54:47 +0530 Subject: [PATCH 27/58] Remove post_updated_messages filter since the hooks will be expecting post to be set. We are calling `post_updated_messages` filter when displaying order edit screen in HPOS. However, this is a post based filter, which means that functions hooked to it might be expecting the global $post object to already be set. This unfortunately may cause warnings or fatals, so we remove this filter call from HPOS page. --- plugins/woocommerce/src/Internal/Admin/Orders/Edit.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php b/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php index c1df8e59130..9ae7e049508 100644 --- a/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php +++ b/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php @@ -230,13 +230,6 @@ class Edit { */ $messages = apply_filters( 'woocommerce_order_updated_messages', array() ); - /** - * Backward compatibility for displaying messages using the post fields. - * - * @since 7.4.0. (Although available earlier by the posts based screen). - */ - $messages = apply_filters( 'post_updated_messages', $messages ); - $message = $this->message; if ( isset( $_GET['message'] ) ) { $message = absint( $_GET['message'] ); From 32cbd6ca403815d7b4535c17824085aac3ea8ffc Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 18 Jan 2023 12:56:59 +0530 Subject: [PATCH 28/58] Make order updated messages to be compatible with both posts and HPOS. --- .../admin/class-wc-admin-post-types.php | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php index 5680eec7f88..817530fe0e2 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php @@ -41,6 +41,7 @@ class WC_Admin_Post_Types { // Admin notices. add_filter( 'post_updated_messages', array( $this, 'post_updated_messages' ) ); + add_filter( 'woocommerce_order_updated_messages', array( $this, 'order_updated_messages' ) ); add_filter( 'bulk_post_updated_messages', array( $this, 'bulk_post_updated_messages' ), 10, 2 ); // Disable Auto Save. @@ -145,24 +146,7 @@ class WC_Admin_Post_Types { 10 => sprintf( __( 'Product draft updated. Preview product', 'woocommerce' ), esc_url( add_query_arg( 'preview', 'true', get_permalink( $post->ID ) ) ) ), ); - $messages['shop_order'] = array( - 0 => '', // Unused. Messages start at index 1. - 1 => __( 'Order updated.', 'woocommerce' ), - 2 => __( 'Custom field updated.', 'woocommerce' ), - 3 => __( 'Custom field deleted.', 'woocommerce' ), - 4 => __( 'Order updated.', 'woocommerce' ), - 5 => __( 'Revision restored.', 'woocommerce' ), - 6 => __( 'Order updated.', 'woocommerce' ), - 7 => __( 'Order saved.', 'woocommerce' ), - 8 => __( 'Order submitted.', 'woocommerce' ), - 9 => sprintf( - /* translators: %s: date */ - __( 'Order scheduled for: %s.', 'woocommerce' ), - '' . date_i18n( __( 'M j, Y @ G:i', 'woocommerce' ), strtotime( $post->post_date ) ) . '' - ), - 10 => __( 'Order draft updated.', 'woocommerce' ), - 11 => __( 'Order updated and sent.', 'woocommerce' ), - ); + $messages = $this->order_updated_messages( $messages ); $messages['shop_coupon'] = array( 0 => '', // Unused. Messages start at index 1. @@ -185,6 +169,46 @@ class WC_Admin_Post_Types { return $messages; } + /** + * Add messages when an order is updated. + * + * @param array $messages Array of messages. + * + * @return array + */ + public function order_updated_messages( array $messages ) { + global $post, $theorder; + + if ( ! isset( $theorder ) || ! $theorder instanceof WC_Abstract_Order ) { + if ( ! isset( $post ) || 'shop_order' !== $post->post_type ) { + return $messages; + } else { + \Automattic\WooCommerce\Utilities\OrderUtil::init_theorder_object( $post ); + } + } + + $messages['shop_order'] = array( + 0 => '', // Unused. Messages start at index 1. + 1 => __( 'Order updated.', 'woocommerce' ), + 2 => __( 'Custom field updated.', 'woocommerce' ), + 3 => __( 'Custom field deleted.', 'woocommerce' ), + 4 => __( 'Order updated.', 'woocommerce' ), + 5 => __( 'Revision restored.', 'woocommerce' ), + 6 => __( 'Order updated.', 'woocommerce' ), + 7 => __( 'Order saved.', 'woocommerce' ), + 8 => __( 'Order submitted.', 'woocommerce' ), + 9 => sprintf( + /* translators: %s: date */ + __( 'Order scheduled for: %s.', 'woocommerce' ), + '' . date_i18n( __( 'M j, Y @ G:i', 'woocommerce' ), strtotime( $theorder->get_date_created() ) ) . '' + ), + 10 => __( 'Order draft updated.', 'woocommerce' ), + 11 => __( 'Order updated and sent.', 'woocommerce' ), + ); + + return $messages; + } + /** * Specify custom bulk actions messages for different post types. * From 1c894ff950a523485e005cc93795042f6f60843e Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 18 Jan 2023 12:58:40 +0530 Subject: [PATCH 29/58] Add changelog. --- plugins/woocommerce/changelog/fix-36841 | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/woocommerce/changelog/fix-36841 diff --git a/plugins/woocommerce/changelog/fix-36841 b/plugins/woocommerce/changelog/fix-36841 new file mode 100644 index 00000000000..7533f21c5a4 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-36841 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Make order edit messages compatible with both posts and theorder object. From 8079c157bca1fdc6e7e2f876e9b217c79f7b8138 Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 18 Jan 2023 13:03:17 +0530 Subject: [PATCH 30/58] Remove commented out code for cleaniliness. --- .../workflows/cot-pr-build-and-e2e-tests.yml | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/.github/workflows/cot-pr-build-and-e2e-tests.yml b/.github/workflows/cot-pr-build-and-e2e-tests.yml index fd8abdaa596..02cfb498434 100644 --- a/.github/workflows/cot-pr-build-and-e2e-tests.yml +++ b/.github/workflows/cot-pr-build-and-e2e-tests.yml @@ -118,102 +118,3 @@ jobs: ${{ env.ALLURE_REPORT_DIR }} if-no-files-found: ignore retention-days: 5 - -# test-summary: -# name: Post test results -# if: | -# always() && -# ! github.event.pull_request.head.repo.fork && -# ( -# contains( needs.*.result, 'success' ) || -# contains( needs.*.result, 'failure' ) -# ) -# runs-on: ubuntu-20.04 -# permissions: -# contents: read -# needs: [cot-api-tests-run, cot-e2e-tests-run] -# steps: -# - name: Create dirs -# run: | -# mkdir -p repo -# mkdir -p artifacts/api -# mkdir -p artifacts/e2e -# mkdir -p output -# -# - name: Checkout code -# uses: actions/checkout@v3 -# with: -# path: repo -# -# - name: Download API test report artifact -# uses: actions/download-artifact@v3 -# with: -# name: api-test-report---pr-${{ github.event.number }} -# path: artifacts/api -# -# - name: Download Playwright E2E test report artifact -# uses: actions/download-artifact@v3 -# with: -# name: e2e-test-report---pr-${{ github.event.number }} -# path: artifacts/e2e -# -# - name: Prepare test summary -# id: prepare-test-summary -# uses: actions/github-script@v6 -# env: -# API_SUMMARY_PATH: ${{ github.workspace }}/artifacts/api/allure-report/widgets/summary.json -# E2E_PW_SUMMARY_PATH: ${{ github.workspace }}/artifacts/e2e/allure-report/widgets/summary.json -# PR_NUMBER: ${{ github.event.number }} -# SHA: ${{ github.event.pull_request.head.sha }} -# with: -# result-encoding: string -# script: | -# const script = require( './repo/.github/workflows/scripts/prepare-test-summary.js' ) -# return await script( { core } ) -# -# - name: Find PR comment by github-actions[bot] -# uses: peter-evans/find-comment@v2 -# id: find-comment -# with: -# issue-number: ${{ github.event.pull_request.number }} -# comment-author: 'github-actions[bot]' -# body-includes: Test Results Summary -# -# - name: Create or update PR comment -# uses: peter-evans/create-or-update-comment@v2 -# with: -# comment-id: ${{ steps.find-comment.outputs.comment-id }} -# issue-number: ${{ github.event.pull_request.number }} -# body: ${{ steps.prepare-test-summary.outputs.result }} -# edit-mode: replace -# -# publish-test-reports: -# name: Publish test reports -# if: | -# always() && -# ! github.event.pull_request.head.repo.fork && -# ( -# contains( needs.*.result, 'success' ) || -# contains( needs.*.result, 'failure' ) -# ) -# runs-on: ubuntu-20.04 -# needs: [cot-api-tests-run, cot-e2e-tests-run] -# env: -# GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }} -# PR_NUMBER: ${{ github.event.number }} -# RUN_ID: ${{ github.run_id }} -# COMMIT_SHA: ${{ github.event.pull_request.head.sha }} -# steps: -# - name: Publish test reports -# env: -# API_ARTIFACT: api-test-report---pr-${{ github.event.number }} -# E2E_ARTIFACT: e2e-test-report---pr-${{ github.event.number }} -# run: | -# gh workflow run publish-test-reports-pr.yml \ -# -f run_id=$RUN_ID \ -# -f api_artifact=$API_ARTIFACT \ -# -f e2e_artifact=$E2E_ARTIFACT \ -# -f pr_number=$PR_NUMBER \ -# -f commit_sha=$COMMIT_SHA \ -# -f s3_root=public \ -# --repo woocommerce/woocommerce-test-reports From 6bc3ad10d607a0290658b8bc1a9613cf6de2eeb5 Mon Sep 17 00:00:00 2001 From: Vedanshu Jain Date: Wed, 18 Jan 2023 13:03:56 +0530 Subject: [PATCH 31/58] Changelog --- plugins/woocommerce/changelog/remove-comment_workflow | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 plugins/woocommerce/changelog/remove-comment_workflow diff --git a/plugins/woocommerce/changelog/remove-comment_workflow b/plugins/woocommerce/changelog/remove-comment_workflow new file mode 100644 index 00000000000..bc03365ccc6 --- /dev/null +++ b/plugins/woocommerce/changelog/remove-comment_workflow @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Build process change. + + From 78759685f147ec3aeca7b992d27f84b76e0250f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Wed, 18 Jan 2023 17:36:36 +0100 Subject: [PATCH 32/58] Remove button styles if the block theme has button styles defined in theme.json (#36225) * Remove button styles if the block theme has their own * Fix button style in TT2 --- ...move-button-styles-when-custom-block-theme | 4 + .../client/legacy/css/twenty-twenty-two.scss | 1 - .../legacy/css/woocommerce-blocktheme.scss | 13 +- .../client/legacy/css/woocommerce.scss | 189 +++++++++--------- .../includes/wc-conditional-functions.php | 33 +++ .../includes/wc-template-functions.php | 6 + 6 files changed, 152 insertions(+), 94 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme diff --git a/plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme b/plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme new file mode 100644 index 00000000000..88297cd396a --- /dev/null +++ b/plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Remove default WooCommerce button styles if using a block theme which adds button styles in theme.json diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss index 3105e01ce15..173a75b8d2e 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss @@ -769,7 +769,6 @@ $tt2-gray: #f7f7f7; #coupon_code, .actions .button { - height: auto; margin-right: 0; } diff --git a/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss b/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss index 1701887309a..137756a3488 100644 --- a/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss +++ b/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss @@ -121,6 +121,11 @@ + .single_add_to_cart_button { min-height: 51px; + // We need to remove top and bottom padding because we are setting a fixed + // height. This is to prevent the button from being cut off. !important is + // needed to override the Elements API styles, which also use !important. + padding-top: 0 !important; + padding-bottom: 0 !important; } } @@ -306,8 +311,14 @@ #coupon_code, .actions .button { height: 50px; - padding: 0.9rem 1.1rem; font-size: var(--wp--preset--font-size--small); + padding-left: 1.1rem; + padding-right: 1.1rem; + // We need to remove top and bottom padding because we are setting a fixed + // height. This is to prevent the button from being cut off. !important is + // needed to override the Elements API styles, which also use !important. + padding-top: 0 !important; + padding-bottom: 0 !important; } @media only screen and ( max-width: 768px ) { diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss index efa4f5432d6..cfd87abad71 100644 --- a/plugins/woocommerce/client/legacy/css/woocommerce.scss +++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss @@ -600,6 +600,7 @@ p.demo_store, } .button { + display: inline-block; margin-top: 1em; } @@ -686,98 +687,6 @@ p.demo_store, } } - /** - * Buttons - */ - a.button, - button.button, - input.button, - #respond input#submit { - font-size: 100%; - margin: 0; - line-height: 1; - cursor: pointer; - position: relative; - text-decoration: none; - overflow: visible; - padding: 0.618em 1em; - font-weight: 700; - border-radius: 3px; - left: auto; - color: $secondarytext; - background-color: $secondary; - border: 0; - display: inline-block; - background-image: none; - box-shadow: none; - text-shadow: none; - - &.loading { - opacity: 0.25; - padding-right: 2.618em; - - &::after { - font-family: "WooCommerce"; - content: "\e01c"; - vertical-align: top; - font-weight: 400; - position: absolute; - top: 0.618em; - right: 1em; - animation: spin 2s linear infinite; - } - } - - &.added::after { - font-family: "WooCommerce"; - content: "\e017"; - margin-left: 0.53em; - vertical-align: bottom; - } - - &:hover { - background-color: darken($secondary, 5%); - text-decoration: none; - background-image: none; - color: $secondarytext; - } - - &.alt { - background-color: $primary; - color: $primarytext; - -webkit-font-smoothing: antialiased; - - &:hover { - background-color: darken($primary, 5%); - color: $primarytext; - } - - &.disabled, - &:disabled, - &:disabled[disabled], - &.disabled:hover, - &:disabled:hover, - &:disabled[disabled]:hover { - background-color: $primary; - color: $primarytext; - } - } - - &:disabled, - &.disabled, - &:disabled[disabled] { - color: inherit; - cursor: not-allowed; - opacity: 0.5; - padding: 0.618em 1em; - - &:hover { - color: inherit; - background-color: $secondary; - } - } - } - .cart .button, .cart input.button { float: none; @@ -1741,6 +1650,102 @@ p.demo_store, } } + +/** + * Buttons + */ +.woocommerce:where(body:not(.woocommerce-block-theme-has-button-styles)), +:where(body:not(.woocommerce-block-theme-has-button-styles)) .woocommerce { + a.button, + button.button, + input.button, + #respond input#submit { + font-size: 100%; + margin: 0; + line-height: 1; + cursor: pointer; + position: relative; + text-decoration: none; + overflow: visible; + padding: 0.618em 1em; + font-weight: 700; + border-radius: 3px; + left: auto; + color: $secondarytext; + background-color: $secondary; + border: 0; + display: inline-block; + background-image: none; + box-shadow: none; + text-shadow: none; + + &.loading { + opacity: 0.25; + padding-right: 2.618em; + + &::after { + font-family: "WooCommerce"; + content: "\e01c"; + vertical-align: top; + font-weight: 400; + position: absolute; + top: 0.618em; + right: 1em; + animation: spin 2s linear infinite; + } + } + + &.added::after { + font-family: "WooCommerce"; + content: "\e017"; + margin-left: 0.53em; + vertical-align: bottom; + } + + &:hover { + background-color: darken($secondary, 5%); + text-decoration: none; + background-image: none; + color: $secondarytext; + } + + &.alt { + background-color: $primary; + color: $primarytext; + -webkit-font-smoothing: antialiased; + + &:hover { + background-color: darken($primary, 5%); + color: $primarytext; + } + + &.disabled, + &:disabled, + &:disabled[disabled], + &.disabled:hover, + &:disabled:hover, + &:disabled[disabled]:hover { + background-color: $primary; + color: $primarytext; + } + } + + &:disabled, + &.disabled, + &:disabled[disabled] { + color: inherit; + cursor: not-allowed; + opacity: 0.5; + padding: 0.618em 1em; + + &:hover { + color: inherit; + background-color: $secondary; + } + } + } +} + .woocommerce-no-js { form.woocommerce-form-login, diff --git a/plugins/woocommerce/includes/wc-conditional-functions.php b/plugins/woocommerce/includes/wc-conditional-functions.php index d992cf24c2c..64651baab16 100644 --- a/plugins/woocommerce/includes/wc-conditional-functions.php +++ b/plugins/woocommerce/includes/wc-conditional-functions.php @@ -541,3 +541,36 @@ function wc_wp_theme_get_element_class_name( $element ) { return ''; } + +/** + * Given an element name, returns true or false depending on whether the + * current theme has styles for that element defined in theme.json. + * + * If the theme is not a block theme or the WP-related function is not defined, + * return false. + * + * @param string $element The name of the element. + * + * @since 7.4.0 + * @return bool + */ +function wc_block_theme_has_styles_for_element( $element ) { + if ( + ! wc_current_theme_is_fse_theme() || + wc_wp_theme_get_element_class_name( $element ) === '' + ) { + return false; + } + + if ( function_exists( 'wp_get_global_styles' ) ) { + $global_styles = wp_get_global_styles(); + if ( + array_key_exists( 'elements', $global_styles ) && + array_key_exists( $element, $global_styles['elements'] ) + ) { + return is_array( $global_styles['elements'][ $element ] ); + } + } + + return false; +} diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php index e07f77e4134..c376d601e47 100644 --- a/plugins/woocommerce/includes/wc-template-functions.php +++ b/plugins/woocommerce/includes/wc-template-functions.php @@ -339,6 +339,12 @@ function wc_body_class( $classes ) { } } + if ( wc_block_theme_has_styles_for_element( 'button' ) ) { + + $classes[] = 'woocommerce-block-theme-has-button-styles'; + + } + $classes[] = 'woocommerce-no-js'; add_action( 'wp_footer', 'wc_no_js' ); From 17086e05c5c7ec4bb3033d33b951ae6c66e5c681 Mon Sep 17 00:00:00 2001 From: Jeremy Lind Date: Wed, 18 Jan 2023 08:38:01 -0800 Subject: [PATCH 33/58] Fix units in function doc comment (#36353) * Fix units in function doc comment Typo in doc comments for available unit options. * Add changelog --- plugins/woocommerce/changelog/CHANGELOG.md | 4 ++++ plugins/woocommerce/includes/wc-formatting-functions.php | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 plugins/woocommerce/changelog/CHANGELOG.md diff --git a/plugins/woocommerce/changelog/CHANGELOG.md b/plugins/woocommerce/changelog/CHANGELOG.md new file mode 100644 index 00000000000..70b9206c4f4 --- /dev/null +++ b/plugins/woocommerce/changelog/CHANGELOG.md @@ -0,0 +1,4 @@ +Significance: minor +Type: tweak + +Fix units in function doc comment diff --git a/plugins/woocommerce/includes/wc-formatting-functions.php b/plugins/woocommerce/includes/wc-formatting-functions.php index e5e34e33a6b..9f05e82c76f 100644 --- a/plugins/woocommerce/includes/wc-formatting-functions.php +++ b/plugins/woocommerce/includes/wc-formatting-functions.php @@ -105,10 +105,10 @@ function wc_get_filename_from_url( $file_url ) { * * @param int|float $dimension Dimension. * @param string $to_unit Unit to convert to. - * Options: 'in', 'm', 'cm', 'm'. + * Options: 'in', 'mm', 'cm', 'm'. * @param string $from_unit Unit to convert from. * Defaults to ''. - * Options: 'in', 'm', 'cm', 'm'. + * Options: 'in', 'mm', 'cm', 'm'. * @return float */ function wc_get_dimension( $dimension, $to_unit, $from_unit = '' ) { From f3a27839e8418287815bf7adc17abeec4d8ef4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Wed, 18 Jan 2023 17:51:11 +0100 Subject: [PATCH 34/58] Fix notices styling in Twenty Twenty-Three (#36475) * Fix TT3 notices CSS identation * Fix notices styling in Twenty Twenty-Three --- .../changelog/fix-notices-styling-tt3 | 4 + .../legacy/css/twenty-twenty-three.scss | 101 +++++++++--------- 2 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-notices-styling-tt3 diff --git a/plugins/woocommerce/changelog/fix-notices-styling-tt3 b/plugins/woocommerce/changelog/fix-notices-styling-tt3 new file mode 100644 index 00000000000..1b5d1d635fd --- /dev/null +++ b/plugins/woocommerce/changelog/fix-notices-styling-tt3 @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix notices styling in Twenty Twenty-Three diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss index 0db0c019bc0..514389c4515 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss @@ -1115,60 +1115,57 @@ } /* - Notice messages (like 'Added to cart', 'Billing address needs to be filled in', etc. - */ - .woocommerce-message, - .woocommerce-error, - .woocommerce-info { - background-color: rgba( 176, 176, 176, 0.6 ); - color: #222; - border-top-color: var( --wp--preset--color--primary ); - border-top-style: solid; - border-top-width: 2px; - padding: 1rem 1.5rem; - margin-bottom: 2rem; - list-style: none; - font-size: var( --wp--preset--font-size--small ); - display: flow-root; +Notice messages (like 'Added to cart', 'Billing address needs to be filled in', etc. +*/ +.woocommerce-message, +.woocommerce-error, +.woocommerce-info { + background-color: rgba( 176, 176, 176, 0.6 ); + color: #222; + border-top-color: var( --wp--preset--color--primary ); + border-top-style: solid; + border-top-width: 2px; + padding: 1rem 1.5rem 1rem 3.5rem; + margin-bottom: 2rem; + list-style: none; + font-size: var( --wp--preset--font-size--small ); + position: relative; - &[role='alert']::before { - background: #d5d5d5; - color: black; - border-radius: 5rem; - font-size: 1rem; - padding-left: 3px; - padding-right: 3px; - margin-right: 1rem; - } + @include clearfix(); - a { - color: var( --wp--preset--color--contrast ); + &[role='alert']::before { + background: #d5d5d5; + color: black; + border-radius: 5rem; + font-size: 1rem; + content: "\e028"; + display: inline-block; + margin-right: 1rem; + height: 1.5em; + line-height: 1.5em; + text-align: center; + width: 1.5em; + position: absolute; + top: 1em; + left: 1.5em; + } - .button { - margin-top: -0.5rem; - border: none; - padding: 0.5rem 1rem; - } - } - } + a.button { + margin-bottom: -0.5rem; + margin-top: -0.5rem; + border: none; + padding: 0.5rem 1rem; + } +} - .woocommerce-error[role='alert'] { - margin: 0; - - &::before { - content: 'X'; - padding-right: 4px; - padding-left: 4px; - } - - li { - display: inline-block; - } - } - - .woocommerce-message { - &[role='alert']::before { - content: '\2713'; - } - } +.woocommerce-error { + &[role='alert']::before { + content: 'X'; + } +} +.woocommerce-message { + &[role='alert']::before { + content: '\2713'; + } +} From 764f8d4490546e114e52d6c9cd98beca5521bb57 Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Wed, 18 Jan 2023 10:07:57 -0800 Subject: [PATCH 35/58] When HPOS is enabled, wc_get_order() should still utilize global post and order objects (if needed). --- .../changelog/fix-35561-wc-get-order | 4 +++ .../includes/class-wc-order-factory.php | 35 ++++++++++++++++--- .../order/class-wc-tests-order-functions.php | 27 ++++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-35561-wc-get-order diff --git a/plugins/woocommerce/changelog/fix-35561-wc-get-order b/plugins/woocommerce/changelog/fix-35561-wc-get-order new file mode 100644 index 00000000000..6d3132e2a9a --- /dev/null +++ b/plugins/woocommerce/changelog/fix-35561-wc-get-order @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensure wc_get_order() works without arguments when HPOS is enabled. diff --git a/plugins/woocommerce/includes/class-wc-order-factory.php b/plugins/woocommerce/includes/class-wc-order-factory.php index bc096b0016d..543f2bf3915 100644 --- a/plugins/woocommerce/includes/class-wc-order-factory.php +++ b/plugins/woocommerce/includes/class-wc-order-factory.php @@ -10,6 +10,8 @@ defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Utilities\OrderUtil; + /** * Order factory class */ @@ -165,10 +167,8 @@ class WC_Order_Factory { * @return int|bool false on failure */ public static function get_order_id( $order ) { - global $post; - - if ( false === $order && is_a( $post, 'WP_Post' ) && 'shop_order' === get_post_type( $post ) ) { - return absint( $post->ID ); + if ( false === $order ) { + return self::get_global_order_id(); } elseif ( is_numeric( $order ) ) { return $order; } elseif ( $order instanceof WC_Abstract_Order ) { @@ -180,6 +180,33 @@ class WC_Order_Factory { } } + /** + * Try to determine the current order ID based on available global state. + * + * @return false|int + */ + private static function get_global_order_id() { + global $post; + global $theorder; + + // Initialize the global $theorder object if necessary. + if ( ! isset( $theorder ) || ! $theorder instanceof WC_Abstract_Order ) { + if ( ! isset( $post ) || 'shop_order' !== $post->post_type ) { + return false; + } else { + OrderUtil::init_theorder_object( $post ); + } + } + + if ( $theorder instanceof WC_Order ) { + return $theorder->get_id(); + } elseif ( is_a( $post, 'WP_Post' ) && 'shop_order' === get_post_type( $post ) ) { + return absint( $post->ID ); + } else { + return false; + } + } + /** * Gets the class name bunch of order instances should have based on their IDs. * diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php index eb110323302..fb5e02055df 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php @@ -162,6 +162,11 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case { * @group test */ public function test_wc_get_order() { + global $post; + global $theorder; + + $original_post = $post; + $original_theorder = $theorder; $order = WC_Helper_Order::create_order(); @@ -178,11 +183,29 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case { $post = $this->factory->post->create_and_get( array( 'post_type' => 'post' ) ); $this->assertFalse( wc_get_order( $post->ID ) ); + // Assert the return when $the_order args is a random (incorrect) id. + $this->assertFalse( wc_get_order( 123456 ) ); + // Assert the return when $the_order args is false. $this->assertFalse( wc_get_order( false ) ); - // Assert the return when $the_order args is a random (incorrect) id. - $this->assertFalse( wc_get_order( 123456 ) ); + $post = get_post( $order->get_id() ); + $this->assertInstanceOf( + 'WC_Order', + wc_get_order(), + 'If no order ID is specified, wc_get_order() will use the global $post object to try and determine the current order.' + ); + + unset( $post ); + $theorder = $order; + $this->assertInstanceOf( + 'WC_Order', + wc_get_order(), + 'If no order ID is specified, wc_get_order() will use the global $theorder object to try and determine the current order.' + ); + + $post = $original_post; + $theorder = $original_theorder; } /** From 403210185ced97c43b86734b973ac0efc18250b2 Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Wed, 18 Jan 2023 10:29:55 -0800 Subject: [PATCH 36/58] To fully test a function that uses globals, we need to suppress the global override warning. --- .../legacy/unit-tests/order/class-wc-tests-order-functions.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php index fb5e02055df..4df224a5e58 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php @@ -165,6 +165,7 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case { global $post; global $theorder; + // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited $original_post = $post; $original_theorder = $theorder; @@ -206,6 +207,7 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case { $post = $original_post; $theorder = $original_theorder; + // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited } /** From 1337a6d36e73a1a31651474db4a90433d7a03b0a Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Wed, 18 Jan 2023 11:11:02 -0800 Subject: [PATCH 37/58] Add product tour to new product management experience (#36428) * Add product tour container and modal * Fix modal open class name * Add product tour * Add changelog entry * Move product tour state logic into hook * Fix tour selectors for pricing and actions * Add tests around product tour container * Add tests around useProductTour hook * Make tour responsive * Use tabs instead of spaces * Fix more scss lint errors * Remove extra whitespace --- .../abstracts/_variables.scss | 1 + .../client/products/add-product-page.tsx | 2 + .../client/products/tour/index.tsx | 1 + .../products/tour/product-tour-container.tsx | 21 ++++ .../products/tour/product-tour-modal.scss | 76 +++++++++++++ .../products/tour/product-tour-modal.tsx | 60 +++++++++++ .../client/products/tour/product-tour.png | Bin 0 -> 8150 bytes .../client/products/tour/product-tour.tsx | 96 +++++++++++++++++ .../tour/test/product-tour-container.spec.tsx | 94 ++++++++++++++++ .../tour/test/use-product-tour.spec.tsx | 101 ++++++++++++++++++ .../client/products/tour/use-product-tour.ts | 50 +++++++++ plugins/woocommerce/changelog/add-36322 | 4 + 12 files changed, 506 insertions(+) create mode 100644 plugins/woocommerce-admin/client/products/tour/index.tsx create mode 100644 plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx create mode 100644 plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss create mode 100644 plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx create mode 100644 plugins/woocommerce-admin/client/products/tour/product-tour.png create mode 100644 plugins/woocommerce-admin/client/products/tour/product-tour.tsx create mode 100644 plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx create mode 100644 plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx create mode 100644 plugins/woocommerce-admin/client/products/tour/use-product-tour.ts create mode 100644 plugins/woocommerce/changelog/add-36322 diff --git a/packages/js/internal-style-build/abstracts/_variables.scss b/packages/js/internal-style-build/abstracts/_variables.scss index 73155c89baf..812b09a7464 100644 --- a/packages/js/internal-style-build/abstracts/_variables.scss +++ b/packages/js/internal-style-build/abstracts/_variables.scss @@ -45,6 +45,7 @@ $alert-green: $valid-green; $adminbar-height: 32px; $adminbar-height-mobile: 46px; $admin-menu-width: 160px; +$admin-menu-width-collapsed: 36px; // wp-admin colors $wp-admin-background: #f1f1f1; diff --git a/plugins/woocommerce-admin/client/products/add-product-page.tsx b/plugins/woocommerce-admin/client/products/add-product-page.tsx index 6b0ab353f11..7702b9e5ac8 100644 --- a/plugins/woocommerce-admin/client/products/add-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/add-product-page.tsx @@ -8,6 +8,7 @@ import { useEffect } from '@wordpress/element'; * Internal dependencies */ import { ProductForm } from './product-form'; +import { ProductTourContainer } from './tour'; import './product-page.scss'; const AddProductPage: React.FC = () => { @@ -18,6 +19,7 @@ const AddProductPage: React.FC = () => { return (
+
); }; diff --git a/plugins/woocommerce-admin/client/products/tour/index.tsx b/plugins/woocommerce-admin/client/products/tour/index.tsx new file mode 100644 index 00000000000..4aaf84f546c --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/index.tsx @@ -0,0 +1 @@ +export * from './product-tour-container'; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx new file mode 100644 index 00000000000..86bec8ccbc8 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import { ProductTour } from './product-tour'; +import { ProductTourModal } from './product-tour-modal'; +import { useProductTour } from './use-product-tour'; + +export const ProductTourContainer: React.FC = () => { + const { dismissModal, endTour, isModalHidden, isTouring, startTour } = + useProductTour(); + + if ( isTouring ) { + return ; + } + + if ( isModalHidden ) { + return null; + } + + return ; +}; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss new file mode 100644 index 00000000000..c515f50e2f4 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss @@ -0,0 +1,76 @@ +.woocommerce-product-tour-modal { + max-width: 400px; + position: fixed; + left: $admin-menu-width + $gap-large; + bottom: $gap-large; + // This puts the modal on top of the RichTextEditor toolbars. + z-index: 31; + + @include breakpoint( '<960px' ) { + left: $admin-menu-width-collapsed + $gap-large; + } + + @include breakpoint( '<782px' ) { + display: none; + } + + .components-modal__content { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + } + + .components-modal__header { + position: static; + padding-top: $gap; + padding-left: $gap; + padding-right: $gap; + height: auto; + + .components-button { + position: absolute; + top: $gap; + right: $gap; + left: auto; + } + + .components-modal__header-heading { + font-size: 16px; + line-height: 24px; + } + } + + .woocommerce-product-tour-modal__header-img { + background: #c5d9ed; + order: -1; + padding: 28px 28px 0 28px; + + img { + max-width: 286px; + display: block; + margin: 0 auto; + } + } + + .woocommerce-product-tour-modal__content { + padding: $gap-smaller $gap $gap $gap; + + > p:first-child { + margin-top: 0; + } + } + + .woocommerce-product-tour-modal__actions { + text-align: right; + margin-top: 28px; + + button { + margin-left: $gap-smaller; + } + } +} + +.woocommerce-product-tour-modal__overlay { + position: static; +} diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx new file mode 100644 index 00000000000..95a60b2997a --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Modal } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ProductTourImage from './product-tour.png'; +import './product-tour-modal.scss'; + +type ProductTourModalProps = { + onClose: () => void; + onStart: () => void; +}; + +export const ProductTourModal: React.FC< ProductTourModalProps > = ( { + onClose, + onStart, +} ) => { + return ( + onClose() } + overlayClassName="woocommerce-product-tour-modal__overlay" + shouldCloseOnClickOutside={ false } + title={ __( 'Meet the product editing form', 'woocommerce' ) } + > +
+ { +
+
+

+ { __( + 'Let us show you how to navigate the form and create this product from start to finish in no time.', + 'woocommerce' + ) } +

+
+ + +
+
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.png b/plugins/woocommerce-admin/client/products/tour/product-tour.png new file mode 100644 index 0000000000000000000000000000000000000000..858df4b9322a16178f5e2f0feff977d901653ccd GIT binary patch literal 8150 zcmbW6cRZWx-}rB(#c>o((NeVZP^&tO8VRk{R*5Y|P;JekF=B!yYw033k<0E0ElG4PF^ds933!TR{-T@L`@`R(V!aLe%gD)^GY!%+JMP|$v64*bDn ze_i)F02D>A?cX~J07A~UG_D)_Ff5JwzaD)UFSc4HIPG8k1SB4em3-`G-&A zU`!|rHZ2$tuETyof-)N$*N4*2|J*u$a#E?~;ln&jg9lq21g2hsi9r#Mvli1Do^$!N zp3>-?-q^HCEzKGHn%pxNMOB=kV+hAb1KJCf(|Z(W5@&oO zig5u~VV6^YAd{k8_bLZ8dMhHPypX_@=94&-SLjpErrBJ|igr{P&@A=(v%ihd>j*^viv5Ugk~D>{6(I zgj_HOP?E+16|)SmZ)r4ZAB-lW*Sq|y_43$&uP??Kfd_BJ&5>fRD(8}YCYoeruf2Kk z;#g0ai-_aKw03vE_E@T5!Kb4@j^>wRsd2T7nRBIu2YH_DckOxZ*_rS5} za!w{dD)&clhXH^19W&(4cFefP?KKor!tly|VCLuI3d0{eUZ}AbEpi5QoU@e?t2T;W z78&VDXSaqQB3Q4tIkd(~JI=Hx>z#B*qexI>xP?DXIc=fg9$8J|7qVKJN=na-hVSFP_3;?$_QaBNx5@qj6Hp*t%T z)ZabZ2ko=Ut$a{DCfJEv4=@;&t}{x?>>9t9$H&LV`nNlk&HNY~9Bf#Z`#v$fy}$I^ z+>lH_`U%gnOoe26tZ=8)Nk5+To>aODo#fH3bEvU-t`%|td&=cJ>Dn{~VA^7H9P;Bl0m|VSj zJ8i-Bqj=bNHa$Qs(6qGAt8197v>kZb&lW=;E+0u)rg{7}W0YD!IAsu_%a+&Zh20)g ztsHLND8zj$$Puib{LFR&s3ROrWl=Kt7GJxfC-wNi=C{u1$=SCR%)zyiTUd^{KsXlLf<}D zlhoCoDy-GB3{Fy9Oa^V2qob-U@(g<59&6fNDwX=~egss$^!LpL60d^!Fu?>Pf2QJI zffB^ok3gESq?SQRQyMq%P(MPlM}Okh#{xf&G>14%8;L+Dw-E4^46e04-Cn!j+axf> z#X!@`E*))_znxn);FXCui#*^nM}pGod4(CfnEKGe6qdFI5Mq3^4>byqRJGi#%Y)vOX3$Q!l*^8*L{e_m6cJ__n-hBga;H+w1*~guO z+nRQhO<^8Fan3$1B4Qa~+~>0LhoUD}5+#Mq3Q6L_NXoJ253fmxHClZ{k)}fks=(9( z7r5E(aw9h09o5aHE{804suv5s`KrV$B=%Zh#;>q*TEnfAqAj_FIu`@nP@-m~18J8E zB)=G7lCR80L#)`A7?}X#eA?|*Uy6rrZd1K`U4@&l;`JT~>x5~@=2d)vCa*hX|9exVWgJ}uyosM{C7Lnh4gMti+KF*UqJzT!; zj5^$Z-noXb;G*epzC*n)yp&7iKp%s z3c{@)U;R?-&7t_PR4h1Jo)_RWm7_&7hiYYJ`9VAO(WF*A-?BE7%7732Jej!cLNXO*TQ$` zldAOM%V@x+oxq&BTF-$kxmLbFkW@7>4iwHeW=_tT8Zq-aW+vXxViGEE#+0*d>30j2 zu(zoY2xS1G`Gjd2tqZ|yV~S=wJC8DTO$0m%%{_T0A#Z7XsdQ?=_{sF_kwa!6t;C7{ zR6B1yr55SuYe3AkT0G-w{oo7~jS8RFmwFIhsy2L#S%P8SHZ-0g=xAvpHl=p0k=KN5 z8ONu=6wjhYx#ssWsJNng{TFq0^+rYOqAjsU!)3(otx88y^uW!@q;H$Q$341zIFrQ> z0Xk3dU7ASdL- zahT$10RdejBct3d4T~Zo(WQ_S77>w+!{K@z{QUimyuG)r3PV={_D{?FJ9UnobD zomj!z*rZL5_vDNnnm>gX9|`~o>gunG8G$n_U~+rSUeEkzCR1N!06yF^PQOmw+?1P| zx~Z+LtxIa0GQM+GF2TGu8-)}(I3VTUHQFc*qwPhYw#?=B#u(BRSzzoL{gGo@+#;mX zaHK+pPd&0WFu=*K?O@MstCmsCqjUZU@ZlT{00ucRK+q!=7@#K4Bm|u0|6}^eMfM5h zZPoY}-gmOJJo<4VS*6gxJAebbRTp0aFLvK%w_d31otDS$9tRHwa5xRdjX%3;l_Rpq;fnKVC0+~t2?f;$g{g@znj>z9*&+8dD2Mli8Pp`-``lMejE~Qqt4v?s zY9VehigQGpV-JDOUrQ?gF4KSAl7AHapPG3#Vf)AEVZHq|q6XHClVZjPdlO86>t8=JEXy~FLv$~_GY8crvZS-)B_rMBzAnbqjJy1JQKwqbwQ z(Emi7$7ejW?CkA(s8r7k#gg)J-f}z6Y?-}5Cqb|($R!e`uZJ_GzG`6W85lrhDCXZS z&iW|#w5zL2Rz)Sr;dqEppij^KqwCd!iM=?5h{^cBhxprgQ z>GdxtrzUYpdpDu-kh5Z)G)8mE$&X@8F2zcFe zWS^LEqVI09BA|a8GwoJexN1rerRd}7v~PrrE!$}MIQ6S$pSK-$Cj9N#f~rGt^?tZch+WxXVApu@M*YgW4kl~nV4s|B(9mqHUK_0+?un42a`IX^WS@~9-1;GEZ^@iU-uBE{aUkLnOT@yQl{js zVt{Ewj4>_>(}4VpHTZW?4;|4Vx?GMf;EuDO zm4Gp3Tb9n+Qs|Bu-B$dEOS+AF1}^i3`>-?S+~>NBDEJ@vrlD|EPL1* zTN1Fh)o`LeIc|vADp{#n@aC6dv8)nb4|8dL`A3|AUt>*q>lm)|l){!&W=ojz1tW!m zMjLR;M(`1nd~N(NwM_No9=$<)y>k&vA85!u`^7PD)pLP%Q{LjNuo8fH5kU9U)J1Zm zUhtM20gF&Hgr5&B;AhSiG%?;Ao<3trR#mw!2_~Td^|5b4x}A71ASAqTVY2aM5(*OK zPM`U-(Jtv40o_6z+0t8O7~mKZOVx8u*|D1Ur#AH-uBv9Ij|H6fx}y?3FCGjZ5vFR; zcBU0-ggQla;SuUbqvEyz>2>$A0OZC@=>%!C2b z7{{Tt){e|Ufe0}N?qCiz$ng7n>TBq9yx!n0-qh?R&p;Jnc!a@v1Qj>Fc*}K-Fq$Y4ZDpkrapo4XVyp-0hm;IsV z#!u~<7OdSR;2~Dql_YLr;hO`zv&gQFhS7d$BF{_-+3b?Db&Ndjz(e zyxiPe*(+BJ9}yI;UX6;)%g)XoNNn=KEVX#4pe&sHBn3kwx83I@$y$_bOBSg0OuZPl zg2QhTFtt&Q@xf2N9$o0cxK2n@b90Xo92z4SK* zmggN@n2cSB^rf#)@ErQQZ{NP1i?>e^Cr9v34D>#(;HPLJer7yAK|wub@8vsiRl29r z;`=W=)vyBGLgFpb^kK57&~9xmF;8`AQ4yxQoxjf#%KK^jXye3mh@jtvWWV`bxd1&& z-XAlJLNqn5K-(@4rVRIUvLzDr%sHVflIP@mm1ol^OYr@ts;;*b_7AnYhsf|W2~^gbpKXDQc}{M7e8|NJ7OM7YZVWJ>SI)t6%#}SX>ld=H!;j%PdQ*8 zeJq?CvrlrTvZ(eE3v>3P+0xUw!5Tu6$0W`{NAtOTMf%LHzu>LH-T98ta%PTdNp|!P&m0;cA;*d8iN~tN8T~wJvNjgk$bJ>^!g>Xc;u8M;}wf{h40M}wr zQjxPG%mnq~Mm=YX!;OhTYrR#xK6iv;Z}L}pX5 zlh*Gh`!~M_V0Jx7nW1{H57%5&sGEJ+0=+H5w0zIx4h-ebbhM+lsGuXbe*g*4tcGdwM4BF)7=iDN*UCa13 z=C_6r>G$RqCNig-3Ng#!Eu;aZ@-#)Q$+%ziAC5c8MMaE6HI51~x?0CLNA_PqqSSWg&1%i_S8$x%PbB*HEir42tmaR~8GALEq6Z$m&+pHF@GJCl zw@1oGQt$9^qPy(DaN;z1#nNmzl8dt3CYei%Hc43rdfu$=(wbFW;X>wku(R&7)SP_{ zf2T)}q;%nMnT1(AZC}p0UnECNRs>i%$9UC3zN-sR^s4>P&e8jKk7kJlfBsUtz zsjB|Www-E~5zwn%f__d3v{BvMy*I^92cN(I=rv6OJG8t%$(<4ruy~)Jp(g z9HAgqj25IdY(Uq~?w)Q-GQgWi|r}t_gCAmz0H}`cxZp1XjxPyc0^xAaB}fq zL!1&2kd}a+vkJQZ!P#20xTO%1E+((8GVxM`r&|7oVbZT17g2U*&r&{`tWY4=r=wE$ zr$*#zciPBBXTy!`3KrQHt{?o^{{ z-yan-DHc+m;i^W`GUm4|_GfnZtuY3au+&8*|8F|-nKIb9P*y%WfwH!6U4x5t^yK1@C9{^X zd&a!S!Oje?bm73jr6J2cQ**9oTHra;%jj6FPvom_1r$NWuKs>g?kMdocOAkC!cB@)+?ZD5OOHR>Ote8bLWO=?^)wQYEay zb+=i;>cM!W77|<4F4i>o_BQqV)sBoOSZ6OfG~%7b zioY;>1Uz#)1%1b_hgJKXwopI`i|U#e@36Y4AZW=Vw7QFb*o;8Hv)4INi#oHu&E+e} zdo*V3!0$EKHJzSZ?R*&tm;u8FUqOADCNVJozLoe~^GJo(SancR7r=3G?;~4~ISW|6 zYUW8UwkD+fSo?N#Iq^#W@kQQ04fr9&Uh$%m?-#-CWZ#K&59?NAUVZq_S;3=n#Hna*j3RhjTaSIPdVD`SC5Hz} zof;TPNIw;3Euq#(+RivG74kC|8QW)cL#h{KT}P2gQA7O zPJB|5W&cd`@iv2cE}tN}CaBblLq=IeL0S(8|GppkN0#x|3H|3a(tm2E1bJNzUaQE; z%f|xqE>2DoawqDUF9dghtmue?*Wb?N|1ZY-Pw?dbKj9x#E}1ngqk6RVY0z$Baya9ntha z6x->doR2A^7jF)iyTPS8>gwu#ueS&9sfsWH0s^_k9zWvBeFUii$QoK;+3nS?|CUJp z6V$qvCne|4FM~jLo!^l#~p?*=-#`bjTXif1dW>yj7>N zGCh*R~FVAw}~b{ov~4mgsv*6C6mb- z0bX8S+zRUtQCI)XfKAP`?Ck8i|AnwhkM%VCHi}NdyuwsXt5AW)}MRB mmdk6DcG@TF@3i`W@g-Ha_w+=ND0mGI+|tz3D7ax0{C@!bh_sgg literal 0 HcmV?d00001 diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx new file mode 100644 index 00000000000..3d6d272acc6 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { TourKit, TourKitTypes } from '@woocommerce/components'; + +type ProductTourProps = { + onClose: () => void; +}; + +export const ProductTour: React.FC< ProductTourProps > = ( { onClose } ) => { + const tourConfig: TourKitTypes.WooConfig = { + placement: 'auto', + options: { + effects: { + spotlight: { + interactivity: { + enabled: false, + }, + }, + liveResize: { + mutation: true, + resize: true, + }, + }, + }, + steps: [ + { + referenceElements: { + desktop: `.woocommerce-product-form-tab__general .woocommerce-form-section__content`, + }, + meta: { + name: 'story', + heading: __( + '📣 Tell a story about your product', + 'woocommerce' + ), + descriptions: { + desktop: __( + 'The product form will help you describe your product field by field—from basic details like name and description to attributes the customers can use to find it on your store.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: `#tab-panel-0-pricing`, + }, + meta: { + name: 'tabs', + heading: __( '✍️ Set up pricing & more', 'woocommerce' ), + descriptions: { + desktop: __( + 'When done, use the tabs to switch between other details and settings. In the future, you’ll also find here extensions and plugins.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: `.woocommerce-product-form-actions`, + }, + meta: { + name: 'actions', + heading: __( '🔍 Preview and publish', 'woocommerce' ), + descriptions: { + desktop: __( + 'With all the details in place, use the buttons at the top to easily preview and publish your product. Click the arrow button for more options.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: `.woocommerce-product-form-more-menu`, + }, + meta: { + name: 'more', + heading: __( '⚙️ Looking for more?', 'woocommerce' ), + descriptions: { + desktop: __( + 'If the form doesn’t yet have all the feautures you need—it’s still in development—you can switch to the classic editor anytime.', + 'woocommerce' + ), + }, + }, + }, + ], + closeHandler: onClose, + }; + + return ; +}; diff --git a/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx new file mode 100644 index 00000000000..17d236ae1fe --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { ProductTourContainer } from '../'; +import { useProductTour } from '../use-product-tour'; + +const dismissModal = jest.fn(); +const endTour = jest.fn(); +const startTour = jest.fn(); + +const defaultValues = { + dismissModal, + endTour, + isModalHidden: false, + isTouring: false, + startTour, +}; + +jest.mock( '../use-product-tour', () => { + return { + useProductTour: jest.fn(), + }; +} ); + +const mockedUseProductTour = useProductTour as jest.Mock; + +describe( 'ProductTourContainer', () => { + it( 'should render the modal initially if not already hidden', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + expect( + getByText( 'Meet the product editing form' ) + ).toBeInTheDocument(); + } ); + + it( 'should not render the modal when the tour has already started', () => { + mockedUseProductTour.mockImplementation( () => ( { + ...defaultValues, + isTouring: true, + } ) ); + const { queryByText } = render( ); + expect( + queryByText( 'Meet the product editing form' ) + ).not.toBeInTheDocument(); + expect( + queryByText( 'Tell a story about your product' ) + ).not.toBeInTheDocument(); + } ); + + it( 'should call startTour after clicking the button to begin the tour', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + userEvent.click( getByText( 'Show me around (10s)' ) ); + expect( startTour ).toBeCalled(); + } ); + + it( 'should call dismissModal after closing the modal', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + userEvent.click( getByText( "I'll explore on my own" ) ); + expect( dismissModal ).toBeCalled(); + } ); + + it( 'should call endTour after closing the tour', () => { + mockedUseProductTour.mockImplementation( () => ( { + ...defaultValues, + isTouring: true, + } ) ); + const { getByLabelText } = render( ); + userEvent.click( getByLabelText( 'Close Tour' ) ); + expect( endTour ).toBeCalled(); + } ); + + it( 'should not show tour or modal once tour is complete', () => { + mockedUseProductTour.mockImplementation( () => ( { + ...defaultValues, + isTouring: false, + isModalHidden: true, + } ) ); + const { queryByText } = render( ); + expect( + queryByText( 'Meet the product editing form' ) + ).not.toBeInTheDocument(); + expect( + queryByText( 'Tell a story about your product' ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx new file mode 100644 index 00000000000..6fc011e3f01 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { PRODUCT_TOUR_MODAL_HIDDEN, useProductTour } from '../use-product-tour'; + +jest.mock( '@wordpress/data', () => { + // Require the original module to not be mocked... + const originalModule = jest.requireActual( '@wordpress/data' ); + + return { + __esModule: true, // Use it when dealing with esModules + ...originalModule, + useDispatch: jest.fn().mockReturnValue( {} ), + useSelect: jest.fn().mockReturnValue( {} ), + }; +} ); + +const mockedUseDispatch = useDispatch as jest.Mock; +const mockedUseSelect = useSelect as jest.Mock; + +describe( 'useProductTour', () => { + it( 'should initially set the tour to hidden', () => { + const { result } = renderHook( () => useProductTour() ); + expect( result.current.isTouring ).toBeFalsy(); + } ); + + it( 'should update the tour state when starting the tour', () => { + const updateOptions = jest.fn(); + mockedUseDispatch.mockImplementation( () => ( { + updateOptions, + } ) ); + const { result } = renderHook( () => useProductTour() ); + act( () => { + result.current.startTour(); + } ); + expect( result.current.isTouring ).toBeTruthy(); + } ); + + it( 'should dismiss the modal when starting the tour', () => { + const updateOptions = jest.fn(); + mockedUseDispatch.mockImplementation( () => ( { + updateOptions, + } ) ); + const { result } = renderHook( () => useProductTour() ); + + act( () => { + result.current.startTour(); + } ); + + expect( updateOptions ).toHaveBeenCalledWith( { + [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes', + } ); + } ); + + it( 'should update the tour state when ending the tour', () => { + const { result } = renderHook( () => useProductTour() ); + act( () => { + result.current.startTour(); + result.current.endTour(); + } ); + expect( result.current.isTouring ).toBeFalsy(); + } ); + + it( 'should return true when the modal is hidden', () => { + mockedUseSelect.mockImplementation( () => ( { + isModalHidden: true, + } ) ); + const { result } = renderHook( () => useProductTour() ); + expect( result.current.isModalHidden ).toBeTruthy(); + } ); + + it( 'should return false when the modal is hidden', () => { + mockedUseSelect.mockImplementation( () => ( { + isModalHidden: false, + } ) ); + const { result } = renderHook( () => useProductTour() ); + expect( result.current.isModalHidden ).toBeFalsy(); + } ); + + it( 'should dismiss the modal when manually called', () => { + const updateOptions = jest.fn(); + mockedUseDispatch.mockImplementation( () => ( { + updateOptions, + } ) ); + const { result } = renderHook( () => useProductTour() ); + + act( () => { + result.current.dismissModal(); + } ); + + expect( updateOptions ).toHaveBeenCalledWith( { + [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes', + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts new file mode 100644 index 00000000000..c8bd7dbec3b --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; + +export const PRODUCT_TOUR_MODAL_HIDDEN = + 'woocommerce_product_tour_modal_hidden'; + +export const useProductTour = () => { + const [ isTouring, setIsTouring ] = useState( false ); + const { updateOptions } = useDispatch( OPTIONS_STORE_NAME ); + const { isModalHidden } = useSelect( ( select ) => { + const { getOption, hasFinishedResolution } = + select( OPTIONS_STORE_NAME ); + + return { + isModalHidden: + getOption( PRODUCT_TOUR_MODAL_HIDDEN ) === 'yes' || + ! hasFinishedResolution( 'getOption', [ + PRODUCT_TOUR_MODAL_HIDDEN, + ] ), + }; + } ); + + const dismissModal = () => { + updateOptions( { + [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes', + } ); + }; + + const endTour = () => { + setIsTouring( false ); + }; + + const startTour = () => { + dismissModal(); + setIsTouring( true ); + }; + + return { + dismissModal, + endTour, + isModalHidden, + isTouring, + startTour, + }; +}; diff --git a/plugins/woocommerce/changelog/add-36322 b/plugins/woocommerce/changelog/add-36322 new file mode 100644 index 00000000000..0f45f7d8cb3 --- /dev/null +++ b/plugins/woocommerce/changelog/add-36322 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add product tour to new product management experience From c71573d7f9330873005b1ac0d78f2492e2cdcf5a Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Wed, 18 Jan 2023 15:44:24 -0800 Subject: [PATCH 38/58] Update product links when new product management experience is enabled (#36382) * Replace add new product link with new experience * Replace add new button on Products page * Update all edit product links to point to new experience * Update default add_product value to null * Add changelog entry * Fix additional space around equals sign --- plugins/woocommerce/changelog/update-36363 | 4 +++ .../legacy/js/admin/woocommerce_admin.js | 5 ++++ .../includes/admin/class-wc-admin-assets.php | 2 ++ .../includes/admin/class-wc-admin-menus.php | 9 ++++++- .../NewProductManagementExperience.php | 26 +++++++++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce/changelog/update-36363 diff --git a/plugins/woocommerce/changelog/update-36363 b/plugins/woocommerce/changelog/update-36363 new file mode 100644 index 00000000000..a6c35857b21 --- /dev/null +++ b/plugins/woocommerce/changelog/update-36363 @@ -0,0 +1,4 @@ +Significance: minor +Type: tweak + +Update product links when new product management experience is enabled diff --git a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js index 4b34a0d1302..542f2e9cc4a 100644 --- a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js +++ b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js @@ -11,6 +11,11 @@ $blankslate = $product_screen.find( '.woocommerce-BlankState' ); if ( 0 === $blankslate.length ) { + if ( woocommerce_admin.urls.add_product ) { + $title_action + .first() + .attr( 'href', woocommerce_admin.urls.add_product ); + } if ( woocommerce_admin.urls.export_products ) { $title_action.after( 'error( + sprintf( + /* translators: %s is file path. */ + __( 'Unable to create or write to %s during CSV export. Please check file permissions.', 'woocommerce' ), + esc_html( $this->get_file_path() ) + ) + ); return false; } From fc1745b03b0fcaa46cb95aa9bbc74eb334b215a5 Mon Sep 17 00:00:00 2001 From: louwie17 Date: Thu, 19 Jan 2023 04:52:02 -0400 Subject: [PATCH 41/58] Add/36075 render fields sections from php config (#36414) * Add initial component to auto load fills from API config * Add changelog * Update logic to make use of new store and re-usable components * Add changelog * Add loading state for product form data to add/edit product pages --- ...075_render_fields_sections_from_php_config | 4 ++ packages/js/data/src/index.ts | 9 +++- packages/js/data/src/product-form/actions.ts | 4 +- .../js/data/src/product-form/resolvers.ts | 4 +- .../js/data/src/product-form/selectors.ts | 7 +++ packages/js/data/src/product-form/types.ts | 9 ++-- .../client/products/add-product-page.tsx | 26 +++++++++- .../client/products/edit-product-page.tsx | 7 ++- .../details-section/details-section-fills.tsx | 1 + .../client/products/fills/index.ts | 5 ++ .../fills/product-form-field-fills.tsx | 37 +++++++++++++ .../products/fills/product-form-fills.tsx | 52 +++++++++++++++++++ .../fills/product-form-section-fills.tsx | 34 ++++++++++++ .../client/products/product-form.tsx | 4 +- ...075_render_fields_sections_from_php_config | 4 ++ 15 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 packages/js/data/changelog/add-36075_render_fields_sections_from_php_config create mode 100644 plugins/woocommerce-admin/client/products/fills/product-form-field-fills.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx create mode 100644 plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config diff --git a/packages/js/data/changelog/add-36075_render_fields_sections_from_php_config b/packages/js/data/changelog/add-36075_render_fields_sections_from_php_config new file mode 100644 index 00000000000..9c3dfc710c8 --- /dev/null +++ b/packages/js/data/changelog/add-36075_render_fields_sections_from_php_config @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Tweak the product form types and exports. diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index c0af7842a32..8fd66a820d8 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -76,7 +76,11 @@ export { // Export types export * from './types'; export * from './countries/types'; -export { ProductForm } from './product-form/types'; +export { + ProductForm, + ProductFormField, + ProductFormSection, +} from './product-form/types'; export * from './onboarding/types'; export * from './plugins/types'; export * from './products/types'; @@ -172,6 +176,7 @@ import { ProductCategorySelectors } from './product-categories/types'; import { ProductAttributeTermsSelectors } from './product-attribute-terms/types'; import { ProductVariationSelectors } from './product-variations/types'; import { TaxClassSelectors } from './tax-classes/types'; +import { ProductFormSelectors } from './product-form/selectors'; // As we add types to all the package selectors we can fill out these unknown types with real ones. See one // of the already typed selectors for an example of how you can do this. @@ -219,6 +224,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME ? ShippingZonesSelectors : T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME ? TaxClassSelectors + : T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME + ? ProductFormSelectors : never; export interface WCDataSelector { diff --git a/packages/js/data/src/product-form/actions.ts b/packages/js/data/src/product-form/actions.ts index e5e2288a838..bba4057dc1c 100644 --- a/packages/js/data/src/product-form/actions.ts +++ b/packages/js/data/src/product-form/actions.ts @@ -2,9 +2,9 @@ * Internal dependencies */ import TYPES from './action-types'; -import { Field, ProductForm } from './types'; +import { ProductFormField, ProductForm } from './types'; -export function getFieldsSuccess( fields: Field[] ) { +export function getFieldsSuccess( fields: ProductFormField[] ) { return { type: TYPES.GET_FIELDS_SUCCESS as const, fields, diff --git a/packages/js/data/src/product-form/resolvers.ts b/packages/js/data/src/product-form/resolvers.ts index 632a2dc5a57..8f9073cd192 100644 --- a/packages/js/data/src/product-form/resolvers.ts +++ b/packages/js/data/src/product-form/resolvers.ts @@ -14,7 +14,7 @@ import { getProductFormError, } from './actions'; import { WC_ADMIN_NAMESPACE } from '../constants'; -import { Field, ProductForm } from './types'; +import { ProductFormField, ProductForm } from './types'; import { STORE_NAME } from './constants'; const resolveSelect = @@ -23,7 +23,7 @@ const resolveSelect = export function* getFields() { try { const url = WC_ADMIN_NAMESPACE + '/product-form/fields'; - const results: Field[] = yield apiFetch( { + const results: ProductFormField[] = yield apiFetch( { path: url, method: 'GET', } ); diff --git a/packages/js/data/src/product-form/selectors.ts b/packages/js/data/src/product-form/selectors.ts index d986b006cc7..bb1f0501bb9 100644 --- a/packages/js/data/src/product-form/selectors.ts +++ b/packages/js/data/src/product-form/selectors.ts @@ -1,6 +1,7 @@ /** * Internal dependencies */ +import { WPDataSelector, WPDataSelectors } from '../types'; import { ProductFormState } from './types'; export const getFields = ( state: ProductFormState ) => { @@ -15,3 +16,9 @@ export const getProductForm = ( state: ProductFormState ) => { const { errors, ...form } = state; return form; }; + +export type ProductFormSelectors = { + getFields: WPDataSelector< typeof getFields >; + getField: WPDataSelector< typeof getField >; + getProductForm: WPDataSelector< typeof getProductForm >; +} & WPDataSelectors; diff --git a/packages/js/data/src/product-form/types.ts b/packages/js/data/src/product-form/types.ts index d4dcc8319b1..e9b86eadaea 100644 --- a/packages/js/data/src/product-form/types.ts +++ b/packages/js/data/src/product-form/types.ts @@ -9,22 +9,23 @@ type FieldProperties = { label: string; }; -export type Field = BaseComponent & { +export type ProductFormField = BaseComponent & { type: string; section: string; properties: FieldProperties; }; -export type Section = BaseComponent & { +export type ProductFormSection = BaseComponent & { title: string; description: string; + location: string; }; export type Subsection = BaseComponent; export type ProductForm = { - fields: Field[]; - sections: Section[]; + fields: ProductFormField[]; + sections: ProductFormSection[]; subsections: Subsection[]; }; diff --git a/plugins/woocommerce-admin/client/products/add-product-page.tsx b/plugins/woocommerce-admin/client/products/add-product-page.tsx index 7702b9e5ac8..34c0126b64f 100644 --- a/plugins/woocommerce-admin/client/products/add-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/add-product-page.tsx @@ -2,7 +2,13 @@ * External dependencies */ import { recordEvent } from '@woocommerce/tracks'; +import { useSelect } from '@wordpress/data'; import { useEffect } from '@wordpress/element'; +import { Spinner } from '@wordpress/components'; +import { + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME, + WCDataSelector, +} from '@woocommerce/data'; /** * Internal dependencies @@ -10,16 +16,32 @@ import { useEffect } from '@wordpress/element'; import { ProductForm } from './product-form'; import { ProductTourContainer } from './tour'; import './product-page.scss'; +import './fills'; const AddProductPage: React.FC = () => { + const { isLoading } = useSelect( ( select: WCDataSelector ) => { + const { hasFinishedResolution: hasProductFormFinishedResolution } = + select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME ); + return { + isLoading: ! hasProductFormFinishedResolution( 'getProductForm' ), + }; + } ); useEffect( () => { recordEvent( 'view_new_product_management_experience' ); }, [] ); return (
- - + { isLoading ? ( +
+ +
+ ) : ( + <> + + + + ) }
); }; diff --git a/plugins/woocommerce-admin/client/products/edit-product-page.tsx b/plugins/woocommerce-admin/client/products/edit-product-page.tsx index 6606da226db..d7e2bffc8dd 100644 --- a/plugins/woocommerce-admin/client/products/edit-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/edit-product-page.tsx @@ -3,6 +3,7 @@ */ import { __ } from '@wordpress/i18n'; import { + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME, EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, PartialProduct, Product, @@ -21,6 +22,7 @@ import { ProductForm } from './product-form'; import { ProductFormLayout } from './layout/product-form-layout'; import { ProductVariationForm } from './product-variation-form'; import './product-page.scss'; +import './fills'; const EditProductPage: React.FC = () => { const { productId, variationId } = useParams(); @@ -35,6 +37,8 @@ const EditProductPage: React.FC = () => { isPending, getPermalinkParts, } = select( PRODUCTS_STORE_NAME ); + const { hasFinishedResolution: hasProductFormFinishedResolution } = + select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME ); const { getProductVariation, hasFinishedResolution: hasProductVariationFinishedResolution, @@ -71,7 +75,8 @@ const EditProductPage: React.FC = () => { 'getProductVariation', [ parseInt( variationId, 10 ) ] ) - ), + ) || + ! hasProductFormFinishedResolution( 'getProductForm' ), isPendingAction: isPending( 'createProduct' ) || isPending( diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx index dae3909e352..73646b5d61c 100644 --- a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx @@ -28,6 +28,7 @@ const DetailsSection = () => ( id={ DETAILS_SECTION_ID } location="tab/general" pluginId="core" + order={ 1 } > = ( { + fields, +} ) => { + const { getInputProps } = useFormContext< Product >(); + + return ( + <> + { fields.map( ( field ) => ( + + <> + { renderField( field.type, { + ...getInputProps( field.properties.name ), + ...field.properties, + } ) } + + + ) ) }{ ' ' } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx b/plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx new file mode 100644 index 00000000000..0710edfd2fd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; +import { useSelect, resolveSelect } from '@wordpress/data'; +import { + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME, + WCDataSelector, +} from '@woocommerce/data'; +import { registerCoreProductFields } from '@woocommerce/components'; + +registerCoreProductFields(); + +/** + * Internal dependencies + */ +import { Fields } from './product-form-field-fills'; +import { Sections } from './product-form-section-fills'; + +const Form = () => { + const { formData } = useSelect( ( select: WCDataSelector ) => { + return { + formData: select( + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME + ).getProductForm(), + }; + } ); + + return ( + <> + { formData && ( + <> + + + + ) } + + ); +}; + +/** + * Preloading product form data, as product pages are waiting on this to be resolved. + * The above Form component won't get rendered until the getProductForm is resolved. + */ +resolveSelect( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME ).getProductForm(); +registerPlugin( 'wc-admin-product-editor-form-fills', { + // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. + scope: 'woocommerce-product-editor', + render: () => { + return
; + }, +} ); diff --git a/plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx new file mode 100644 index 00000000000..87026629a88 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Card, CardBody } from '@wordpress/components'; +import { + __experimentalWooProductSectionItem as WooProductSectionItem, + __experimentalProductFieldSection as ProductFieldSection, +} from '@woocommerce/components'; +import { ProductFormSection } from '@woocommerce/data'; + +export const Sections: React.FC< { sections: ProductFormSection[] } > = ( { + sections, +} ) => { + return ( + <> + { sections.map( ( section ) => ( + + + + ) ) } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/product-form.tsx b/plugins/woocommerce-admin/client/products/product-form.tsx index 655e10006d2..60abf3f26b5 100644 --- a/plugins/woocommerce-admin/client/products/product-form.tsx +++ b/plugins/woocommerce-admin/client/products/product-form.tsx @@ -7,8 +7,8 @@ import { __experimentalWooProductSectionItem as WooProductSectionItem, } from '@woocommerce/components'; import { PartialProduct, Product } from '@woocommerce/data'; -import { Ref } from 'react'; import { PluginArea } from '@wordpress/plugins'; +import { Ref } from 'react'; /** * Internal dependencies @@ -26,8 +26,6 @@ import { OptionsSection } from './sections/options-section'; import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormTab } from './product-form-tab'; -import './fills'; - export const ProductForm: React.FC< { product?: PartialProduct; formRef?: Ref< FormRef< Partial< Product > > >; diff --git a/plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config b/plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config new file mode 100644 index 00000000000..4642e24d58f --- /dev/null +++ b/plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Minor adjustments to the ProductForm API From 2fae3537a786a967877683a5635835fe510e3dec Mon Sep 17 00:00:00 2001 From: Joel Thiessen <444632+joelclimbsthings@users.noreply.github.com> Date: Thu, 19 Jan 2023 01:52:45 -0800 Subject: [PATCH 42/58] Experimental SlotContext for managing slot fill interactions (#36333) * Adding Slotcontext component and adding support to product slot fill components * Passing inject props correctly to non-function components. --- .../try-product-mvp-slotfill-experiments | 4 + packages/js/components/src/index.ts | 6 + .../js/components/src/slot-context/index.ts | 1 + .../src/slot-context/slot-context.tsx | 104 ++++++++++++++++ packages/js/components/src/utils.tsx | 11 +- .../woo-product-field-item.tsx | 82 ++++++++----- .../details-section/details-section-fills.tsx | 4 +- .../client/products/product-form.tsx | 111 +++++++++--------- .../try-product-mvp-slotfill-experiments | 4 + 9 files changed, 235 insertions(+), 92 deletions(-) create mode 100644 packages/js/components/changelog/try-product-mvp-slotfill-experiments create mode 100644 packages/js/components/src/slot-context/index.ts create mode 100644 packages/js/components/src/slot-context/slot-context.tsx create mode 100644 plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments diff --git a/packages/js/components/changelog/try-product-mvp-slotfill-experiments b/packages/js/components/changelog/try-product-mvp-slotfill-experiments new file mode 100644 index 00000000000..7df70dd246d --- /dev/null +++ b/packages/js/components/changelog/try-product-mvp-slotfill-experiments @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding experimental component SlotContext diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts index 94562eb2dce..8cea1e13515 100644 --- a/packages/js/components/src/index.ts +++ b/packages/js/components/src/index.ts @@ -92,3 +92,9 @@ export { ProductFieldSection as __experimentalProductFieldSection, } from './product-section-layout'; export * from './product-fields'; +export { + SlotContextProvider, + useSlotContext, + SlotContextType, + SlotContextHelpersType, +} from './slot-context'; diff --git a/packages/js/components/src/slot-context/index.ts b/packages/js/components/src/slot-context/index.ts new file mode 100644 index 00000000000..86ac84d1fdf --- /dev/null +++ b/packages/js/components/src/slot-context/index.ts @@ -0,0 +1 @@ +export * from './slot-context'; diff --git a/packages/js/components/src/slot-context/slot-context.tsx b/packages/js/components/src/slot-context/slot-context.tsx new file mode 100644 index 00000000000..49b70ccb86a --- /dev/null +++ b/packages/js/components/src/slot-context/slot-context.tsx @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { + createElement, + createContext, + useContext, + useCallback, + useReducer, +} from '@wordpress/element'; + +type FillConfigType = { + visible: boolean; +}; + +type FillType = Record< string, FillConfigType >; + +type FillCollection = readonly ( readonly JSX.Element[] )[]; + +export type SlotContextHelpersType = { + hideFill: ( id: string ) => void; + showFill: ( id: string ) => void; + getFills: () => FillType; +}; + +export type SlotContextType = { + fills: FillType; + getFillHelpers: () => SlotContextHelpersType; + registerFill: ( id: string ) => void; + filterRegisteredFills: ( fillsArrays: FillCollection ) => FillCollection; +}; + +const SlotContext = createContext< SlotContextType | undefined >( undefined ); + +export const SlotContextProvider: React.FC = ( { children } ) => { + const [ fills, updateFills ] = useReducer( + ( data: FillType, updates: FillType ) => ( { ...data, ...updates } ), + {} + ); + + const updateFillConfig = ( + id: string, + update: Partial< FillConfigType > + ) => { + if ( ! fills[ id ] ) { + throw new Error( `No fill found with ID: ${ id }` ); + } + updateFills( { [ id ]: { ...fills[ id ], ...update } } ); + }; + + const registerFill = useCallback( + ( id: string ) => { + if ( fills[ id ] ) { + return; + } + updateFills( { [ id ]: { visible: true } } ); + }, + [ fills ] + ); + + const hideFill = useCallback( + ( id: string ) => updateFillConfig( id, { visible: false } ), + [ fills ] + ); + + const showFill = useCallback( + ( id: string ) => updateFillConfig( id, { visible: true } ), + [ fills ] + ); + + const getFills = useCallback( () => ( { ...fills } ), [ fills ] ); + + return ( + + fills[ arr[ 0 ].props._id ]?.visible !== false + ); + }, + fills, + } } + > + { children } + + ); +}; + +export const useSlotContext = () => { + const slotContext = useContext( SlotContext ); + + if ( slotContext === undefined ) { + throw new Error( + 'useSlotContext must be used within a SlotContextProvider' + ); + } + + return slotContext; +}; diff --git a/packages/js/components/src/utils.tsx b/packages/js/components/src/utils.tsx index 682a1a0aded..34117864d5d 100644 --- a/packages/js/components/src/utils.tsx +++ b/packages/js/components/src/utils.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import React, { isValidElement, Fragment } from 'react'; +import { isValidElement, Fragment } from 'react'; import { Slot, Fill } from '@wordpress/components'; import { cloneElement, createElement } from '@wordpress/element'; @@ -13,15 +13,16 @@ import { cloneElement, createElement } from '@wordpress/element'; * @param {Array} props - Fill props. * @return {Node} Node. */ -function createOrderedChildren< T = Fill.Props >( +function createOrderedChildren< T = Fill.Props, S = Record< string, unknown > >( children: React.ReactNode, order: number, - props: T + props: T, + injectProps?: S ) { if ( typeof children === 'function' ) { - return cloneElement( children( props ), { order } ); + return cloneElement( children( props ), { order, ...injectProps } ); } else if ( isValidElement( children ) ) { - return cloneElement( children, { ...props, order } ); + return cloneElement( children, { ...props, order, ...injectProps } ); } throw Error( 'Invalid children type' ); } diff --git a/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx b/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx index 3a62adf24df..2db6a28165d 100644 --- a/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx +++ b/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx @@ -9,6 +9,7 @@ import { createElement, Children } from '@wordpress/element'; * Internal dependencies */ import { createOrderedChildren, sortFillsByOrder } from '../utils'; +import { useSlotContext, SlotContextHelpersType } from '../slot-context'; type WooProductFieldItemProps = { id: string; @@ -23,36 +24,55 @@ type WooProductFieldSlotProps = { export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & { Slot: React.FC< Slot.Props & WooProductFieldSlotProps >; -} = ( { children, order = 20, section } ) => ( - - { ( fillProps: Fill.Props ) => { - return createOrderedChildren< Fill.Props >( - children, - order, - fillProps - ); - } } - -); +} = ( { children, order = 20, section, id } ) => { + const { registerFill, getFillHelpers } = useSlotContext(); -WooProductFieldItem.Slot = ( { fillProps, section } ) => ( - - { ( fills ) => { - if ( ! sortFillsByOrder ) { - return null; - } + registerFill( id ); - return Children.map( - sortFillsByOrder( fills )?.props.children, - ( child ) => ( -
- { child } -
- ) - ); - } } -
-); + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren< + Fill.Props & SlotContextHelpersType, + { _id: string } + >( + children, + order, + { + ...fillProps, + ...getFillHelpers(), + }, + { _id: id } + ); + } } + + ); +}; + +WooProductFieldItem.Slot = ( { fillProps, section } ) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { filterRegisteredFills } = useSlotContext(); + + return ( + + { ( fills ) => { + if ( ! sortFillsByOrder ) { + return null; + } + + return Children.map( + sortFillsByOrder( filterRegisteredFills( fills ) )?.props + .children, + ( child ) => ( +
+ { child } +
+ ) + ); + } } +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx index 73646b5d61c..bf203331e30 100644 --- a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx @@ -85,7 +85,5 @@ const DetailsSection = () => ( registerPlugin( 'wc-admin-product-editor-details-section', { // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. scope: 'woocommerce-product-editor', - render: () => { - return ; - }, + render: () => , } ); diff --git a/plugins/woocommerce-admin/client/products/product-form.tsx b/plugins/woocommerce-admin/client/products/product-form.tsx index 60abf3f26b5..0f1a171fafb 100644 --- a/plugins/woocommerce-admin/client/products/product-form.tsx +++ b/plugins/woocommerce-admin/client/products/product-form.tsx @@ -5,6 +5,7 @@ import { Form, FormRef, __experimentalWooProductSectionItem as WooProductSectionItem, + SlotContextProvider, } from '@woocommerce/components'; import { PartialProduct, Product } from '@woocommerce/data'; import { PluginArea } from '@wordpress/plugins'; @@ -31,60 +32,64 @@ export const ProductForm: React.FC< { formRef?: Ref< FormRef< Partial< Product > > >; } > = ( { product, formRef } ) => { return ( - > - initialValues={ - product || { - reviews_allowed: true, - name: '', - sku: '', - stock_quantity: 0, - stock_status: 'instock', + + > + initialValues={ + product || { + reviews_allowed: true, + name: '', + sku: '', + stock_quantity: 0, + stock_status: 'instock', + } } - } - ref={ formRef } - errors={ {} } - validate={ validate } - > - - - - - - - - - - - - - - - - - { window.wcAdminFeatures[ 'product-variation-management' ] ? ( - - - + ref={ formRef } + errors={ {} } + validate={ validate } + > + + + + + + - ) : ( - <> - ) } - - - { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } - - + + + + + + + + + + { window.wcAdminFeatures[ + 'product-variation-management' + ] ? ( + + + + + ) : ( + <> + ) } + + + { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } + + + ); }; diff --git a/plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments b/plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments new file mode 100644 index 00000000000..735fdc59890 --- /dev/null +++ b/plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Trying experimental slot context with product editor fills. From ece2fb71cedb7a0a343f6f529eed908b930cc2c3 Mon Sep 17 00:00:00 2001 From: Barry Hughes <3594411+barryhughes@users.noreply.github.com> Date: Thu, 19 Jan 2023 04:08:46 -0800 Subject: [PATCH 43/58] Update Action Scheduler to 3.5.4. (#36433) * Update Action Scheduler to 3.5.4. * Update changelog Co-authored-by: Jorge A. Torres --- .../bin/composer/mozart/composer.lock | 2 +- .../bin/composer/phpcs/composer.lock | 2 +- .../bin/composer/phpunit/composer.lock | 2 +- .../woocommerce/bin/composer/wp/composer.lock | 23 +++++++++++++------ .../changelog/update-action-scheduler-3-5-3 | 4 ++++ plugins/woocommerce/composer.json | 2 +- plugins/woocommerce/composer.lock | 16 ++++++------- 7 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 plugins/woocommerce/changelog/update-action-scheduler-3-5-3 diff --git a/plugins/woocommerce/bin/composer/mozart/composer.lock b/plugins/woocommerce/bin/composer/mozart/composer.lock index 5291be463e6..1554fb40d8b 100644 --- a/plugins/woocommerce/bin/composer/mozart/composer.lock +++ b/plugins/woocommerce/bin/composer/mozart/composer.lock @@ -1169,5 +1169,5 @@ "platform-overrides": { "php": "7.3" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/bin/composer/phpcs/composer.lock b/plugins/woocommerce/bin/composer/phpcs/composer.lock index 29bcf29ff1d..0e1a6def19c 100644 --- a/plugins/woocommerce/bin/composer/phpcs/composer.lock +++ b/plugins/woocommerce/bin/composer/phpcs/composer.lock @@ -475,5 +475,5 @@ "platform-overrides": { "php": "7.2" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/bin/composer/phpunit/composer.lock b/plugins/woocommerce/bin/composer/phpunit/composer.lock index c80d9739bc5..1983c0a3a0b 100644 --- a/plugins/woocommerce/bin/composer/phpunit/composer.lock +++ b/plugins/woocommerce/bin/composer/phpunit/composer.lock @@ -1697,5 +1697,5 @@ "platform-overrides": { "php": "7.0" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/bin/composer/wp/composer.lock b/plugins/woocommerce/bin/composer/wp/composer.lock index 84e38397103..d949742c29f 100644 --- a/plugins/woocommerce/bin/composer/wp/composer.lock +++ b/plugins/woocommerce/bin/composer/wp/composer.lock @@ -553,22 +553,31 @@ }, { "name": "wp-cli/php-cli-tools", - "version": "v0.11.16", + "version": "v0.11.17", "source": { "type": "git", "url": "https://github.com/wp-cli/php-cli-tools.git", - "reference": "c32e51a5c9993ad40591bc426b21f5422a5ed293" + "reference": "f6be76b7c4ee2ef93c9531b8a37bdb7ce42c3728" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/c32e51a5c9993ad40591bc426b21f5422a5ed293", - "reference": "c32e51a5c9993ad40591bc426b21f5422a5ed293", + "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/f6be76b7c4ee2ef93c9531b8a37bdb7ce42c3728", + "reference": "f6be76b7c4ee2ef93c9531b8a37bdb7ce42c3728", "shasum": "" }, "require": { "php": ">= 5.3.0" }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "wp-cli/wp-cli-tests": "^3.1.6" + }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.11.x-dev" + } + }, "autoload": { "files": [ "lib/cli/cli.php" @@ -601,9 +610,9 @@ ], "support": { "issues": "https://github.com/wp-cli/php-cli-tools/issues", - "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.16" + "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.17" }, - "time": "2022-11-03T15:19:26+00:00" + "time": "2023-01-12T01:18:21+00:00" }, { "name": "wp-cli/wp-cli", @@ -687,5 +696,5 @@ "platform-overrides": { "php": "7.0" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/changelog/update-action-scheduler-3-5-3 b/plugins/woocommerce/changelog/update-action-scheduler-3-5-3 new file mode 100644 index 00000000000..ed7b1e941bc --- /dev/null +++ b/plugins/woocommerce/changelog/update-action-scheduler-3-5-3 @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Bundled version of Action Scheduler updated to 3.5.4. diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json index 60cb6ae41f7..2819dae9151 100644 --- a/plugins/woocommerce/composer.json +++ b/plugins/woocommerce/composer.json @@ -20,7 +20,7 @@ "composer/installers": "^1.9", "maxmind-db/reader": "^1.11", "pelago/emogrifier": "^6.0", - "woocommerce/action-scheduler": "3.4.2", + "woocommerce/action-scheduler": "3.5.4", "woocommerce/woocommerce-blocks": "9.1.4" }, "require-dev": { diff --git a/plugins/woocommerce/composer.lock b/plugins/woocommerce/composer.lock index 66036e5e61d..662897b9e15 100644 --- a/plugins/woocommerce/composer.lock +++ b/plugins/woocommerce/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c00f9ad96d703d7e841895190ec42436", + "content-hash": "0bdaab6e57bde687bb8e66ab2f19a64a", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -588,16 +588,16 @@ }, { "name": "woocommerce/action-scheduler", - "version": "3.4.2", + "version": "3.5.4", "source": { "type": "git", "url": "https://github.com/woocommerce/action-scheduler.git", - "reference": "7d8e830b6387410ccf11708194d3836f01cb2942" + "reference": "9533e71b0eba4a519721dde84a34dfb161f11eb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/7d8e830b6387410ccf11708194d3836f01cb2942", - "reference": "7d8e830b6387410ccf11708194d3836f01cb2942", + "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/9533e71b0eba4a519721dde84a34dfb161f11eb8", + "reference": "9533e71b0eba4a519721dde84a34dfb161f11eb8", "shasum": "" }, "require-dev": { @@ -622,9 +622,9 @@ "homepage": "https://actionscheduler.org/", "support": { "issues": "https://github.com/woocommerce/action-scheduler/issues", - "source": "https://github.com/woocommerce/action-scheduler/tree/3.4.2" + "source": "https://github.com/woocommerce/action-scheduler/tree/3.5.4" }, - "time": "2022-06-08T15:46:07+00:00" + "time": "2023-01-17T20:20:43+00:00" }, { "name": "woocommerce/woocommerce-blocks", @@ -2726,5 +2726,5 @@ "platform-overrides": { "php": "7.2" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } From 5cda49f26974c403e595a41453e4fe9633aa3260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20=C5=9Eakar?= Date: Thu, 19 Jan 2023 17:56:11 +0300 Subject: [PATCH 44/58] Fix issue #34344 - Cannot edit replies to product reviews (#35888) * Fix issue #34344 - Cannot edit replies to product reviews * Simplify logic in product review edit screen. * Add changelog Co-authored-by: Jorge A. Torres --- plugins/woocommerce/changelog/fix-edit-reply-reviews | 4 ++++ .../src/Internal/Admin/ProductReviews/Reviews.php | 7 +------ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 plugins/woocommerce/changelog/fix-edit-reply-reviews diff --git a/plugins/woocommerce/changelog/fix-edit-reply-reviews b/plugins/woocommerce/changelog/fix-edit-reply-reviews new file mode 100644 index 00000000000..14a1f5cbb62 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-edit-reply-reviews @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fixes editing of child product reviews. diff --git a/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php b/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php index 426aefc538f..22109694091 100644 --- a/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php +++ b/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php @@ -533,12 +533,7 @@ class Reviews { $comment = get_comment( $comment_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited } - $is_reply = false; - - if ( isset( $comment->comment_parent ) && $comment->comment_parent > 0 ) { - $is_reply = true; - $comment = get_comment( $comment->comment_parent ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - } + $is_reply = isset( $comment->comment_parent ) && $comment->comment_parent > 0; // Only replace the translated text if we are editing a comment left on a product (ie. a review). if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) { From 7e17a969142fccb16323fa4f97af7bab31950b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Mar=C3=ADn?= <292309+davefx@users.noreply.github.com> Date: Thu, 19 Jan 2023 16:34:46 +0100 Subject: [PATCH 45/58] Making function more robust against wrong transients (#34742) * Making function more robust against wrong transients * Add changelog Co-authored-by: Jorge A. Torres --- plugins/woocommerce/changelog/patch-15 | 4 ++++ plugins/woocommerce/includes/wc-product-functions.php | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 plugins/woocommerce/changelog/patch-15 diff --git a/plugins/woocommerce/changelog/patch-15 b/plugins/woocommerce/changelog/patch-15 new file mode 100644 index 00000000000..b9b0ae147f0 --- /dev/null +++ b/plugins/woocommerce/changelog/patch-15 @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Make related products check more robust against wrong transients. diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index dcd0f512235..d1099b5605c 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -915,7 +915,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( ); $transient = get_transient( $transient_name ); - $related_posts = $transient && isset( $transient[ $query_args ] ) ? $transient[ $query_args ] : false; + $related_posts = $transient && is_array( $transient ) && isset( $transient[ $query_args ] ) ? $transient[ $query_args ] : false; // We want to query related posts if they are not cached, or we don't have enough. if ( false === $related_posts || count( $related_posts ) < $limit ) { @@ -931,7 +931,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( $related_posts = $data_store->get_related_products( $cats_array, $tags_array, $exclude_ids, $limit + 10, $product_id ); } - if ( $transient ) { + if ( $transient && is_array( $transient ) ) { $transient[ $query_args ] = $related_posts; } else { $transient = array( $query_args => $related_posts ); From f11def132b2f751376200cf009e81592bfab4a18 Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Thu, 19 Jan 2023 09:39:22 -0800 Subject: [PATCH 46/58] Improve error logging when file permissions prevent CSV generation. --- plugins/woocommerce/changelog/csv-export-error-logging | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/woocommerce/changelog/csv-export-error-logging diff --git a/plugins/woocommerce/changelog/csv-export-error-logging b/plugins/woocommerce/changelog/csv-export-error-logging new file mode 100644 index 00000000000..9108587b6c0 --- /dev/null +++ b/plugins/woocommerce/changelog/csv-export-error-logging @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Additional error logging within the CSV Exporter framework. From 687dd6fdfee364034158c6ebbf9bf653ee8c0291 Mon Sep 17 00:00:00 2001 From: Joel Thiessen <444632+joelclimbsthings@users.noreply.github.com> Date: Thu, 19 Jan 2023 09:45:30 -0800 Subject: [PATCH 47/58] Migrating product editor images section to slot-fill (#36461) --- .../client/products/fills/constants.ts | 7 + .../fills/details-section/constants.ts | 2 - .../details-section/details-field-feature.tsx | 2 +- .../details-section/details-field-name.tsx | 2 +- .../details-section/details-section-fills.tsx | 20 +- .../products/fills/details-section/index.ts | 1 - .../images-section/images-field-gallery.tsx | 201 +++++++++++++++ .../images-section/images-section-fills.tsx | 74 ++++++ .../images-section}/images-section.scss | 0 .../products/fills/images-section/index.ts | 1 + .../client/products/fills/index.ts | 1 + .../client/products/product-form.tsx | 7 +- .../products/sections/images-section.tsx | 241 ------------------ .../changelog/add-36417-mvp-images-slotfill | 4 + 14 files changed, 305 insertions(+), 258 deletions(-) create mode 100644 plugins/woocommerce-admin/client/products/fills/constants.ts delete mode 100644 plugins/woocommerce-admin/client/products/fills/details-section/constants.ts create mode 100644 plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx create mode 100644 plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx rename plugins/woocommerce-admin/client/products/{sections => fills/images-section}/images-section.scss (100%) create mode 100644 plugins/woocommerce-admin/client/products/fills/images-section/index.ts delete mode 100644 plugins/woocommerce-admin/client/products/sections/images-section.tsx create mode 100644 plugins/woocommerce/changelog/add-36417-mvp-images-slotfill diff --git a/plugins/woocommerce-admin/client/products/fills/constants.ts b/plugins/woocommerce-admin/client/products/fills/constants.ts new file mode 100644 index 00000000000..ad4ca43284e --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/constants.ts @@ -0,0 +1,7 @@ +export const PRODUCT_DETAILS_SLUG = 'product-details'; + +export const DETAILS_SECTION_ID = 'general/details'; +export const IMAGES_SECTION_ID = 'general/images'; + +export const TAB_GENERAL_ID = 'tab/general'; +export const PLUGIN_ID = 'woocommerce'; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/constants.ts b/plugins/woocommerce-admin/client/products/fills/details-section/constants.ts deleted file mode 100644 index 4404df12093..00000000000 --- a/plugins/woocommerce-admin/client/products/fills/details-section/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const PRODUCT_DETAILS_SLUG = 'product-details'; -export const DETAILS_SECTION_ID = 'general/details'; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx index 350871b4c03..afbae52275d 100644 --- a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx @@ -16,7 +16,7 @@ import { recordEvent } from '@woocommerce/tracks'; * Internal dependencies */ import { getCheckboxTracks } from '../../sections/utils'; -import { PRODUCT_DETAILS_SLUG } from './index'; +import { PRODUCT_DETAILS_SLUG } from '../constants'; export const DetailsFeatureField = () => { const { getCheckboxControlProps } = useFormContext< Product >(); diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx index c8513e6c755..0fb92254221 100644 --- a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx @@ -18,7 +18,7 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import { EditProductLinkModal } from '../../shared/edit-product-link-modal'; -import { PRODUCT_DETAILS_SLUG } from './index'; +import { PRODUCT_DETAILS_SLUG } from '../constants'; export const DetailsNameField = ( {} ) => { const [ showProductLinkEditModal, setShowProductLinkEditModal ] = diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx index bf203331e30..9eefdae7b0d 100644 --- a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx @@ -18,20 +18,22 @@ import { DetailsFeatureField, DetailsSummaryField, DetailsDescriptionField, - DETAILS_SECTION_ID, } from './index'; + +import { DETAILS_SECTION_ID, PLUGIN_ID, TAB_GENERAL_ID } from '../constants'; + import './product-details-section.scss'; const DetailsSection = () => ( <> ( @@ -50,7 +52,7 @@ const DetailsSection = () => ( @@ -58,7 +60,7 @@ const DetailsSection = () => ( @@ -66,7 +68,7 @@ const DetailsSection = () => ( @@ -74,7 +76,7 @@ const DetailsSection = () => ( diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/index.ts b/plugins/woocommerce-admin/client/products/fills/details-section/index.ts index ece2335fc8d..1e342c4c1c1 100644 --- a/plugins/woocommerce-admin/client/products/fills/details-section/index.ts +++ b/plugins/woocommerce-admin/client/products/fills/details-section/index.ts @@ -3,4 +3,3 @@ export * from './details-field-categories'; export * from './details-field-feature'; export * from './details-field-summary'; export * from './details-field-description'; -export * from './constants'; diff --git a/plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx b/plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx new file mode 100644 index 00000000000..1a9ba1f2ebd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx @@ -0,0 +1,201 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + MediaUploader, + ImageGallery, + ImageGalleryItem, +} from '@woocommerce/components'; +import { CardBody, DropZone } from '@wordpress/components'; +import { recordEvent } from '@woocommerce/tracks'; +import { useState } from '@wordpress/element'; +import { Product } from '@woocommerce/data'; +import { Icon, trash } from '@wordpress/icons'; +import { MediaItem } from '@wordpress/media-utils'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import DragAndDrop from '../../images/drag-and-drop.svg'; + +type Image = MediaItem & { + src: string; +}; + +export const ImagesGalleryField = () => { + const { getInputProps, setValue } = useFormContext< Product >(); + const images = ( getInputProps( 'images' ).value as Image[] ) || []; + const [ isRemovingZoneVisible, setIsRemovingZoneVisible ] = + useState< boolean >( false ); + const [ isRemoving, setIsRemoving ] = useState< boolean >( false ); + const [ draggedImageId, setDraggedImageId ] = useState< number | null >( + null + ); + + const toggleRemoveZone = () => { + setIsRemovingZoneVisible( ! isRemovingZoneVisible ); + }; + + const orderImages = ( newOrder: JSX.Element[] ) => { + const orderedImages = newOrder.map( ( image ) => { + return images.find( + ( file ) => file.id === parseInt( image?.props?.id, 10 ) + ); + } ); + recordEvent( 'product_images_change_image_order_via_image_gallery' ); + setValue( 'images', orderedImages ); + }; + const onFileUpload = ( files: MediaItem[] ) => { + if ( files[ 0 ].id ) { + recordEvent( 'product_images_add_via_file_upload_area' ); + setValue( 'images', [ ...images, ...files ] ); + } + }; + + return ( +
0, + } ) } + > + { + const { id: imageId, dataset } = + event.target as HTMLElement; + if ( imageId ) { + setDraggedImageId( parseInt( imageId, 10 ) ); + } else { + const index = dataset?.index; + if ( index ) { + setDraggedImageId( + images[ parseInt( index, 10 ) ]?.id + ); + } + } + toggleRemoveZone(); + } } + onDragEnd={ () => { + if ( isRemoving && draggedImageId ) { + recordEvent( + 'product_images_remove_image_button_click' + ); + setValue( + 'images', + images.filter( + ( img ) => img.id !== draggedImageId + ) + ); + setIsRemoving( false ); + setDraggedImageId( null ); + } + toggleRemoveZone(); + } } + onOrderChange={ orderImages } + onReplace={ ( { replaceIndex, media } ) => { + if ( + images.find( ( img ) => media.id === img.id ) === + undefined + ) { + images[ replaceIndex ] = media as Image; + recordEvent( + 'product_images_replace_image_button_click' + ); + setValue( 'images', images ); + } + } } + onSelectAsCover={ () => + recordEvent( + 'product_images_select_image_as_cover_button_click' + ) + } + > + { images.map( ( image ) => ( + + ) ) } + +
+ { isRemovingZoneVisible ? ( + +
+ + + { __( 'Drop here to remove', 'woocommerce' ) } + + setIsRemoving( true ) } + onDrop={ () => setIsRemoving( true ) } + label={ __( + 'Drop here to remove', + 'woocommerce' + ) } + /> +
+
+ ) : ( + + null } + onFileUploadChange={ onFileUpload } + onSelect={ ( files ) => { + const newImages = files.filter( + ( img: Image ) => + ! images.find( + ( image ) => image.id === img.id + ) + ); + if ( newImages.length > 0 ) { + recordEvent( + 'product_images_add_via_media_library' + ); + setValue( 'images', [ + ...images, + ...newImages, + ] ); + } + } } + onUpload={ ( files ) => { + if ( files[ 0 ].id ) { + recordEvent( + 'product_images_add_via_drag_and_drop_upload' + ); + setValue( 'images', [ + ...images, + ...files, + ] ); + } + } } + label={ + <> + { + + { __( + 'Drag images here or click to upload', + 'woocommerce' + ) } + + + } + /> + + ) } +
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx new file mode 100644 index 00000000000..01b735d0627 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + __experimentalWooProductSectionItem as WooProductSectionItem, + __experimentalWooProductFieldItem as WooProductFieldItem, + __experimentalProductFieldSection as ProductFieldSection, + Link, +} from '@woocommerce/components'; +import { registerPlugin } from '@wordpress/plugins'; +import { recordEvent } from '@woocommerce/tracks'; + +/** + * Internal dependencies + */ +import { ImagesGalleryField } from './index'; +import { IMAGES_SECTION_ID, TAB_GENERAL_ID, PLUGIN_ID } from '../constants'; + +import './images-section.scss'; + +const ImagesSection = () => ( + <> + + + + { __( + 'For best results, use JPEG files that are 1000 by 1000 pixels or larger.', + 'woocommerce' + ) } + + { + recordEvent( 'prepare_images_help' ); + } } + > + { __( + 'How should I prepare images?', + 'woocommerce' + ) } + + + } + /> + + + + + +); + +registerPlugin( 'wc-admin-product-editor-images-section', { + // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. + scope: 'woocommerce-product-editor', + render: () => , +} ); diff --git a/plugins/woocommerce-admin/client/products/sections/images-section.scss b/plugins/woocommerce-admin/client/products/fills/images-section/images-section.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/sections/images-section.scss rename to plugins/woocommerce-admin/client/products/fills/images-section/images-section.scss diff --git a/plugins/woocommerce-admin/client/products/fills/images-section/index.ts b/plugins/woocommerce-admin/client/products/fills/images-section/index.ts new file mode 100644 index 00000000000..9d67f229c29 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/images-section/index.ts @@ -0,0 +1 @@ +export * from './images-field-gallery'; diff --git a/plugins/woocommerce-admin/client/products/fills/index.ts b/plugins/woocommerce-admin/client/products/fills/index.ts index 925e757f7d7..f1cc184b6f7 100644 --- a/plugins/woocommerce-admin/client/products/fills/index.ts +++ b/plugins/woocommerce-admin/client/products/fills/index.ts @@ -4,3 +4,4 @@ import './product-form-fills'; export * from './details-section/details-section-fills'; +export * from './images-section/images-section-fills'; diff --git a/plugins/woocommerce-admin/client/products/product-form.tsx b/plugins/woocommerce-admin/client/products/product-form.tsx index 0f1a171fafb..302c532e18c 100644 --- a/plugins/woocommerce-admin/client/products/product-form.tsx +++ b/plugins/woocommerce-admin/client/products/product-form.tsx @@ -20,12 +20,12 @@ import { ProductInventorySection } from './sections/product-inventory-section'; import { PricingSection } from './sections/pricing-section'; import { ProductShippingSection } from './sections/product-shipping-section'; import { ProductVariationsSection } from './sections/product-variations-section'; -import { ImagesSection } from './sections/images-section'; import { validate } from './product-validation'; import { AttributesSection } from './sections/attributes-section'; import { OptionsSection } from './sections/options-section'; import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormTab } from './product-form-tab'; +import { TAB_GENERAL_ID } from './fills/constants'; export const ProductForm: React.FC< { product?: PartialProduct; @@ -50,8 +50,9 @@ export const ProductForm: React.FC< { - - + { - const { getInputProps, setValue } = useFormContext< Product >(); - const images = ( getInputProps( 'images' ).value as Image[] ) || []; - const [ isRemovingZoneVisible, setIsRemovingZoneVisible ] = - useState< boolean >( false ); - const [ isRemoving, setIsRemoving ] = useState< boolean >( false ); - const [ draggedImageId, setDraggedImageId ] = useState< number | null >( - null - ); - - const toggleRemoveZone = () => { - setIsRemovingZoneVisible( ! isRemovingZoneVisible ); - }; - - const orderImages = ( newOrder: JSX.Element[] ) => { - const orderedImages = newOrder.map( ( image ) => { - return images.find( - ( file ) => file.id === parseInt( image?.props?.id, 10 ) - ); - } ); - recordEvent( 'product_images_change_image_order_via_image_gallery' ); - setValue( 'images', orderedImages ); - }; - const onFileUpload = ( files: MediaItem[] ) => { - if ( files[ 0 ].id ) { - recordEvent( 'product_images_add_via_file_upload_area' ); - setValue( 'images', [ ...images, ...files ] ); - } - }; - - return ( - - - { __( - 'For best results, use JPEG files that are 1000 by 1000 pixels or larger.', - 'woocommerce' - ) } - - { - recordEvent( 'prepare_images_help' ); - } } - > - { __( 'How should I prepare images?', 'woocommerce' ) } - - - } - > - 0, - } ) } - > - - { - const { id: imageId, dataset } = - event.target as HTMLElement; - if ( imageId ) { - setDraggedImageId( parseInt( imageId, 10 ) ); - } else { - const index = dataset?.index; - if ( index ) { - setDraggedImageId( - images[ parseInt( index, 10 ) ]?.id - ); - } - } - toggleRemoveZone(); - } } - onDragEnd={ () => { - if ( isRemoving && draggedImageId ) { - recordEvent( - 'product_images_remove_image_button_click' - ); - setValue( - 'images', - images.filter( - ( img ) => img.id !== draggedImageId - ) - ); - setIsRemoving( false ); - setDraggedImageId( null ); - } - toggleRemoveZone(); - } } - onOrderChange={ orderImages } - onReplace={ ( { replaceIndex, media } ) => { - if ( - images.find( - ( img ) => media.id === img.id - ) === undefined - ) { - images[ replaceIndex ] = media as Image; - recordEvent( - 'product_images_replace_image_button_click' - ); - setValue( 'images', images ); - } - } } - onSelectAsCover={ () => - recordEvent( - 'product_images_select_image_as_cover_button_click' - ) - } - > - { images.map( ( image ) => ( - - ) ) } - -
- { isRemovingZoneVisible ? ( - -
- - - { __( - 'Drop here to remove', - 'woocommerce' - ) } - - - setIsRemoving( true ) - } - onDrop={ () => setIsRemoving( true ) } - label={ __( - 'Drop here to remove', - 'woocommerce' - ) } - /> -
-
- ) : ( - - null } - onFileUploadChange={ onFileUpload } - onSelect={ ( files ) => { - const newImages = files.filter( - ( img: Image ) => - ! images.find( - ( image ) => - image.id === img.id - ) - ); - if ( newImages.length > 0 ) { - recordEvent( - 'product_images_add_via_media_library' - ); - setValue( 'images', [ - ...images, - ...newImages, - ] ); - } - } } - onUpload={ ( files ) => { - if ( files[ 0 ].id ) { - recordEvent( - 'product_images_add_via_drag_and_drop_upload' - ); - setValue( 'images', [ - ...images, - ...files, - ] ); - } - } } - label={ - <> - { - - { __( - 'Drag images here or click to upload', - 'woocommerce' - ) } - - - } - /> - - ) } -
-
-
-
- ); -}; diff --git a/plugins/woocommerce/changelog/add-36417-mvp-images-slotfill b/plugins/woocommerce/changelog/add-36417-mvp-images-slotfill new file mode 100644 index 00000000000..d4b8e282cca --- /dev/null +++ b/plugins/woocommerce/changelog/add-36417-mvp-images-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Using slotfill to insert images section in product editor. From c42f99048d7b1d89809fb9c88132ed2889989cff Mon Sep 17 00:00:00 2001 From: barryhughes <3594411+barryhughes@users.noreply.github.com> Date: Thu, 19 Jan 2023 10:07:39 -0800 Subject: [PATCH 48/58] Linting (clean-up whitespace). --- .../includes/export/abstract-wc-csv-batch-exporter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php index 4ae391e2fb2..73fd268af66 100644 --- a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php +++ b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php @@ -127,7 +127,7 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter { protected function write_csv_data( $data ) { if ( ! file_exists( $this->get_file_path() ) || ! is_writeable( $this->get_file_path() ) ) { - wc_get_logger()->error( + wc_get_logger()->error( sprintf( /* translators: %s is file path. */ __( 'Unable to create or write to %s during CSV export. Please check file permissions.', 'woocommerce' ), From eca891df09c3e346bf11603992a2b554be9ee737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maikel=20David=20P=C3=A9rez=20G=C3=B3mez?= Date: Thu, 19 Jan 2023 15:59:08 -0300 Subject: [PATCH 49/58] Create tree-control component (#36432) --- .../changelog/add-35851-tree-control | 4 ++ .../hooks/use-linked-tree.ts | 50 +++++++++++++++++++ .../hooks/use-tree-item.ts | 30 +++++++++++ .../hooks/use-tree.ts | 21 ++++++++ .../src/experimental-tree-control/index.ts | 4 ++ .../stories/index.tsx | 42 ++++++++++++++++ .../tree-control.tsx | 20 ++++++++ .../experimental-tree-control/tree-item.scss | 34 +++++++++++++ .../experimental-tree-control/tree-item.tsx | 44 ++++++++++++++++ .../src/experimental-tree-control/tree.scss | 15 ++++++ .../src/experimental-tree-control/tree.tsx | 42 ++++++++++++++++ .../src/experimental-tree-control/types.ts | 31 ++++++++++++ 12 files changed, 337 insertions(+) create mode 100644 packages/js/components/changelog/add-35851-tree-control create mode 100644 packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts create mode 100644 packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts create mode 100644 packages/js/components/src/experimental-tree-control/hooks/use-tree.ts create mode 100644 packages/js/components/src/experimental-tree-control/index.ts create mode 100644 packages/js/components/src/experimental-tree-control/stories/index.tsx create mode 100644 packages/js/components/src/experimental-tree-control/tree-control.tsx create mode 100644 packages/js/components/src/experimental-tree-control/tree-item.scss create mode 100644 packages/js/components/src/experimental-tree-control/tree-item.tsx create mode 100644 packages/js/components/src/experimental-tree-control/tree.scss create mode 100644 packages/js/components/src/experimental-tree-control/tree.tsx create mode 100644 packages/js/components/src/experimental-tree-control/types.ts diff --git a/packages/js/components/changelog/add-35851-tree-control b/packages/js/components/changelog/add-35851-tree-control new file mode 100644 index 00000000000..24395c6622b --- /dev/null +++ b/packages/js/components/changelog/add-35851-tree-control @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create tree-control component diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts new file mode 100644 index 00000000000..94ff95706b8 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { useMemo } from 'react'; + +/** + * Internal dependencies + */ +import { Item, LinkedTree } from '../types'; + +type MemoItems = { + [ value: Item[ 'value' ] ]: LinkedTree; +}; + +function findChildren( + items: Item[], + parent?: Item[ 'parent' ], + memo: MemoItems = {} +): LinkedTree[] { + const children: Item[] = []; + const others: Item[] = []; + + items.forEach( ( item ) => { + if ( item.parent === parent ) { + children.push( item ); + } else { + others.push( item ); + } + memo[ item.value ] = { + parent: undefined, + data: item, + children: [], + }; + } ); + + return children.map( ( child ) => { + const linkedTree = memo[ child.value ]; + linkedTree.parent = child.parent ? memo[ child.parent ] : undefined; + linkedTree.children = findChildren( others, child.value, memo ); + return linkedTree; + } ); +} + +export function useLinkedTree( items: Item[] ): LinkedTree[] { + const linkedTree = useMemo( () => { + return findChildren( items, undefined, {} ); + }, [ items ] ); + + return linkedTree; +} diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts new file mode 100644 index 00000000000..9043fe00f69 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts @@ -0,0 +1,30 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { TreeItemProps } from '../types'; + +export function useTreeItem( { item, level, ...props }: TreeItemProps ) { + const nextLevel = level + 1; + const nextHeadingPaddingLeft = ( level - 1 ) * 28 + 12; + + return { + item, + level: nextLevel, + treeItemProps: { + ...props, + }, + headingProps: { + style: { + paddingLeft: nextHeadingPaddingLeft, + }, + }, + treeProps: { + items: item.children, + level: nextLevel, + }, + }; +} diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts new file mode 100644 index 00000000000..2ab6b889c58 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts @@ -0,0 +1,21 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { TreeProps } from '../types'; + +export function useTree( { ref, items, level = 1, ...props }: TreeProps ) { + return { + level, + items, + treeProps: { + ...props, + }, + treeItemProps: { + level, + }, + }; +} diff --git a/packages/js/components/src/experimental-tree-control/index.ts b/packages/js/components/src/experimental-tree-control/index.ts new file mode 100644 index 00000000000..bf4fdd7d970 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/index.ts @@ -0,0 +1,4 @@ +export * from './tree'; +export * from './tree-control'; +export * from './tree-item'; +export * from './types'; diff --git a/packages/js/components/src/experimental-tree-control/stories/index.tsx b/packages/js/components/src/experimental-tree-control/stories/index.tsx new file mode 100644 index 00000000000..d62a8ed1964 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/stories/index.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { BaseControl } from '@wordpress/components'; +import React, { createElement } from 'react'; + +/** + * Internal dependencies + */ +import { TreeControl } from '../tree-control'; +import { Item } from '../types'; + +const listItems: Item[] = [ + { value: '1', label: 'Technology' }, + { value: '1.1', label: 'Notebooks', parent: '1' }, + { value: '1.2', label: 'Phones', parent: '1' }, + { value: '1.2.1', label: 'iPhone', parent: '1.2' }, + { value: '1.2.1.1', label: 'iPhone 14 Pro', parent: '1.2.1' }, + { value: '1.2.1.2', label: 'iPhone 14 Pro Max', parent: '1.2.1' }, + { value: '1.2.2', label: 'Samsung', parent: '1.2' }, + { value: '1.2.2.1', label: 'Samsung Galaxy 22 Plus', parent: '1.2.2' }, + { value: '1.2.2.2', label: 'Samsung Galaxy 22 Ultra', parent: '1.2.2' }, + { value: '1.3', label: 'Wearables', parent: '1' }, + { value: '2', label: 'Hardware' }, + { value: '2.1', label: 'CPU', parent: '2' }, + { value: '2.2', label: 'GPU', parent: '2' }, + { value: '2.3', label: 'Memory RAM', parent: '2' }, + { value: '3', label: 'Other' }, +]; + +export const SimpleTree: React.FC = () => { + return ( + + + + ); +}; + +export default { + title: 'WooCommerce Admin/experimental/TreeControl', + component: TreeControl, +}; diff --git a/packages/js/components/src/experimental-tree-control/tree-control.tsx b/packages/js/components/src/experimental-tree-control/tree-control.tsx new file mode 100644 index 00000000000..24a484a2995 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-control.tsx @@ -0,0 +1,20 @@ +/** + * External dependencies + */ +import { createElement, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import { useLinkedTree } from './hooks/use-linked-tree'; +import { Tree } from './tree'; +import { TreeControlProps } from './types'; + +export const TreeControl = forwardRef( function ForwardedTree( + { items, ...props }: TreeControlProps, + ref: React.ForwardedRef< HTMLOListElement > +) { + const linkedTree = useLinkedTree( items ); + + return ; +} ); diff --git a/packages/js/components/src/experimental-tree-control/tree-item.scss b/packages/js/components/src/experimental-tree-control/tree-item.scss new file mode 100644 index 00000000000..a62dbde0122 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-item.scss @@ -0,0 +1,34 @@ +.experimental-woocommerce-tree-item { + margin: 0; + + &__heading { + display: flex; + flex-grow: 1; + gap: $gap-smaller; + min-height: $gap-largest; + padding: 0 $gap-small; + border-radius: 2px; + + &:hover, + &:focus-within { + outline: 1.5px solid var( --wp-admin-theme-color ); + outline-offset: -1.5px; + } + + &:hover, + &:focus-within { + background-color: $gray-0; + } + } + &__label { + display: flex; + flex-grow: 1; + align-items: center; + padding: $gap-smaller $gap-small $gap-smaller 0; + position: relative; + + > span { + display: block; + } + } +} diff --git a/packages/js/components/src/experimental-tree-control/tree-item.tsx b/packages/js/components/src/experimental-tree-control/tree-item.tsx new file mode 100644 index 00000000000..e8e7c407932 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-item.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import classNames from 'classnames'; +import { createElement, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import { useTreeItem } from './hooks/use-tree-item'; +import { Tree } from './tree'; +import { TreeItemProps } from './types'; + +export const TreeItem = forwardRef( function ForwardedTreeItem( + props: TreeItemProps, + ref: React.ForwardedRef< HTMLLIElement > +) { + const { item, treeItemProps, headingProps, treeProps } = useTreeItem( { + ...props, + ref, + } ); + + return ( +
  • +
    +
    + { item.data.label } +
    +
    + + { Boolean( item.children.length ) && } +
  • + ); +} ); diff --git a/packages/js/components/src/experimental-tree-control/tree.scss b/packages/js/components/src/experimental-tree-control/tree.scss new file mode 100644 index 00000000000..221208658d5 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree.scss @@ -0,0 +1,15 @@ +@import './tree-item.scss'; + +.experimental-woocommerce-tree { + list-style: none; + padding: 0; + margin: 0; + + &--level-1 { + max-height: 280px; + overflow-y: auto; + background-color: $white; + border: 1px solid $gray-400; + border-radius: 2px; + } +} diff --git a/packages/js/components/src/experimental-tree-control/tree.tsx b/packages/js/components/src/experimental-tree-control/tree.tsx new file mode 100644 index 00000000000..da3a2200839 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; +import { createElement, forwardRef } from 'react'; + +/** + * Internal dependencies + */ +import { useTree } from './hooks/use-tree'; +import { TreeItem } from './tree-item'; +import { TreeProps } from './types'; + +export const Tree = forwardRef( function ForwardedTree( + props: TreeProps, + ref: React.ForwardedRef< HTMLOListElement > +) { + const { level, items, treeProps, treeItemProps } = useTree( { + ...props, + ref, + } ); + + if ( ! items.length ) return null; + return ( +
      + { items.map( ( child ) => ( + + ) ) } +
    + ); +} ); diff --git a/packages/js/components/src/experimental-tree-control/types.ts b/packages/js/components/src/experimental-tree-control/types.ts new file mode 100644 index 00000000000..c925bbb8574 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/types.ts @@ -0,0 +1,31 @@ +export interface Item { + parent?: string; + value: string; + label: string; +} + +export interface LinkedTree { + parent?: LinkedTree; + data: Item; + children: LinkedTree[]; +} + +export type TreeProps = React.DetailedHTMLProps< + React.OlHTMLAttributes< HTMLOListElement >, + HTMLOListElement +> & { + level?: number; + items: LinkedTree[]; +}; + +export type TreeItemProps = React.DetailedHTMLProps< + React.LiHTMLAttributes< HTMLLIElement >, + HTMLLIElement +> & { + level: number; + item: LinkedTree; +}; + +export type TreeControlProps = Omit< TreeProps, 'items' | 'level' > & { + items: Item[]; +}; From 924e64aa486767086c578978635af09918a4cbbb Mon Sep 17 00:00:00 2001 From: Nathan Silveira Date: Thu, 19 Jan 2023 16:19:24 -0300 Subject: [PATCH 50/58] Add new filter to add additional clauses for SQL statement in Variations report (#36378) * Apply filter to get additional sql to include variations with no orders * Add join only when correct option is selected * Some more desperate tests * Analytics: use a sepoarate query for data without orders. * Change 'experimental_woocommerce_analytics_variations_additional_clause' filter to apply sql clauses directly * Cleanup * Add changelog * Fix PHPCS issues * Add docblock and update the filter name * Improve docblock of new filter * Add Since 7.4.0 on filter * Move union statement before order by statement * Apply filters before running the db count statement and add sql select clause before * Remove with sql clause since it's not compatible with MySQL 5.7 * Remove additional spaces * Fix bug caught by unit test in which $variations_query is overwritten because the assignment was outside the 'else' statement Co-authored-by: AnnaMag --- .../changelog/add-new-variation-report-filter | 4 ++++ .../src/Admin/API/Reports/SqlQuery.php | 16 ++++++++++++++-- .../Admin/API/Reports/Variations/DataStore.php | 15 +++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 plugins/woocommerce/changelog/add-new-variation-report-filter diff --git a/plugins/woocommerce/changelog/add-new-variation-report-filter b/plugins/woocommerce/changelog/add-new-variation-report-filter new file mode 100644 index 00000000000..27f636fb3d3 --- /dev/null +++ b/plugins/woocommerce/changelog/add-new-variation-report-filter @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add new filter to add additional clauses for SQL statement in Variations report diff --git a/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php b/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php index 83e78b32f8a..fdcd2a4cb57 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php +++ b/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php @@ -30,6 +30,7 @@ class SqlQuery { 'having' => array(), 'limit' => array(), 'order_by' => array(), + 'union' => array(), ); /** * SQL clause merge filters. @@ -69,7 +70,7 @@ class SqlQuery { * @param string $type Clause type. * @param string $clause SQL clause. */ - protected function add_sql_clause( $type, $clause ) { + public function add_sql_clause( $type, $clause ) { if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) { $this->sql_clauses[ $type ][] = $clause; } @@ -160,8 +161,11 @@ class SqlQuery { $group_by = $this->get_sql_clause( 'group_by', 'filtered' ); $having = $this->get_sql_clause( 'having', 'filtered' ); $order_by = $this->get_sql_clause( 'order_by', 'filtered' ); + $union = $this->get_sql_clause( 'union', 'filtered' ); - $statement = " + $statement = ''; + + $statement .= " SELECT {$this->get_sql_clause( 'select', 'filtered' )} FROM @@ -186,6 +190,13 @@ class SqlQuery { } } + if ( ! empty( $union ) ) { + $statement .= " + UNION + {$union} + "; + } + if ( ! empty( $order_by ) ) { $statement .= " ORDER BY @@ -212,6 +223,7 @@ class SqlQuery { 'having' => array(), 'limit' => array(), 'order_by' => array(), + 'union' => array(), ); } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php index 4ca0bbf4f20..0915ad1390d 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php @@ -453,6 +453,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $variations_query = $this->get_query_statement(); } else { + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + + /** + * Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses. + * + * @since 7.4.0 + * @param array $query_args Query parameters. + * @param SqlQuery $subquery Variations query class. + */ + apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery ); + /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( @@ -468,8 +481,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $data; } - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $variations_query = $this->subquery->get_query_statement(); From 0d67a6aaf1293ab0d0315de7a4d203e22e71e931 Mon Sep 17 00:00:00 2001 From: Moon Date: Thu, 19 Jan 2023 13:05:12 -0800 Subject: [PATCH 51/58] Migrate Table component to TS (#36370) * Migrate table component to TS * Revert pnpm-lock to orig * Revert pnpm-lock to orig * Fix eslint errors * Update packages/js/components/src/table/empty.tsx Co-authored-by: Chi-Hsuan Huang * Update packages/js/components/src/table/empty.tsx Co-authored-by: Chi-Hsuan Huang * Remove unnecessary empty space and convert comment stlye * Update packages/js/components/src/table/index.tsx Co-authored-by: Chi-Hsuan Huang * Remove unnecessary type casting * Type defaultOnQueryChange func correctly Co-authored-by: Chi-Hsuan Huang --- .../dev-migrate-table-component-to-ts | 4 + .../src/table/{empty.js => empty.tsx} | 31 +- packages/js/components/src/table/index.js | 384 -------------- packages/js/components/src/table/index.tsx | 248 +++++++++ .../js/components/src/table/placeholder.js | 68 --- .../js/components/src/table/placeholder.tsx | 55 ++ .../{empty-table.js => empty-table.tsx} | 1 + .../src/table/stories/{index.js => index.ts} | 0 .../src/table/stories/table-card.js | 42 -- .../src/table/stories/table-card.tsx | 93 ++++ ...e-placeholder.js => table-placeholder.tsx} | 14 +- ...older.js => table-summary-placeholder.tsx} | 18 +- .../{table-summary.js => table-summary.jsx} | 0 .../src/table/stories/{table.js => table.tsx} | 26 +- .../src/table/{summary.js => summary.tsx} | 21 +- packages/js/components/src/table/table.js | 491 ------------------ packages/js/components/src/table/table.tsx | 374 +++++++++++++ packages/js/components/src/table/types.ts | 189 +++++++ 18 files changed, 1018 insertions(+), 1041 deletions(-) create mode 100644 packages/js/components/changelog/dev-migrate-table-component-to-ts rename packages/js/components/src/table/{empty.js => empty.tsx} (50%) delete mode 100644 packages/js/components/src/table/index.js create mode 100644 packages/js/components/src/table/index.tsx delete mode 100644 packages/js/components/src/table/placeholder.js create mode 100644 packages/js/components/src/table/placeholder.tsx rename packages/js/components/src/table/stories/{empty-table.js => empty-table.tsx} (83%) rename packages/js/components/src/table/stories/{index.js => index.ts} (100%) delete mode 100644 packages/js/components/src/table/stories/table-card.js create mode 100644 packages/js/components/src/table/stories/table-card.tsx rename packages/js/components/src/table/stories/{table-placeholder.js => table-placeholder.tsx} (52%) rename packages/js/components/src/table/stories/{table-summary-placeholder.js => table-summary-placeholder.tsx} (50%) rename packages/js/components/src/table/stories/{table-summary.js => table-summary.jsx} (100%) rename packages/js/components/src/table/stories/{table.js => table.tsx} (54%) rename packages/js/components/src/table/{summary.js => summary.tsx} (76%) delete mode 100644 packages/js/components/src/table/table.js create mode 100644 packages/js/components/src/table/table.tsx create mode 100644 packages/js/components/src/table/types.ts diff --git a/packages/js/components/changelog/dev-migrate-table-component-to-ts b/packages/js/components/changelog/dev-migrate-table-component-to-ts new file mode 100644 index 00000000000..0f3389fca67 --- /dev/null +++ b/packages/js/components/changelog/dev-migrate-table-component-to-ts @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Migrate Table component to TS \ No newline at end of file diff --git a/packages/js/components/src/table/empty.js b/packages/js/components/src/table/empty.tsx similarity index 50% rename from packages/js/components/src/table/empty.js rename to packages/js/components/src/table/empty.tsx index 503ab1fc9a2..80550ced137 100644 --- a/packages/js/components/src/table/empty.js +++ b/packages/js/components/src/table/empty.tsx @@ -1,39 +1,32 @@ /** * External dependencies */ -import PropTypes from 'prop-types'; import { createElement } from '@wordpress/element'; +import React from 'react'; + +type EmptyTableProps = { + children: React.ReactNode; + + /** An integer with the number of rows the box should occupy. */ + numberOfRows?: number; +}; /** * `EmptyTable` displays a blank space with an optional message passed as a children node * with the purpose of replacing a table with no rows. * It mimics the same height a table would have according to the `numberOfRows` prop. - * - * @param {Object} props - * @param {Node} props.children - * @param {number} props.numberOfRows - * @return {Object} - */ -const EmptyTable = ( { children, numberOfRows } ) => { +const EmptyTable = ( { children, numberOfRows = 5 }: EmptyTableProps ) => { return (
    { children }
    ); }; -EmptyTable.propTypes = { - /** - * An integer with the number of rows the box should occupy. - */ - numberOfRows: PropTypes.number, -}; - -EmptyTable.defaultProps = { - numberOfRows: 5, -}; - export default EmptyTable; diff --git a/packages/js/components/src/table/index.js b/packages/js/components/src/table/index.js deleted file mode 100644 index 2723ceb6c32..00000000000 --- a/packages/js/components/src/table/index.js +++ /dev/null @@ -1,384 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import classnames from 'classnames'; -import { - Card, - CardBody, - CardFooter, - CardHeader, - __experimentalText as Text, -} from '@wordpress/components'; -import { createElement, Component, Fragment } from '@wordpress/element'; -import { find, first, isEqual, without } from 'lodash'; -import PropTypes from 'prop-types'; - -/** - * Internal dependencies - */ -import EllipsisMenu from '../ellipsis-menu'; -import MenuItem from '../ellipsis-menu/menu-item'; -import MenuTitle from '../ellipsis-menu/menu-title'; -import Pagination from '../pagination'; -import Table from './table'; -import TablePlaceholder from './placeholder'; -import TableSummary, { TableSummaryPlaceholder } from './summary'; - -/** - * This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data). - * It accepts `headers` for column headers, and `rows` for the table content. - * `rowHeader` can be used to define the index of the row header (or false if no header). - * - * `TableCard` serves as Card wrapper & contains a card header, ``, ``, and ``. - * This includes filtering and comparison functionality for report pages. - */ -class TableCard extends Component { - constructor( props ) { - super( props ); - const showCols = this.getShowCols( props.headers ); - - this.state = { showCols }; - this.onColumnToggle = this.onColumnToggle.bind( this ); - this.onPageChange = this.onPageChange.bind( this ); - } - - componentDidUpdate( { headers: prevHeaders, query: prevQuery } ) { - const { headers, onColumnsChange, query } = this.props; - const { showCols } = this.state; - - if ( ! isEqual( headers, prevHeaders ) ) { - /* eslint-disable react/no-did-update-set-state */ - this.setState( { - showCols: this.getShowCols( headers ), - } ); - /* eslint-enable react/no-did-update-set-state */ - } - if ( - query.orderby !== prevQuery.orderby && - ! showCols.includes( query.orderby ) - ) { - const newShowCols = showCols.concat( query.orderby ); - /* eslint-disable react/no-did-update-set-state */ - this.setState( { - showCols: newShowCols, - } ); - /* eslint-enable react/no-did-update-set-state */ - onColumnsChange( newShowCols ); - } - } - - getShowCols( headers ) { - return headers - .map( ( { key, visible } ) => { - if ( typeof visible === 'undefined' || visible ) { - return key; - } - return false; - } ) - .filter( Boolean ); - } - - getVisibleHeaders() { - const { headers } = this.props; - const { showCols } = this.state; - return headers.filter( ( { key } ) => showCols.includes( key ) ); - } - - getVisibleRows() { - const { headers, rows } = this.props; - const { showCols } = this.state; - - return rows.map( ( row ) => { - return headers - .map( ( { key }, i ) => { - return showCols.includes( key ) && row[ i ]; - } ) - .filter( Boolean ); - } ); - } - - onColumnToggle( key ) { - const { headers, query, onQueryChange, onColumnsChange } = this.props; - - return () => { - this.setState( ( prevState ) => { - const hasKey = prevState.showCols.includes( key ); - - if ( hasKey ) { - // Handle hiding a sorted column - if ( query.orderby === key ) { - const defaultSort = - find( headers, { defaultSort: true } ) || - first( headers ) || - {}; - onQueryChange( 'sort' )( defaultSort.key, 'desc' ); - } - - const showCols = without( prevState.showCols, key ); - onColumnsChange( showCols, key ); - return { showCols }; - } - - const showCols = [ ...prevState.showCols, key ]; - onColumnsChange( showCols, key ); - return { showCols }; - } ); - }; - } - - onPageChange( ...params ) { - const { onPageChange, onQueryChange } = this.props; - if ( onPageChange ) { - onPageChange( ...params ); - } - if ( onQueryChange ) { - onQueryChange( 'paged' )( ...params ); - } - } - - render() { - const { - actions, - className, - hasSearch, - isLoading, - onQueryChange, - onSort, - query, - rowHeader, - rowsPerPage, - showMenu, - summary, - title, - totalRows, - rowKey, - emptyMessage, - } = this.props; - const { showCols } = this.state; - const allHeaders = this.props.headers; - const headers = this.getVisibleHeaders(); - const rows = this.getVisibleRows(); - const classes = classnames( 'woocommerce-table', className, { - 'has-actions': !! actions, - 'has-menu': showMenu, - 'has-search': hasSearch, - } ); - - return ( - - - - { title } - -
    - { actions } -
    - { showMenu && ( - ( - - - { __( 'Columns:', 'woocommerce' ) } - - { allHeaders.map( - ( { key, label, required } ) => { - if ( required ) { - return null; - } - return ( - - { label } - - ); - } - ) } - - ) } - /> - ) } -
    - - { isLoading ? ( - - - { __( - 'Your requested data is loading', - 'woocommerce' - ) } - - - - ) : ( -
    - ) } - - - - { isLoading ? ( - - ) : ( - - - - { summary && } - - ) } - - - ); - } -} - -TableCard.propTypes = { - /** - * If a search is provided in actions and should reorder actions on mobile. - */ - hasSearch: PropTypes.bool, - /** - * An array of column headers (see `Table` props). - */ - headers: PropTypes.arrayOf( - PropTypes.shape( { - hiddenByDefault: PropTypes.bool, - defaultSort: PropTypes.bool, - isSortable: PropTypes.bool, - key: PropTypes.string, - label: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), - required: PropTypes.bool, - } ) - ), - /** - * A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ]. - */ - ids: PropTypes.arrayOf( PropTypes.number ), - /** - * Defines if the table contents are loading. - * It will display `TablePlaceholder` component instead of `Table` if that's the case. - */ - isLoading: PropTypes.bool, - /** - * A function which returns a callback function to update the query string for a given `param`. - */ - onQueryChange: PropTypes.func, - /** - * A function which returns a callback function which is called upon the user changing the visiblity of columns. - */ - onColumnsChange: PropTypes.func, - /** - * A function which is called upon the user changing the sorting of the table. - */ - onSort: PropTypes.func, - /** - * An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`. - */ - query: PropTypes.object, - /** - * Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col - * is checkboxes, for example). Set to false to disable row headers. - */ - rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ), - /** - * An array of arrays of display/value object pairs (see `Table` props). - */ - rows: PropTypes.arrayOf( - PropTypes.arrayOf( - PropTypes.shape( { - display: PropTypes.node, - value: PropTypes.oneOfType( [ - PropTypes.string, - PropTypes.number, - PropTypes.bool, - ] ), - } ) - ) - ).isRequired, - /** - * The total number of rows to display per page. - */ - rowsPerPage: PropTypes.number.isRequired, - /** - * Boolean to determine whether or not ellipsis menu is shown. - */ - showMenu: PropTypes.bool, - /** - * An array of objects with `label` & `value` properties, which display in a line under the table. - * Optional, can be left off to show no summary. - */ - summary: PropTypes.arrayOf( - PropTypes.shape( { - label: PropTypes.node, - value: PropTypes.oneOfType( [ - PropTypes.string, - PropTypes.number, - ] ), - } ) - ), - /** - * The title used in the card header, also used as the caption for the content in this table. - */ - title: PropTypes.string.isRequired, - /** - * The total number of rows (across all pages). - */ - totalRows: PropTypes.number.isRequired, - /** - * The rowKey used for the key value on each row, this can be a string of the key or a function that returns the value. - * This uses the index if not defined. - */ - rowKey: PropTypes.func, - /** - * Customize the message to show when there are no rows in the table. - */ - emptyMessage: PropTypes.string, -}; - -TableCard.defaultProps = { - isLoading: false, - onQueryChange: () => () => {}, - onColumnsChange: () => {}, - onSort: undefined, - query: {}, - rowHeader: 0, - rows: [], - showMenu: true, - emptyMessage: undefined, -}; - -export default TableCard; diff --git a/packages/js/components/src/table/index.tsx b/packages/js/components/src/table/index.tsx new file mode 100644 index 00000000000..6c12b11f810 --- /dev/null +++ b/packages/js/components/src/table/index.tsx @@ -0,0 +1,248 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import classnames from 'classnames'; +import { createElement, Fragment, useState } from '@wordpress/element'; +import { find, first, without } from 'lodash'; +import React from 'react'; +import { + Card, + CardBody, + CardFooter, + CardHeader, + // @ts-expect-error: Suppressing Module '"@wordpress/components"' has no exported member '__experimentalText' + __experimentalText as Text, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import EllipsisMenu from '../ellipsis-menu'; +import MenuItem from '../ellipsis-menu/menu-item'; +import MenuTitle from '../ellipsis-menu/menu-title'; +import Pagination from '../pagination'; +import Table from './table'; +import TablePlaceholder from './placeholder'; +import TableSummary, { TableSummaryPlaceholder } from './summary'; +import { TableCardProps } from './types'; + +const defaultOnQueryChange = + ( param: string ) => ( path?: string, direction?: string ) => {}; + +const defaultOnColumnsChange = ( + showCols: Array< string >, + key?: string +) => {}; +/** + * This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data). + * It accepts `headers` for column headers, and `rows` for the table content. + * `rowHeader` can be used to define the index of the row header (or false if no header). + * + * `TableCard` serves as Card wrapper & contains a card header, `
    `, ``, and ``. + * This includes filtering and comparison functionality for report pages. + */ +const TableCard: React.VFC< TableCardProps > = ( { + actions, + className, + hasSearch, + headers = [], + ids, + isLoading = false, + onQueryChange = defaultOnQueryChange, + onColumnsChange = defaultOnColumnsChange, + onSort, + query = {}, + rowHeader = 0, + rows = [], + rowsPerPage, + showMenu = true, + summary, + title, + totalRows, + rowKey, + emptyMessage = undefined, + ...props +} ) => { + // eslint-disable-next-line no-console + const getShowCols = ( _headers: TableCardProps[ 'headers' ] = [] ) => { + return _headers + .map( ( { key, visible } ) => { + if ( typeof visible === 'undefined' || visible ) { + return key; + } + return false; + } ) + .filter( Boolean ) as string[]; + }; + + const [ showCols, setShowCols ] = useState( getShowCols( headers ) ); + + const onColumnToggle = ( key: string ) => { + return () => { + const hasKey = showCols.includes( key ); + + if ( hasKey ) { + // Handle hiding a sorted column + if ( query.orderby === key ) { + const defaultSort = find( headers, { + defaultSort: true, + } ) || + first( headers ) || { key: undefined }; + onQueryChange( 'sort' )( defaultSort.key, 'desc' ); + } + + const newShowCols = without( showCols, key ); + onColumnsChange( newShowCols, key ); + setShowCols( newShowCols ); + } else { + const newShowCols = [ ...showCols, key ] as string[]; + onColumnsChange( newShowCols, key ); + setShowCols( newShowCols ); + } + }; + }; + + const onPageChange = ( + newPage: string, + direction?: 'previous' | 'next' + ) => { + if ( props.onPageChange ) { + props.onPageChange( parseInt( newPage, 10 ), direction ); + } + if ( onQueryChange ) { + onQueryChange( 'paged' )( newPage, direction ); + } + }; + + const allHeaders = headers; + const visibleHeaders = headers.filter( ( { key } ) => + showCols.includes( key ) + ); + const visibleRows = rows.map( ( row ) => { + return headers + .map( ( { key }, i ) => { + return showCols.includes( key ) && row[ i ]; + } ) + .filter( Boolean ); + } ); + const classes = classnames( 'woocommerce-table', className, { + 'has-actions': !! actions, + 'has-menu': showMenu, + 'has-search': hasSearch, + } ); + + return ( + + + + { title } + +
    { actions }
    + { showMenu && ( + ( + + { /* @ts-expect-error: Ignoring the error until we migrate ellipsis-menu to TS*/ } + + { /* @ts-expect-error: Allow string */ } + { __( 'Columns:', 'woocommerce' ) } + + { allHeaders.map( + ( { key, label, required } ) => { + if ( required ) { + return null; + } + return ( + + { label } + + ); + } + ) } + + ) } + /> + ) } +
    + { /* Ignoring the error to make it backward compatible for now. */ } + { /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ } + + { isLoading ? ( + + + { __( + 'Your requested data is loading', + 'woocommerce' + ) } + + + + ) : ( +
    void ) + } + rowKey={ rowKey } + emptyMessage={ emptyMessage } + /> + ) } + + + { /* @ts-expect-error: justify is missing from the latest @types/wordpress__components */ } + + { isLoading ? ( + + ) : ( + + + + { summary && } + + ) } + + + ); +}; + +export default TableCard; diff --git a/packages/js/components/src/table/placeholder.js b/packages/js/components/src/table/placeholder.js deleted file mode 100644 index cd6e2cbf999..00000000000 --- a/packages/js/components/src/table/placeholder.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * External dependencies - */ -import { createElement, Component } from '@wordpress/element'; -import { range } from 'lodash'; -import PropTypes from 'prop-types'; - -/** - * Internal dependencies - */ -import Table from './table'; - -/** - * `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading. - */ -class TablePlaceholder extends Component { - render() { - const { numberOfRows, ...tableProps } = this.props; - const rows = range( numberOfRows ).map( () => - this.props.headers.map( () => ( { - display: , - } ) ) - ); - - return ( -
    - ); - } -} - -TablePlaceholder.propTypes = { - /** - * An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`. - */ - query: PropTypes.object, - /** - * A label for the content in this table. - */ - caption: PropTypes.string.isRequired, - /** - * An array of column headers (see `Table` props). - */ - headers: PropTypes.arrayOf( - PropTypes.shape( { - hiddenByDefault: PropTypes.bool, - defaultSort: PropTypes.bool, - isSortable: PropTypes.bool, - key: PropTypes.string, - label: PropTypes.node, - required: PropTypes.bool, - } ) - ), - /** - * An integer with the number of rows to display. - */ - numberOfRows: PropTypes.number, -}; - -TablePlaceholder.defaultProps = { - numberOfRows: 5, -}; - -export default TablePlaceholder; diff --git a/packages/js/components/src/table/placeholder.tsx b/packages/js/components/src/table/placeholder.tsx new file mode 100644 index 00000000000..fbc8ee916f1 --- /dev/null +++ b/packages/js/components/src/table/placeholder.tsx @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { range } from 'lodash'; + +/** + * Internal dependencies + */ +import Table from './table'; +import { QueryProps, TableHeader } from './types'; + +type TablePlaceholderProps = { + /** An object of the query parameters passed to the page */ + query?: QueryProps; + /** A label for the content in this table. */ + caption: string; + /** An integer with the number of rows to display. */ + numberOfRows?: number; + /** + * Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col + * is checkboxes, for example). Set to false to disable row headers. + */ + rowHeader?: number | false; + /** An array of column headers (see `Table` props). */ + headers: Array< TableHeader >; +}; + +/** + * `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading. + */ +const TablePlaceholder: React.VFC< TablePlaceholderProps > = ( { + query, + caption, + headers, + numberOfRows = 5, + ...props +} ) => { + const rows = range( numberOfRows ).map( () => + headers.map( () => ( { + display: , + } ) ) + ); + const tableProps = { query, caption, headers, numberOfRows, ...props }; + return ( +
    + ); +}; + +export default TablePlaceholder; diff --git a/packages/js/components/src/table/stories/empty-table.js b/packages/js/components/src/table/stories/empty-table.tsx similarity index 83% rename from packages/js/components/src/table/stories/empty-table.js rename to packages/js/components/src/table/stories/empty-table.tsx index d7688261b34..cc2674a85f4 100644 --- a/packages/js/components/src/table/stories/empty-table.js +++ b/packages/js/components/src/table/stories/empty-table.tsx @@ -2,6 +2,7 @@ * External dependencies */ import { EmptyTable } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; export const Basic = () => There are no entries.; diff --git a/packages/js/components/src/table/stories/index.js b/packages/js/components/src/table/stories/index.ts similarity index 100% rename from packages/js/components/src/table/stories/index.js rename to packages/js/components/src/table/stories/index.ts diff --git a/packages/js/components/src/table/stories/table-card.js b/packages/js/components/src/table/stories/table-card.js deleted file mode 100644 index cc485146080..00000000000 --- a/packages/js/components/src/table/stories/table-card.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * External dependencies - */ -import { TableCard } from '@woocommerce/components'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { headers, rows, summary } from './index'; - -const TableCardExample = () => { - const [ { query }, setState ] = useState( { - query: { - paged: 1, - }, - } ); - return ( - ( value ) => - setState( { - query: { - [ param ]: value, - }, - } ) } - query={ query } - rowsPerPage={ 7 } - totalRows={ 10 } - summary={ summary } - /> - ); -}; - -export const Basic = () => ; - -export default { - title: 'WooCommerce Admin/components/TableCard', - component: TableCard, -}; diff --git a/packages/js/components/src/table/stories/table-card.tsx b/packages/js/components/src/table/stories/table-card.tsx new file mode 100644 index 00000000000..86aa3202426 --- /dev/null +++ b/packages/js/components/src/table/stories/table-card.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { TableCard } from '@woocommerce/components'; +import { useState, createElement } from '@wordpress/element'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { headers, rows, summary } from './index'; + +const TableCardExample = () => { + const [ { query }, setState ] = useState( { + query: { + paged: 1, + }, + } ); + return ( + ( value ) => + setState( { + // @ts-expect-error: ignore for storybook + query: { + [ param ]: value, + }, + } ) } + query={ query } + rowsPerPage={ 7 } + totalRows={ 10 } + summary={ summary } + /> + ); +}; + +const TableCardWithActionsExample = () => { + const [ { query }, setState ] = useState( { + query: { + paged: 1, + }, + } ); + + const [ action1Text, setAction1Text ] = useState( 'Action 1' ); + const [ action2Text, setAction2Text ] = useState( 'Action 2' ); + + return ( + { + setAction1Text( 'Action 1 Clicked' ); + } } + > + { action1Text } + , + , + ] } + title="Revenue last week" + rows={ rows } + headers={ headers } + onQueryChange={ ( param ) => ( value ) => + setState( { + // @ts-expect-error: ignore for storybook + query: { + [ param ]: value, + }, + } ) } + query={ query } + rowsPerPage={ 7 } + totalRows={ 10 } + summary={ summary } + /> + ); +}; + +export const Basic = () => ; +export const Actions = () => ; + +export default { + title: 'WooCommerce Admin/components/TableCard', + component: TableCard, +}; diff --git a/packages/js/components/src/table/stories/table-placeholder.js b/packages/js/components/src/table/stories/table-placeholder.tsx similarity index 52% rename from packages/js/components/src/table/stories/table-placeholder.js rename to packages/js/components/src/table/stories/table-placeholder.tsx index ea947bb0125..ea1dd40ac65 100644 --- a/packages/js/components/src/table/stories/table-placeholder.js +++ b/packages/js/components/src/table/stories/table-placeholder.tsx @@ -3,17 +3,21 @@ */ import { Card } from '@wordpress/components'; import { TablePlaceholder } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; /** * Internal dependencies */ import { headers } from './index'; -export const Basic = () => ( - - - -); +export const Basic = () => { + return ( + /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ + + + + ); +}; export default { title: 'WooCommerce Admin/components/TablePlaceholder', diff --git a/packages/js/components/src/table/stories/table-summary-placeholder.js b/packages/js/components/src/table/stories/table-summary-placeholder.tsx similarity index 50% rename from packages/js/components/src/table/stories/table-summary-placeholder.js rename to packages/js/components/src/table/stories/table-summary-placeholder.tsx index 6c5e15ed11d..6343c44aa73 100644 --- a/packages/js/components/src/table/stories/table-summary-placeholder.js +++ b/packages/js/components/src/table/stories/table-summary-placeholder.tsx @@ -3,14 +3,18 @@ */ import { Card, CardFooter } from '@wordpress/components'; import { TableSummaryPlaceholder } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; -export const Basic = () => ( - - - - - -); +export const Basic = () => { + return ( + + { /* @ts-expect-error: justify is missing from the latest type def. */ } + + + + + ); +}; export default { title: 'WooCommerce Admin/components/TableSummaryPlaceholder', diff --git a/packages/js/components/src/table/stories/table-summary.js b/packages/js/components/src/table/stories/table-summary.jsx similarity index 100% rename from packages/js/components/src/table/stories/table-summary.js rename to packages/js/components/src/table/stories/table-summary.jsx diff --git a/packages/js/components/src/table/stories/table.js b/packages/js/components/src/table/stories/table.tsx similarity index 54% rename from packages/js/components/src/table/stories/table.js rename to packages/js/components/src/table/stories/table.tsx index 1ea3ffb97c9..a7a68b6bce7 100644 --- a/packages/js/components/src/table/stories/table.js +++ b/packages/js/components/src/table/stories/table.tsx @@ -3,6 +3,7 @@ */ import { Card } from '@wordpress/components'; import { Table } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; /** * Internal dependencies @@ -20,17 +21,20 @@ export const Basic = () => ( ); -export const NoDataCustomMessage = () => ( - -
    row[ 0 ].value } - emptyMessage="Custom empty message" - /> - -); +export const NoDataCustomMessage = () => { + return ( + /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ + +
    row[ 0 ].value } + emptyMessage="Custom empty message" + /> + + ); +}; export default { title: 'WooCommerce Admin/components/Table', diff --git a/packages/js/components/src/table/summary.js b/packages/js/components/src/table/summary.tsx similarity index 76% rename from packages/js/components/src/table/summary.js rename to packages/js/components/src/table/summary.tsx index e3e85ca724f..784d47632da 100644 --- a/packages/js/components/src/table/summary.js +++ b/packages/js/components/src/table/summary.tsx @@ -1,17 +1,17 @@ /** * External dependencies */ -import PropTypes from 'prop-types'; import { createElement } from '@wordpress/element'; /** - * A component to display summarized table data - the list of data passed in on a single line. - * - * @param {Object} props - * @param {Array} props.data - * @return {Object} - + * Internal dependencies */ -const TableSummary = ( { data } ) => { +import { TableSummaryProps } from './types'; + +/** + * A component to display summarized table data - the list of data passed in on a single line. + */ +const TableSummary = ( { data }: TableSummaryProps ) => { return (
      { data.map( ( { label, value }, i ) => ( @@ -28,13 +28,6 @@ const TableSummary = ( { data } ) => { ); }; -TableSummary.propTypes = { - /** - * An array of objects with `label` & `value` properties, which display on a single line. - */ - data: PropTypes.array, -}; - export default TableSummary; /** diff --git a/packages/js/components/src/table/table.js b/packages/js/components/src/table/table.js deleted file mode 100644 index 0f484027b69..00000000000 --- a/packages/js/components/src/table/table.js +++ /dev/null @@ -1,491 +0,0 @@ -/** - * External dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; -import { - createElement, - Component, - createRef, - Fragment, -} from '@wordpress/element'; -import classnames from 'classnames'; -import { Button } from '@wordpress/components'; -import { find, get, noop } from 'lodash'; -import PropTypes from 'prop-types'; -import { withInstanceId } from '@wordpress/compose'; -import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; -import deprecated from '@wordpress/deprecated'; - -const ASC = 'asc'; -const DESC = 'desc'; - -const getDisplay = ( cell ) => cell.display || null; - -/** - * A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering. - * - * Row data should be passed to the component as a list of arrays, where each array is a row in the table. - * Headers are passed in separately as an array of objects with column-related properties. For example, - * this data would render the following table. - * - * ```js - * const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ]; - * const rows = [ - * [ - * { display: 'January', value: 1 }, - * { display: 10, value: 10 }, - * { display: '$530.00', value: 530 }, - * ], - * [ - * { display: 'February', value: 2 }, - * { display: 13, value: 13 }, - * { display: '$675.00', value: 675 }, - * ], - * [ - * { display: 'March', value: 3 }, - * { display: 9, value: 9 }, - * { display: '$460.00', value: 460 }, - * ], - * ] - * ``` - * - * | Month | Orders | Revenue | - * | ---------|--------|---------| - * | January | 10 | $530.00 | - * | February | 13 | $675.00 | - * | March | 9 | $460.00 | - */ -class Table extends Component { - constructor( props ) { - super( props ); - this.state = { - tabIndex: null, - isScrollableRight: false, - isScrollableLeft: false, - }; - this.container = createRef(); - this.sortBy = this.sortBy.bind( this ); - this.updateTableShadow = this.updateTableShadow.bind( this ); - this.getRowKey = this.getRowKey.bind( this ); - } - - componentDidMount() { - const { scrollWidth, clientWidth } = this.container.current; - const scrollable = scrollWidth > clientWidth; - /* eslint-disable react/no-did-mount-set-state */ - this.setState( { - tabIndex: scrollable ? '0' : null, - } ); - /* eslint-enable react/no-did-mount-set-state */ - this.updateTableShadow(); - window.addEventListener( 'resize', this.updateTableShadow ); - } - - componentDidUpdate() { - this.updateTableShadow(); - } - - componentWillUnmount() { - window.removeEventListener( 'resize', this.updateTableShadow ); - } - - sortBy( key ) { - const { headers, query } = this.props; - return () => { - const currentKey = - query.orderby || - get( find( headers, { defaultSort: true } ), 'key', false ); - const currentDir = - query.order || - get( - find( headers, { key: currentKey } ), - 'defaultOrder', - DESC - ); - let dir = DESC; - if ( key === currentKey ) { - dir = DESC === currentDir ? ASC : DESC; - } - this.props.onSort( key, dir ); - }; - } - - updateTableShadow() { - const table = this.container.current; - const { isScrollableRight, isScrollableLeft } = this.state; - - const scrolledToEnd = - table.scrollWidth - table.scrollLeft <= table.offsetWidth; - if ( scrolledToEnd && isScrollableRight ) { - this.setState( { isScrollableRight: false } ); - } else if ( ! scrolledToEnd && ! this.state.isScrollableRight ) { - this.setState( { isScrollableRight: true } ); - } - - const scrolledToStart = table.scrollLeft <= 0; - if ( scrolledToStart && isScrollableLeft ) { - this.setState( { isScrollableLeft: false } ); - } else if ( ! scrolledToStart && ! isScrollableLeft ) { - this.setState( { isScrollableLeft: true } ); - } - } - - getRowKey( row, index ) { - if ( this.props.rowKey && typeof this.props.rowKey === 'function' ) { - return this.props.rowKey( row, index ); - } - return index; - } - - render() { - const { - ariaHidden, - caption, - className, - classNames, - headers, - instanceId, - query, - rowHeader, - rows, - emptyMessage, - } = this.props; - const { isScrollableRight, isScrollableLeft, tabIndex } = this.state; - - if ( classNames ) { - deprecated( `Table component's classNames prop`, { - since: '11.1.0', - version: '12.0.0', - alternative: 'className', - plugin: '@woocommerce/components', - } ); - } - - const classes = classnames( - 'woocommerce-table__table', - classNames, - className, - { - 'is-scrollable-right': isScrollableRight, - 'is-scrollable-left': isScrollableLeft, - } - ); - const sortedBy = - query.orderby || - get( find( headers, { defaultSort: true } ), 'key', false ); - const sortDir = - query.order || - get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC ); - const hasData = !! rows.length; - - return ( -
      -
    - - - - { headers.map( ( header, i ) => { - const { - cellClassName, - isLeftAligned, - isSortable, - isNumeric, - key, - label, - screenReaderLabel, - } = header; - const labelId = `header-${ instanceId }-${ i }`; - const thProps = { - className: classnames( - 'woocommerce-table__header', - cellClassName, - { - 'is-left-aligned': - isLeftAligned || ! isNumeric, - 'is-sortable': isSortable, - 'is-sorted': sortedBy === key, - 'is-numeric': isNumeric, - } - ), - }; - if ( isSortable ) { - thProps[ 'aria-sort' ] = 'none'; - if ( sortedBy === key ) { - thProps[ 'aria-sort' ] = - sortDir === ASC - ? 'ascending' - : 'descending'; - } - } - // We only sort by ascending if the col is already sorted descending - const iconLabel = - sortedBy === key && sortDir !== ASC - ? sprintf( - __( - 'Sort by %s in ascending order', - 'woocommerce' - ), - screenReaderLabel || label - ) - : sprintf( - __( - 'Sort by %s in descending order', - 'woocommerce' - ), - screenReaderLabel || label - ); - - const textLabel = ( - - - { label } - - { screenReaderLabel && ( - - { screenReaderLabel } - - ) } - - ); - - return ( - - ); - } ) } - - { hasData ? ( - rows.map( ( row, i ) => ( - - { row.map( ( cell, j ) => { - const { - cellClassName, - isLeftAligned, - isNumeric, - } = headers[ j ]; - const isHeader = rowHeader === j; - const Cell = isHeader ? 'th' : 'td'; - const cellClasses = classnames( - 'woocommerce-table__item', - cellClassName, - { - 'is-left-aligned': - isLeftAligned || - ! isNumeric, - 'is-numeric': isNumeric, - 'is-sorted': - sortedBy === - headers[ j ].key, - } - ); - const cellKey = - this.getRowKey( - row, - i - ).toString() + j; - return ( - - { getDisplay( cell ) } - - ); - } ) } - - ) ) - ) : ( - - - - ) } - -
    - { caption } - { tabIndex === '0' && ( - - { __( '(scroll to see more)', 'woocommerce' ) } - - ) } -
    - { isSortable ? ( - - - - { iconLabel } - - - ) : ( - textLabel - ) } -
    - { emptyMessage ?? - __( - 'No data to display', - 'woocommerce' - ) } -
    -
    - ); - } -} - -Table.propTypes = { - /** - * Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read. - * Don't use this on real tables unless the table data is loaded elsewhere on the page. - */ - ariaHidden: PropTypes.bool, - /** - * A label for the content in this table - */ - caption: PropTypes.string.isRequired, - /** - * Additional CSS classes. - */ - className: PropTypes.string, - /** - * An array of column headers, as objects. - */ - headers: PropTypes.arrayOf( - PropTypes.shape( { - /** - * Boolean, true if this column is the default for sorting. Only one column should have this set. - */ - defaultSort: PropTypes.bool, - /** - * String, asc|desc if this column is the default for sorting. Only one column should have this set. - */ - defaultOrder: PropTypes.string, - /** - * Boolean, true if this column should be aligned to the left. - */ - isLeftAligned: PropTypes.bool, - /** - * Boolean, true if this column is a number value. - */ - isNumeric: PropTypes.bool, - /** - * Boolean, true if this column is sortable. - */ - isSortable: PropTypes.bool, - /** - * The API parameter name for this column, passed to `orderby` when sorting via API. - */ - key: PropTypes.string, - /** - * The display label for this column. - */ - label: PropTypes.node, - /** - * Boolean, true if this column should always display in the table (not shown in toggle-able list). - */ - required: PropTypes.bool, - /** - * The label used for screen readers for this column. - */ - screenReaderLabel: PropTypes.string, - } ) - ), - /** - * A function called when sortable table headers are clicked, gets the `header.key` as argument. - */ - onSort: PropTypes.func, - /** - * The query string represented in object form - */ - query: PropTypes.object, - /** - * An array of arrays of display/value object pairs. - */ - rows: PropTypes.arrayOf( - PropTypes.arrayOf( - PropTypes.shape( { - /** - * Display value, used for rendering- strings or elements are best here. - */ - display: PropTypes.node, - /** - * "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable. - */ - value: PropTypes.oneOfType( [ - PropTypes.string, - PropTypes.number, - PropTypes.bool, - ] ), - } ) - ) - ).isRequired, - /** - * Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col - * is checkboxes, for example). Set to false to disable row headers. - */ - rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ), - /** - * The rowKey used for the key value on each row, a function that returns the key. - * Defaults to index. - */ - rowKey: PropTypes.func, - /** - * Customize the message to show when there are no rows in the table. - */ - emptyMessage: PropTypes.string, -}; - -Table.defaultProps = { - ariaHidden: false, - headers: [], - onSort: noop, - query: {}, - rowHeader: 0, - emptyMessage: undefined, -}; - -export default withInstanceId( Table ); diff --git a/packages/js/components/src/table/table.tsx b/packages/js/components/src/table/table.tsx new file mode 100644 index 00000000000..6de75954e85 --- /dev/null +++ b/packages/js/components/src/table/table.tsx @@ -0,0 +1,374 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + createElement, + useRef, + Fragment, + useState, + useEffect, +} from '@wordpress/element'; +import classnames from 'classnames'; +import { Button } from '@wordpress/components'; +import { find, get, noop } from 'lodash'; +import { withInstanceId } from '@wordpress/compose'; +import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; +import deprecated from '@wordpress/deprecated'; +import React from 'react'; + +/** + * Internal dependencies + */ +import { TableRow, TableProps } from './types'; + +const ASC = 'asc'; +const DESC = 'desc'; + +const getDisplay = ( cell: { display?: React.ReactNode } ) => + cell.display || null; + +/** + * A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering. + * + * Row data should be passed to the component as a list of arrays, where each array is a row in the table. + * Headers are passed in separately as an array of objects with column-related properties. For example, + * this data would render the following table. + * + * ```js + * const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ]; + * const rows = [ + * [ + * { display: 'January', value: 1 }, + * { display: 10, value: 10 }, + * { display: '$530.00', value: 530 }, + * ], + * [ + * { display: 'February', value: 2 }, + * { display: 13, value: 13 }, + * { display: '$675.00', value: 675 }, + * ], + * [ + * { display: 'March', value: 3 }, + * { display: 9, value: 9 }, + * { display: '$460.00', value: 460 }, + * ], + * ] + * ``` + * + * | Month | Orders | Revenue | + * | ---------|--------|---------| + * | January | 10 | $530.00 | + * | February | 13 | $675.00 | + * | March | 9 | $460.00 | + */ + +const Table: React.VFC< TableProps > = ( { + instanceId, + headers = [], + rows = [], + ariaHidden, + caption, + className, + onSort = ( f ) => f, + query = {}, + rowHeader, + rowKey, + emptyMessage, + ...props +} ) => { + const { classNames } = props; + const [ tabIndex, setTabIndex ] = useState< number | undefined >( + undefined + ); + const [ isScrollableRight, setIsScrollableRight ] = useState( false ); + const [ isScrollableLeft, setIsScrollableLeft ] = useState( false ); + + const container = useRef< HTMLDivElement >( null ); + + if ( classNames ) { + deprecated( `Table component's classNames prop`, { + since: '11.1.0', + version: '12.0.0', + alternative: 'className', + plugin: '@woocommerce/components', + } ); + } + + const classes = classnames( + 'woocommerce-table__table', + classNames, + className, + { + 'is-scrollable-right': isScrollableRight, + 'is-scrollable-left': isScrollableLeft, + } + ); + + const sortBy = ( key: string ) => { + return () => { + const currentKey = + query.orderby || + get( find( headers, { defaultSort: true } ), 'key', false ); + const currentDir = + query.order || + get( + find( headers, { key: currentKey } ), + 'defaultOrder', + DESC + ); + let dir = DESC; + if ( key === currentKey ) { + dir = DESC === currentDir ? ASC : DESC; + } + onSort( key, dir ); + }; + }; + + const getRowKey = ( row: TableRow[], index: number ) => { + if ( rowKey && typeof rowKey === 'function' ) { + return rowKey( row, index ); + } + return index; + }; + + const updateTableShadow = () => { + const table = container.current; + + if ( table?.scrollWidth && table?.scrollHeight && table?.offsetWidth ) { + const scrolledToEnd = + table?.scrollWidth - table?.scrollLeft <= table?.offsetWidth; + if ( scrolledToEnd && isScrollableRight ) { + setIsScrollableRight( false ); + } else if ( ! scrolledToEnd && ! isScrollableRight ) { + setIsScrollableRight( true ); + } + } + + if ( table?.scrollLeft ) { + const scrolledToStart = table?.scrollLeft <= 0; + if ( scrolledToStart && isScrollableLeft ) { + setIsScrollableLeft( false ); + } else if ( ! scrolledToStart && ! isScrollableLeft ) { + setIsScrollableLeft( true ); + } + } + }; + + const sortedBy = + query.orderby || + get( find( headers, { defaultSort: true } ), 'key', false ); + const sortDir = + query.order || + get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC ); + const hasData = !! rows.length; + + useEffect( () => { + const scrollWidth = container.current?.scrollWidth; + const clientWidth = container.current?.clientWidth; + + if ( scrollWidth === undefined || clientWidth === undefined ) { + return; + } + + const scrollable = scrollWidth > clientWidth; + setTabIndex( scrollable ? 0 : undefined ); + updateTableShadow(); + window.addEventListener( 'resize', updateTableShadow ); + + return () => { + window.removeEventListener( 'resize', updateTableShadow ); + }; + }, [] ); + + useEffect( updateTableShadow, [ headers, rows, emptyMessage ] ); + + return ( +
    + + + + + { headers.map( ( header, i ) => { + const { + cellClassName, + isLeftAligned, + isSortable, + isNumeric, + key, + label, + screenReaderLabel, + } = header; + const labelId = `header-${ instanceId }-${ i }`; + const thProps: { [ key: string ]: string } = { + className: classnames( + 'woocommerce-table__header', + cellClassName, + { + 'is-left-aligned': + isLeftAligned || ! isNumeric, + 'is-sortable': isSortable, + 'is-sorted': sortedBy === key, + 'is-numeric': isNumeric, + } + ), + }; + if ( isSortable ) { + thProps[ 'aria-sort' ] = 'none'; + if ( sortedBy === key ) { + thProps[ 'aria-sort' ] = + sortDir === ASC + ? 'ascending' + : 'descending'; + } + } + // We only sort by ascending if the col is already sorted descending + const iconLabel = + sortedBy === key && sortDir !== ASC + ? sprintf( + __( + 'Sort by %s in ascending order', + 'woocommerce' + ), + screenReaderLabel || label + ) + : sprintf( + __( + 'Sort by %s in descending order', + 'woocommerce' + ), + screenReaderLabel || label + ); + + const textLabel = ( + + + { label } + + { screenReaderLabel && ( + + { screenReaderLabel } + + ) } + + ); + + return ( + + ); + } ) } + + { hasData ? ( + rows.map( ( row, i ) => ( + + { row.map( ( cell, j ) => { + const { + cellClassName, + isLeftAligned, + isNumeric, + } = headers[ j ]; + const isHeader = rowHeader === j; + const Cell = isHeader ? 'th' : 'td'; + const cellClasses = classnames( + 'woocommerce-table__item', + cellClassName, + { + 'is-left-aligned': + isLeftAligned || ! isNumeric, + 'is-numeric': isNumeric, + 'is-sorted': + sortedBy === headers[ j ].key, + } + ); + const cellKey = + getRowKey( row, i ).toString() + j; + return ( + + { getDisplay( cell ) } + + ); + } ) } + + ) ) + ) : ( + + + + ) } + +
    + { caption } + { tabIndex === 0 && ( + + { __( '(scroll to see more)', 'woocommerce' ) } + + ) } +
    + { isSortable ? ( + + + + { iconLabel } + + + ) : ( + textLabel + ) } +
    + { emptyMessage ?? + __( 'No data to display', 'woocommerce' ) } +
    +
    + ); +}; + +export default withInstanceId( Table ); diff --git a/packages/js/components/src/table/types.ts b/packages/js/components/src/table/types.ts new file mode 100644 index 00000000000..79b61e92a3d --- /dev/null +++ b/packages/js/components/src/table/types.ts @@ -0,0 +1,189 @@ +export type QueryProps = { + orderby?: string; + order?: string; + page?: number; + per_page?: number; + /** + * Allowing string for backward compatibility + */ + paged?: number | string; +}; + +export type TableHeader = { + /** + * Boolean, true if this column is the default for sorting. Only one column should have this set. + */ + defaultSort?: boolean; + /** + * String, asc|desc if this column is the default for sorting. Only one column should have this set. + */ + defaultOrder?: string; + /** + * Boolean, true if this column should be aligned to the left. + */ + isLeftAligned?: boolean; + /** + * Boolean, true if this column is a number value. + */ + isNumeric?: boolean; + /** + * Boolean, true if this column is sortable. + */ + isSortable?: boolean; + /** + * The API parameter name for this column, passed to `orderby` when sorting via API. + */ + key: string; + /** + * The display label for this column. + */ + label?: React.ReactNode; + /** + * Boolean, true if this column should always display in the table (not shown in toggle-able list). + */ + required?: boolean; + /** + * The label used for screen readers for this column. + */ + screenReaderLabel?: string; + /** + * Additional classname for the header cell + */ + cellClassName?: string; + /** + * Boolean value to control visibility of a header + */ + visible?: boolean; +}; + +export type TableRow = { + /** + * Display value, used for rendering- strings or elements are best here. + */ + display?: React.ReactNode; + /** + * "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable. + */ + value?: string | number | boolean; +}; + +/** + * Props shared between TableProps and TableCardProps. + */ +type CommonTableProps = { + /** + * The rowKey used for the key value on each row, a function that returns the key. + * Defaults to index. + */ + rowKey?: ( row: TableRow[], index: number ) => number; + /** + * Customize the message to show when there are no rows in the table. + */ + emptyMessage?: string; + /** + * The query string represented in object form + */ + query?: QueryProps; + /** + * Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col + * is checkboxes, for example). Set to false to disable row headers. + */ + rowHeader?: number | false; + /** + * An array of column headers (see `Table` props). + */ + headers?: Array< TableHeader >; + /** + * An array of arrays of display/value object pairs (see `Table` props). + */ + rows?: Array< Array< TableRow > >; + /** + * Additional CSS classes. + */ + className?: string; + /** + * A function called when sortable table headers are clicked, gets the `header.key` as argument. + */ + onSort?: ( key: string, direction: string ) => void; +}; + +export type TableProps = CommonTableProps & { + /** A unique ID for this instance of the component. This is automatically generated by withInstanceId. */ + instanceId: number | string; + /** + * Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read. + * Don't use this on real tables unless the table data is loaded elsewhere on the page. + */ + ariaHidden?: boolean; + /** + * A label for the content in this table + */ + caption?: string; + /** + * Additional classnames + */ + classNames?: string | Record< string, string >; +}; + +export type TableSummaryProps = { + // An array of objects with `label` & `value` properties, which display on a single line. + data: Array< { + label: string; + value: boolean | number | string | React.ReactNode; + } >; +}; + +export type TableCardProps = CommonTableProps & { + /** + * An array of custom React nodes that is placed at the top right corner. + */ + actions?: Array< React.ReactNode >; + /** + * If a search is provided in actions and should reorder actions on mobile. + */ + hasSearch?: boolean; + /** + * A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ]. + */ + ids?: Array< number >; + /** + * Defines if the table contents are loading. + * It will display `TablePlaceholder` component instead of `Table` if that's the case. + */ + isLoading?: boolean; + /** + * A function which returns a callback function to update the query string for a given `param`. + */ + // Allowing any for backward compatibitlity + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onQueryChange?: ( param: string ) => ( ...props: any ) => void; + /** + * A function which returns a callback function which is called upon the user changing the visiblity of columns. + */ + onColumnsChange?: ( showCols: Array< string >, key?: string ) => void; + /** + * A callback function that is invoked when the current page is changed. + */ + onPageChange?: ( newPage: number, direction?: 'previous' | 'next' ) => void; + /** + * The total number of rows to display per page. + */ + rowsPerPage: number; + /** + * Boolean to determine whether or not ellipsis menu is shown. + */ + showMenu?: boolean; + /** + * An array of objects with `label` & `value` properties, which display in a line under the table. + * Optional, can be left off to show no summary. + */ + summary?: TableSummaryProps[ 'data' ]; + /** + * The title used in the card header, also used as the caption for the content in this table. + */ + title: string; + /** + * The total number of rows (across all pages). + */ + totalRows: number; +}; From 395f3b3eb5bd2228427bb2e3a4587ac583a05fdb Mon Sep 17 00:00:00 2001 From: Moon Date: Thu, 19 Jan 2023 13:21:54 -0800 Subject: [PATCH 52/58] Add slot for ProgressHeader and ProgressTitle (#36482) * Add slot for ProgressTitle * Add slot for ProgressHeader * Add changelog * Add experimental prefix --- .../default-progress-header.tsx | 72 +++++++++++++++++ .../progress-header/progress-header.tsx | 73 ++++------------- .../task-lists/progress-header/utils.tsx | 38 +++++++++ .../progress-title/default-progress-title.tsx | 69 ++++++++++++++++ .../progress-title/progress-title.tsx | 78 +++++-------------- .../task-lists/progress-title/utils.tsx | 38 +++++++++ ...add-wcadmin-progress-header-and-title-slot | 4 + 7 files changed, 255 insertions(+), 117 deletions(-) create mode 100644 plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx create mode 100644 plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx create mode 100644 plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx create mode 100644 plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx create mode 100644 plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot diff --git a/plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx b/plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx new file mode 100644 index 00000000000..1980bdc4d55 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import './progress-header.scss'; +import { TaskListMenu } from '~/tasks/task-list-menu'; + +export type DefaultProgressHeaderProps = { + taskListId: string; +}; + +export const DefaultProgressHeader: React.FC< DefaultProgressHeaderProps > = ( { + taskListId, +} ) => { + const { loading, tasksCount, completedCount } = useSelect( ( select ) => { + const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( + taskListId + ); + const finishedResolution = select( + ONBOARDING_STORE_NAME + ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); + const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + + return { + loading: ! finishedResolution, + tasksCount: visibleTasks?.length, + completedCount: visibleTasks?.filter( ( task ) => task.isComplete ) + .length, + }; + } ); + + if ( loading ) { + return null; + } + + return ( +
    + +
    + { completedCount !== tasksCount ? ( + <> +

    + { sprintf( + /* translators: 1: completed tasks, 2: total tasks */ + __( + 'Follow these steps to start selling quickly. %1$d out of %2$d complete.', + 'woocommerce' + ), + completedCount, + tasksCount + ) } +

    + + + ) : null } +
    +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx b/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx index e68a7e240ea..e7fadef3fcb 100644 --- a/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx +++ b/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx @@ -1,72 +1,29 @@ /** * External dependencies */ -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; -import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; +import { useSlot } from '@woocommerce/experimental'; /** * Internal dependencies */ import './progress-header.scss'; -import { TaskListMenu } from '~/tasks/task-list-menu'; +import { + WC_TASKLIST_EXPERIMENTAL_PROGRESS_HEADER_SLOT_NAME, + WooTaskListProgressHeaderItem, +} from './utils'; +import { + DefaultProgressHeader, + DefaultProgressHeaderProps, +} from './default-progress-header'; -type ProgressHeaderProps = { - taskListId: string; -}; - -export const ProgressHeader: React.FC< ProgressHeaderProps > = ( { +export const ProgressHeader: React.FC< DefaultProgressHeaderProps > = ( { taskListId, } ) => { - const { loading, tasksCount, completedCount } = useSelect( ( select ) => { - const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( - taskListId - ); - const finishedResolution = select( - ONBOARDING_STORE_NAME - ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); - const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + const slot = useSlot( WC_TASKLIST_EXPERIMENTAL_PROGRESS_HEADER_SLOT_NAME ); - return { - loading: ! finishedResolution, - tasksCount: visibleTasks?.length, - completedCount: visibleTasks?.filter( ( task ) => task.isComplete ) - .length, - }; - } ); - - if ( loading ) { - return null; - } - - return ( -
    - -
    - { completedCount !== tasksCount ? ( - <> -

    - { sprintf( - /* translators: 1: completed tasks, 2: total tasks */ - __( - 'Follow these steps to start selling quickly. %1$d out of %2$d complete.', - 'woocommerce' - ), - completedCount, - tasksCount - ) } -

    - - - ) : null } -
    -
    + return Boolean( slot?.fills?.length ) ? ( + + ) : ( + ); }; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx b/plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx new file mode 100644 index 00000000000..d0f0fc42002 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +/** + * External dependencies + */ +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { createOrderedChildren, sortFillsByOrder } from '../../utils'; + +export const WC_TASKLIST_EXPERIMENTAL_PROGRESS_HEADER_SLOT_NAME = + 'woocommerce_tasklist_experimental_progress_header_item'; + +export const WooTaskListProgressHeaderItem: React.FC< { + order?: number; +} > & { + Slot: React.FC< Slot.Props >; +} = ( { children, order = 1 } ) => { + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooTaskListProgressHeaderItem.Slot = ( { fillProps } ) => { + return ( + + { sortFillsByOrder } + + ); +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx b/plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx new file mode 100644 index 00000000000..6d3b0b06c33 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; +import { getSetting } from '@woocommerce/settings'; + +export type DefaultProgressTitleProps = { + taskListId: string; +}; + +export const DefaultProgressTitle: React.FC< DefaultProgressTitleProps > = ( { + taskListId, +} ) => { + const { loading, tasksCount, completedCount, hasVisitedTasks } = useSelect( + ( select ) => { + const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( + taskListId + ); + const finishedResolution = select( + ONBOARDING_STORE_NAME + ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); + const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + + return { + loading: ! finishedResolution, + tasksCount: visibleTasks?.length, + completedCount: visibleTasks?.filter( + ( task ) => task.isComplete + ).length, + hasVisitedTasks: + visibleTasks?.filter( + ( task ) => + task.isVisited && task.id !== 'store_details' + ).length > 0, + }; + } + ); + + const title = useMemo( () => { + if ( ! hasVisitedTasks || completedCount === tasksCount ) { + const siteTitle = getSetting( 'siteTitle' ); + return siteTitle + ? sprintf( + /* translators: %s = site title */ + __( 'Welcome to %s', 'woocommerce' ), + siteTitle + ) + : __( 'Welcome to your store', 'woocommerce' ); + } + if ( completedCount > 0 && completedCount < 4 ) { + return __( "Let's get you started", 'woocommerce' ) + ' 🚀'; + } + if ( completedCount > 3 && completedCount < 6 ) { + return __( 'You are on the right track', 'woocommerce' ); + } + return __( 'You are almost there', 'woocommerce' ); + }, [ completedCount, hasVisitedTasks, tasksCount ] ); + + if ( loading ) { + return null; + } + + return ( +

    { title }

    + ); +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx b/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx index bad9271c4fc..96d4588ed1d 100644 --- a/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx +++ b/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx @@ -1,69 +1,29 @@ /** * External dependencies */ -import { __, sprintf } from '@wordpress/i18n'; -import { useMemo } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; -import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; -import { getSetting } from '@woocommerce/settings'; +import { useSlot } from '@woocommerce/experimental'; -type ProgressTitleProps = { - taskListId: string; -}; +/** + * Internal dependencies + */ +import { + WC_TASKLIST_EXPERIMENTAL_PROGRESS_TITLE_SLOT_NAME, + WooTaskListProgressTitleItem, +} from './utils'; -export const ProgressTitle: React.FC< ProgressTitleProps > = ( { +import { + DefaultProgressTitle, + DefaultProgressTitleProps, +} from './default-progress-title'; + +export const ProgressTitle: React.FC< DefaultProgressTitleProps > = ( { taskListId, } ) => { - const { loading, tasksCount, completedCount, hasVisitedTasks } = useSelect( - ( select ) => { - const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( - taskListId - ); - const finishedResolution = select( - ONBOARDING_STORE_NAME - ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); - const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + const slot = useSlot( WC_TASKLIST_EXPERIMENTAL_PROGRESS_TITLE_SLOT_NAME ); - return { - loading: ! finishedResolution, - tasksCount: visibleTasks?.length, - completedCount: visibleTasks?.filter( - ( task ) => task.isComplete - ).length, - hasVisitedTasks: - visibleTasks?.filter( - ( task ) => - task.isVisited && task.id !== 'store_details' - ).length > 0, - }; - } - ); - - const title = useMemo( () => { - if ( ! hasVisitedTasks || completedCount === tasksCount ) { - const siteTitle = getSetting( 'siteTitle' ); - return siteTitle - ? sprintf( - /* translators: %s = site title */ - __( 'Welcome to %s', 'woocommerce' ), - siteTitle - ) - : __( 'Welcome to your store', 'woocommerce' ); - } - if ( completedCount > 0 && completedCount < 4 ) { - return __( "Let's get you started", 'woocommerce' ) + ' 🚀'; - } - if ( completedCount > 3 && completedCount < 6 ) { - return __( 'You are on the right track', 'woocommerce' ); - } - return __( 'You are almost there', 'woocommerce' ); - }, [ completedCount, hasVisitedTasks, tasksCount ] ); - - if ( loading ) { - return null; - } - - return ( -

    { title }

    + return Boolean( slot?.fills?.length ) ? ( + + ) : ( + ); }; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx b/plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx new file mode 100644 index 00000000000..e06002eafa6 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +/** + * External dependencies + */ +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { createOrderedChildren, sortFillsByOrder } from '../../utils'; + +export const WC_TASKLIST_EXPERIMENTAL_PROGRESS_TITLE_SLOT_NAME = + 'woocommerce_tasklist_experimental_progress_title_item'; + +export const WooTaskListProgressTitleItem: React.FC< { + order?: number; +} > & { + Slot: React.FC< Slot.Props >; +} = ( { children, order = 1 } ) => { + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooTaskListProgressTitleItem.Slot = ( { fillProps } ) => { + return ( + + { sortFillsByOrder } + + ); +}; diff --git a/plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot b/plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot new file mode 100644 index 00000000000..1be291b98e9 --- /dev/null +++ b/plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added a slot for ProgressHeader and ProgressTitle component \ No newline at end of file From 383c942fa750dccb2e5328a236b6db7557a557d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A9stor=20Soriano?= Date: Thu, 19 Jan 2023 22:30:01 +0100 Subject: [PATCH 53/58] Add an admin notice about the upcoming change in PHP requirements (PHP 7.3) (#36444) * Add an admin notice about the upcoming change in PHP requirements The minimum required PHP version will be 7.3 as of WooCommerce 7.7. This adds a dismissable admin notice to PHP 7.2 users. * Add changelog file * Disable PHPCS warnings for TODO items (required by GitHub CI) * Reformat the PHP 7.3 requirement notice to be more translators-friendly * Add a translators note to pass linting Co-authored-by: Corey McKrill <916023+coreymckrill@users.noreply.github.com> --- .../changelog/add-php-73-version-bump-notice | 4 ++ .../includes/admin/class-wc-admin-notices.php | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 plugins/woocommerce/changelog/add-php-73-version-bump-notice diff --git a/plugins/woocommerce/changelog/add-php-73-version-bump-notice b/plugins/woocommerce/changelog/add-php-73-version-bump-notice new file mode 100644 index 00000000000..09a544979f4 --- /dev/null +++ b/plugins/woocommerce/changelog/add-php-73-version-bump-notice @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add an admin notice about the upcoming PHP version requirement change for PHP 7.2 users diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php index 1bb51f64c04..05f528fc04c 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php @@ -8,6 +8,7 @@ use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Internal\Utilities\Users; +use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; defined( 'ABSPATH' ) || exit; @@ -16,6 +17,8 @@ defined( 'ABSPATH' ) || exit; */ class WC_Admin_Notices { + use AccessiblePrivateMethods; + /** * Stores notices. * @@ -54,6 +57,7 @@ class WC_Admin_Notices { add_action( 'woocommerce_installed', array( __CLASS__, 'reset_admin_notices' ) ); add_action( 'wp_loaded', array( __CLASS__, 'add_redirect_download_method_notice' ) ); add_action( 'admin_init', array( __CLASS__, 'hide_notices' ), 20 ); + self::add_action( 'admin_init', array( __CLASS__, 'maybe_remove_php73_required_notice' ) ); // @TODO: This prevents Action Scheduler async jobs from storing empty list of notices during WC installation. // That could lead to OBW not starting and 'Run setup wizard' notice not appearing in WP admin, which we want @@ -116,8 +120,53 @@ class WC_Admin_Notices { self::add_notice( 'template_files' ); self::add_min_version_notice(); self::add_maxmind_missing_license_key_notice(); + self::maybe_add_php73_required_notice(); } + // phpcs:disable Generic.Commenting.Todo.TaskFound + + /** + * Add an admin notice about the bump of the required PHP version in WooCommerce 7.7 + * if the current PHP version is too old. + * + * TODO: Remove this method in WooCommerce 7.7. + */ + private static function maybe_add_php73_required_notice() { + if ( version_compare( phpversion(), '7.3', '>=' ) ) { + return; + } + + self::add_custom_notice( + 'php73_required_in_woo_77', + sprintf( + '%s%s', + sprintf( + '

    %s

    ', + esc_html__( 'PHP version requirements will change soon', 'woocommerce' ) + ), + sprintf( + // translators: Placeholder is a URL. + wpautop( wp_kses_data( __( 'WooCommerce 7.7, scheduled for May 2023, will require PHP 7.3 or newer to work. Your server is currently running an older version of PHP, so this change will impact your store. Upgrading to at least PHP 8.0 is recommended.
    Learn more about this change.', 'woocommerce' ) ) ), + 'https://developer.woocommerce.com/2023/01/10/new-requirement-for-woocommerce-7-7-php-7-3/' + ) + ) + ); + } + + /** + * Remove the admin notice about the bump of the required PHP version in WooCommerce 7.7 + * if the current PHP version is good. + * + * TODO: Remove this method in WooCommerce 7.7. + */ + private static function maybe_remove_php73_required_notice() { + if ( version_compare( phpversion(), '7.3', '>=' ) && self::has_notice( 'php73_required_in_woo_77' ) ) { + self::remove_notice( 'php73_required_in_woo_77' ); + } + } + + // phpcs:enable Generic.Commenting.Todo.TaskFound + /** * Show a notice. * From 1123f9991f7cc56b57d9c9cffd0e12ffa8f86671 Mon Sep 17 00:00:00 2001 From: Nathan Silveira Date: Thu, 19 Jan 2023 19:43:36 -0300 Subject: [PATCH 54/58] Scroll newly added attribute into view (#36447) * Scroll newly added attribute into view * Increment changelog * Add smooth scrolling when adding new attributes --- .../fields/attribute-control/add-attribute-modal.tsx | 10 ++++++++++ plugins/woocommerce/changelog/add-scroll-attribute | 4 ++++ 2 files changed, 14 insertions(+) create mode 100644 plugins/woocommerce/changelog/add-scroll-attribute diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx index ea4c9ae9b2f..64fd69e9457 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx @@ -80,6 +80,15 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { onAdd, selectedAttributeIds = [], } ) => { + const scrollAttributeIntoView = ( index: number ) => { + setTimeout( () => { + const attributeRow = document.querySelector( + `.woocommerce-add-attribute-modal__table-row-${ index }` + ); + attributeRow?.scrollIntoView( { behavior: 'smooth' } ); + }, 0 ); + }; + const [ showConfirmClose, setShowConfirmClose ] = useState( false ); const addAnother = ( values: AttributeForm, @@ -89,6 +98,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { ) => void ) => { setValue( 'attributes', [ ...values.attributes, null ] ); + scrollAttributeIntoView( values.attributes.length ); }; const onAddingAttributes = ( values: AttributeForm ) => { diff --git a/plugins/woocommerce/changelog/add-scroll-attribute b/plugins/woocommerce/changelog/add-scroll-attribute new file mode 100644 index 00000000000..70045c4e008 --- /dev/null +++ b/plugins/woocommerce/changelog/add-scroll-attribute @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Scroll newly added product attribute into view in new product management experience From d1bd4f5538d8dfb9f5a83446a17dd5a974d2291c Mon Sep 17 00:00:00 2001 From: Matt Sherman Date: Thu, 19 Jan 2023 20:28:25 -0500 Subject: [PATCH 55/58] Add permalink_template and generated_slug to products REST API response (#36497) * Add permalink_template and generated_slug to posts REST API * Changelog * Add missing domain for translations * Use strict comparison for in_array * Fix code style (alignment) issue * Update number of expected properties in products schema --- .../add-product-permalink-template-rest-api | 4 ++ .../class-wc-rest-products-controller.php | 53 +++++++++++++++++-- .../rest-api/Tests/Version3/products.php | 2 +- 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 plugins/woocommerce/changelog/add-product-permalink-template-rest-api diff --git a/plugins/woocommerce/changelog/add-product-permalink-template-rest-api b/plugins/woocommerce/changelog/add-product-permalink-template-rest-api new file mode 100644 index 00000000000..e2647c948bc --- /dev/null +++ b/plugins/woocommerce/changelog/add-product-permalink-template-rest-api @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Add permalink_template and generated_slug to products REST API response. diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index 2cc6137d064..f7d8ff984be 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -1421,6 +1421,24 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { ), ), ); + + $post_type_obj = get_post_type_object( $this->post_type ); + if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) { + $schema['properties']['permalink_template'] = array( + 'description' => __( 'Permalink template for the product.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + + $schema['properties']['generated_slug'] = array( + 'description' => __( 'Slug automatically generated from the product name.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + } + return $this->add_additional_fields_schema( $schema ); } @@ -1462,16 +1480,45 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { */ protected function get_product_data( $product, $context = 'view' ) { $data = parent::get_product_data( ...func_get_args() ); - // Add stock_status if needed. + if ( isset( $this->request ) ) { $fields = $this->get_fields_for_response( $this->request ); - if ( in_array( 'stock_status', $fields ) ) { + + // Add stock_status if needed. + if ( in_array( 'stock_status', $fields, true ) ) { $data['stock_status'] = $product->get_stock_status( $context ); } - if ( in_array( 'has_options', $fields ) ) { + + // Add has_options if needed. + if ( in_array( 'has_options', $fields, true ) ) { $data['has_options'] = $product->has_options( $context ); } + + $post_type_obj = get_post_type_object( $this->post_type ); + if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) { + $permalink_template_requested = in_array( 'permalink_template', $fields, true ); + $generated_slug_requested = in_array( 'generated_slug', $fields, true ); + + if ( $permalink_template_requested || $generated_slug_requested ) { + if ( ! function_exists( 'get_sample_permalink' ) ) { + require_once ABSPATH . 'wp-admin/includes/post.php'; + } + + $sample_permalink = get_sample_permalink( $product->get_id(), $product->get_name(), '' ); + + // Add permalink_template if needed. + if ( $permalink_template_requested ) { + $data['permalink_template'] = $sample_permalink[0]; + } + + // Add generated_slug if needed. + if ( $generated_slug_requested ) { + $data['generated_slug'] = $sample_permalink[1]; + } + } + } } + return $data; } } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php index dd78f483623..552a54c4f75 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php @@ -646,7 +646,7 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 67, count( $properties ) ); + $this->assertEquals( 69, count( $properties ) ); } /** From bc6bc8f9b6dd13098c4ac4f464eb9c5cac8ce520 Mon Sep 17 00:00:00 2001 From: Luigi Teschio Date: Fri, 20 Jan 2023 12:01:03 +0100 Subject: [PATCH 56/58] Update WooCommerce blocks package to 9.4.0 (#36524) bump WC Blocks version --- .../changelog/update-woocommerce-blocks-9.4.0 | 4 ++++ plugins/woocommerce/composer.json | 2 +- plugins/woocommerce/composer.lock | 14 +++++++------- 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 diff --git a/plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 b/plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 new file mode 100644 index 00000000000..ec26ff384d0 --- /dev/null +++ b/plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update WooCommerce Blocks to 9.4.0 diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json index 2819dae9151..b40ca4444a7 100644 --- a/plugins/woocommerce/composer.json +++ b/plugins/woocommerce/composer.json @@ -21,7 +21,7 @@ "maxmind-db/reader": "^1.11", "pelago/emogrifier": "^6.0", "woocommerce/action-scheduler": "3.5.4", - "woocommerce/woocommerce-blocks": "9.1.4" + "woocommerce/woocommerce-blocks": "9.4.0" }, "require-dev": { "automattic/jetpack-changelogger": "^3.3.0", diff --git a/plugins/woocommerce/composer.lock b/plugins/woocommerce/composer.lock index 662897b9e15..f3d227c7c63 100644 --- a/plugins/woocommerce/composer.lock +++ b/plugins/woocommerce/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0bdaab6e57bde687bb8e66ab2f19a64a", + "content-hash": "bb1431be373f6ca9005bbfecbb8805c3", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -628,16 +628,16 @@ }, { "name": "woocommerce/woocommerce-blocks", - "version": "v9.1.4", + "version": "v9.4.0", "source": { "type": "git", "url": "https://github.com/woocommerce/woocommerce-blocks.git", - "reference": "03d5efd33206aa11684dee2c493bbbe9a4e417c8" + "reference": "1d3f841a8f6cfeb44450debe58cf6960b119970b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/woocommerce/woocommerce-blocks/zipball/03d5efd33206aa11684dee2c493bbbe9a4e417c8", - "reference": "03d5efd33206aa11684dee2c493bbbe9a4e417c8", + "url": "https://api.github.com/repos/woocommerce/woocommerce-blocks/zipball/1d3f841a8f6cfeb44450debe58cf6960b119970b", + "reference": "1d3f841a8f6cfeb44450debe58cf6960b119970b", "shasum": "" }, "require": { @@ -683,9 +683,9 @@ ], "support": { "issues": "https://github.com/woocommerce/woocommerce-blocks/issues", - "source": "https://github.com/woocommerce/woocommerce-blocks/tree/v9.1.4" + "source": "https://github.com/woocommerce/woocommerce-blocks/tree/v9.4.0" }, - "time": "2023-01-05T23:41:26+00:00" + "time": "2023-01-17T13:28:50+00:00" } ], "packages-dev": [ From da6b4919f8627d6773b9a7d355c375a97c29d127 Mon Sep 17 00:00:00 2001 From: Nima Karimi <73110514+nima-karimi@users.noreply.github.com> Date: Fri, 20 Jan 2023 11:56:54 +0000 Subject: [PATCH 57/58] Multichannel marketing backend and API (#36453) * Multichannel Marketing - Core Library (#35099) * Create channel interface and campaign value class * Create MarketingChannels class * Register MarketingChannels class in DI container * Use the new MarketingChannels class to get the installed marketing extensions' data * Use DI container to access InstalledExtensions class * Add InstalledExtensions to the $provides array * Hint that campaign cost should also indicate the currency * Initialize the channels array * Add unit tests for MarketingCampaign * Add unit tests for MarketingChannels * Add Price class to represent a price with currency * Use Price class for marketing campaign's cost * Define a constant to indicate the MCM classes exist This constant will be checked by third-party extensions before utilizing any of the classes/interfaces defined for this feature. * Create MarketingSpecs class to include WC.com API calls * Remove WC.com API calls from Marketing class And replace them with calls from MarketingSpecs class. * Use the const from MarketingSpecs * Fix MarketingChannels unit tests * Add missing settings URL to the channel data Co-authored-by: Nima * Multichannel Marketing - Changes to the marketing classes (#36012) * Rename `get_errors_no` to `get_errors_count` * Remove the validation for marketing channel slugs Do not check if the marketing channel's slug exists in the list returned by WooCommerce.com Recommendation API. This allows any third-party extension to register as a marketing channel. * Revert InstalledExtensions The InstalledExtensions class will be used by the previous generation of the Marketing dashboard (if the user has not enabled the new "Marketing" feature); therefore, it's best to restore it to the original code. * Fix code style * Translate Exception message * Remove doc references to a predetermined list of marketing channels Co-authored-by: Nima * Multichannel Marketing - API (#36222) * Rename `get_errors_no` to `get_errors_count` * Remove the validation for marketing channel slugs Do not check if the marketing channel's slug exists in the list returned by WooCommerce.com Recommendation API. This essentially allows any third-party extension to register as a marketing channel. * Revert InstalledExtensions The InstalledExtensions class will be used by the previous generation of Marketing dashboard (if the user has not enabled the new "Marketing" feature); therefore, it's best to restore it to the original code. * Fix code style * Add channel property to MarketingCampaign * Add methods to filter the recommended marketing channels and extensions * Add `marketing/recommendations` API * Add unit tests for `marketing/recommendations` API * Add `marketing/channels` API * Add unit tests for `marketing/channels` API * Add `marketing/campaigns` API * Add unit tests for `marketing/campaigns` API * Translate Exception message * Remove doc references to predetermined list of marketing channels * Add `unregister_all` method To allow unregistering all marketing channels. * Unregister all channels on test tear down * Change API access denied authorization code * Change API access permission * Add MarketingCampaignType class This allows defining campaign types for each marketing channel. * Add campaign type property to campaign class * Add `marketing/campaign-types` API This API returns the aggregated list of supported marketing campaign types for all registered marketing channels. * Add unit tests for `marketing/campaign-types` API * Remove unused jsonSerialize method * Fix unit tests Co-authored-by: Nima * Add changelog * Add product listing status sync failed Co-authored-by: Nima --- ...ature-34548-multichannel-marketing-backend | 4 + .../includes/wc-update-functions.php | 4 +- plugins/woocommerce/src/Admin/API/Init.php | 4 + .../woocommerce/src/Admin/API/Marketing.php | 20 +- .../src/Admin/API/MarketingCampaignTypes.php | 211 ++++++++++++++++ .../src/Admin/API/MarketingCampaigns.php | 237 ++++++++++++++++++ .../src/Admin/API/MarketingChannels.php | 192 ++++++++++++++ .../Admin/API/MarketingRecommendations.php | 235 +++++++++++++++++ .../Admin/Marketing/InstalledExtensions.php | 3 +- .../src/Admin/Marketing/MarketingCampaign.php | 112 +++++++++ .../Admin/Marketing/MarketingCampaignType.php | 130 ++++++++++ .../Marketing/MarketingChannelInterface.php | 90 +++++++ .../src/Admin/Marketing/MarketingChannels.php | 66 +++++ .../woocommerce/src/Admin/Marketing/Price.php | 56 +++++ plugins/woocommerce/src/Container.php | 2 + .../src/Internal/Admin/Marketing.php | 131 ---------- .../Admin/Marketing/MarketingSpecs.php | 217 ++++++++++++++++ .../MarketingServiceProvider.php | 41 +++ .../Admin/API/MarketingCampaignTypesTest.php | 101 ++++++++ .../src/Admin/API/MarketingCampaignsTest.php | 155 ++++++++++++ .../src/Admin/API/MarketingChannelsTest.php | 77 ++++++ .../API/MarketingRecommendationsTest.php | 138 ++++++++++ .../Admin/Marketing/MarketingCampaignTest.php | 42 ++++ .../Admin/Marketing/MarketingChannelsTest.php | 103 ++++++++ 24 files changed, 2233 insertions(+), 138 deletions(-) create mode 100644 plugins/woocommerce/changelog/feature-34548-multichannel-marketing-backend create mode 100644 plugins/woocommerce/src/Admin/API/MarketingCampaignTypes.php create mode 100644 plugins/woocommerce/src/Admin/API/MarketingCampaigns.php create mode 100644 plugins/woocommerce/src/Admin/API/MarketingChannels.php create mode 100644 plugins/woocommerce/src/Admin/API/MarketingRecommendations.php create mode 100644 plugins/woocommerce/src/Admin/Marketing/MarketingCampaign.php create mode 100644 plugins/woocommerce/src/Admin/Marketing/MarketingCampaignType.php create mode 100644 plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php create mode 100644 plugins/woocommerce/src/Admin/Marketing/MarketingChannels.php create mode 100644 plugins/woocommerce/src/Admin/Marketing/Price.php create mode 100644 plugins/woocommerce/src/Internal/Admin/Marketing/MarketingSpecs.php create mode 100644 plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/MarketingServiceProvider.php create mode 100644 plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignTypesTest.php create mode 100644 plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignsTest.php create mode 100644 plugins/woocommerce/tests/php/src/Admin/API/MarketingChannelsTest.php create mode 100644 plugins/woocommerce/tests/php/src/Admin/API/MarketingRecommendationsTest.php create mode 100644 plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingCampaignTest.php create mode 100644 plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingChannelsTest.php diff --git a/plugins/woocommerce/changelog/feature-34548-multichannel-marketing-backend b/plugins/woocommerce/changelog/feature-34548-multichannel-marketing-backend new file mode 100644 index 00000000000..c23bfc56f5a --- /dev/null +++ b/plugins/woocommerce/changelog/feature-34548-multichannel-marketing-backend @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add multichannel marketing API diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php index 7ff5ef67aef..691ba690cb9 100644 --- a/plugins/woocommerce/includes/wc-update-functions.php +++ b/plugins/woocommerce/includes/wc-update-functions.php @@ -19,7 +19,7 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Database\Migrations\MigrationHelper; -use Automattic\WooCommerce\Internal\Admin\Marketing; +use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs; use Automattic\WooCommerce\Internal\AssignDefaultCategory; use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator; use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore; @@ -2470,7 +2470,7 @@ function wc_update_700_remove_download_log_fk() { * Remove the transient data for recommended marketing extensions. */ function wc_update_700_remove_recommended_marketing_plugins_transient() { - delete_transient( Marketing::RECOMMENDED_PLUGINS_TRANSIENT ); + delete_transient( MarketingSpecs::RECOMMENDED_PLUGINS_TRANSIENT ); } /** diff --git a/plugins/woocommerce/src/Admin/API/Init.php b/plugins/woocommerce/src/Admin/API/Init.php index fb98830c9af..78b7d8df9d6 100644 --- a/plugins/woocommerce/src/Admin/API/Init.php +++ b/plugins/woocommerce/src/Admin/API/Init.php @@ -64,6 +64,10 @@ class Init { 'Automattic\WooCommerce\Admin\API\Experiments', 'Automattic\WooCommerce\Admin\API\Marketing', 'Automattic\WooCommerce\Admin\API\MarketingOverview', + 'Automattic\WooCommerce\Admin\API\MarketingRecommendations', + 'Automattic\WooCommerce\Admin\API\MarketingChannels', + 'Automattic\WooCommerce\Admin\API\MarketingCampaigns', + 'Automattic\WooCommerce\Admin\API\MarketingCampaignTypes', 'Automattic\WooCommerce\Admin\API\Options', 'Automattic\WooCommerce\Admin\API\Orders', 'Automattic\WooCommerce\Admin\API\PaymentGatewaySuggestions', diff --git a/plugins/woocommerce/src/Admin/API/Marketing.php b/plugins/woocommerce/src/Admin/API/Marketing.php index de06c4b7071..a417170f00a 100644 --- a/plugins/woocommerce/src/Admin/API/Marketing.php +++ b/plugins/woocommerce/src/Admin/API/Marketing.php @@ -7,8 +7,8 @@ namespace Automattic\WooCommerce\Admin\API; -use Automattic\WooCommerce\Internal\Admin\Marketing as MarketingFeature; use Automattic\WooCommerce\Admin\PluginsHelper; +use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs; defined( 'ABSPATH' ) || exit; @@ -103,9 +103,16 @@ class Marketing extends \WC_REST_Data_Controller { * @return \WP_Error|\WP_REST_Response */ public function get_recommended_plugins( $request ) { + /** + * MarketingSpecs class. + * + * @var MarketingSpecs $marketing_specs + */ + $marketing_specs = wc_get_container()->get( MarketingSpecs::class ); + // Default to marketing category (if no category set). $category = ( ! empty( $request->get_param( 'category' ) ) ) ? $request->get_param( 'category' ) : 'marketing'; - $all_plugins = MarketingFeature::get_instance()->get_recommended_plugins(); + $all_plugins = $marketing_specs->get_recommended_plugins(); $valid_plugins = []; $per_page = $request->get_param( 'per_page' ); @@ -130,7 +137,14 @@ class Marketing extends \WC_REST_Data_Controller { * @return \WP_Error|\WP_REST_Response */ public function get_knowledge_base_posts( $request ) { + /** + * MarketingSpecs class. + * + * @var MarketingSpecs $marketing_specs + */ + $marketing_specs = wc_get_container()->get( MarketingSpecs::class ); + $category = $request->get_param( 'category' ); - return rest_ensure_response( MarketingFeature::get_instance()->get_knowledge_base_posts( $category ) ); + return rest_ensure_response( $marketing_specs->get_knowledge_base_posts( $category ) ); } } diff --git a/plugins/woocommerce/src/Admin/API/MarketingCampaignTypes.php b/plugins/woocommerce/src/Admin/API/MarketingCampaignTypes.php new file mode 100644 index 00000000000..f01dba7a649 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/MarketingCampaignTypes.php @@ -0,0 +1,211 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Retrieves the query params for the collections. + * + * @return array Query parameters for the collection. + */ + public function get_collection_params() { + $params = parent::get_collection_params(); + unset( $params['search'] ); + + return $params; + } + + /** + * Check whether a given request has permission to view marketing campaigns. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { + return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Returns an aggregated array of marketing campaigns for all active marketing channels. + * + * @param WP_REST_Request $request Request data. + * + * @return WP_Error|WP_REST_Response + */ + public function get_items( $request ) { + /** + * MarketingChannels class. + * + * @var MarketingChannelsService $marketing_channels_service + */ + $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); + + // Aggregate the supported campaign types from all registered marketing channels. + $responses = []; + foreach ( $marketing_channels_service->get_registered_channels() as $channel ) { + foreach ( $channel->get_supported_campaign_types() as $campaign_type ) { + $response = $this->prepare_item_for_response( $campaign_type, $request ); + $responses[] = $this->prepare_response_for_collection( $response ); + } + } + + return rest_ensure_response( $responses ); + } + + /** + * Prepares the item for the REST response. + * + * @param MarketingCampaignType $item WordPress representation of the item. + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $data = [ + 'id' => $item->get_id(), + 'name' => $item->get_name(), + 'description' => $item->get_description(), + 'channel' => [ + 'slug' => $item->get_channel()->get_slug(), + 'name' => $item->get_channel()->get_name(), + ], + 'create_url' => $item->get_create_url(), + 'icon_url' => $item->get_icon_url(), + ]; + + $context = $request['context'] ?? 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'marketing_campaign_type', + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'description' => __( 'The unique identifier for the marketing campaign type.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'name' => [ + 'description' => __( 'Name of the marketing campaign type.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'description' => [ + 'description' => __( 'Description of the marketing campaign type.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'channel' => [ + 'description' => __( 'The marketing channel that this campaign type belongs to.', 'woocommerce' ), + 'type' => 'object', + 'context' => [ 'view' ], + 'readonly' => true, + 'properties' => [ + 'slug' => [ + 'description' => __( 'The unique identifier of the marketing channel that this campaign type belongs to.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'name' => [ + 'description' => __( 'The name of the marketing channel that this campaign type belongs to.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + ], + ], + 'create_url' => [ + 'description' => __( 'URL to the create campaign page for this campaign type.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'icon_url' => [ + 'description' => __( 'URL to an image/icon for the campaign type.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + ], + ]; + + return $this->add_additional_fields_schema( $schema ); + } + + +} diff --git a/plugins/woocommerce/src/Admin/API/MarketingCampaigns.php b/plugins/woocommerce/src/Admin/API/MarketingCampaigns.php new file mode 100644 index 00000000000..d8f9ac79378 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/MarketingCampaigns.php @@ -0,0 +1,237 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Check whether a given request has permission to view marketing campaigns. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { + return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + + /** + * Returns an aggregated array of marketing campaigns for all active marketing channels. + * + * @param WP_REST_Request $request Request data. + * + * @return WP_Error|WP_REST_Response + */ + public function get_items( $request ) { + /** + * MarketingChannels class. + * + * @var MarketingChannelsService $marketing_channels_service + */ + $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); + + // Aggregate the campaigns from all registered marketing channels. + $responses = []; + foreach ( $marketing_channels_service->get_registered_channels() as $channel ) { + foreach ( $channel->get_campaigns() as $campaign ) { + $response = $this->prepare_item_for_response( $campaign, $request ); + $responses[] = $this->prepare_response_for_collection( $response ); + } + } + + // Pagination. + $page = $request['page']; + $items_per_page = $request['per_page']; + $offset = ( $page - 1 ) * $items_per_page; + $paginated_results = array_slice( $responses, $offset, $items_per_page ); + + $response = rest_ensure_response( $paginated_results ); + + $total_campaigns = count( $responses ); + $max_pages = ceil( $total_campaigns / $items_per_page ); + $response->header( 'X-WP-Total', $total_campaigns ); + $response->header( 'X-WP-TotalPages', (int) $max_pages ); + + // Add previous and next page links to response header. + $request_params = $request->get_query_params(); + $base = add_query_arg( urlencode_deep( $request_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + if ( $page > 1 ) { + $prev_page = $page - 1; + if ( $prev_page > $max_pages ) { + $prev_page = $max_pages; + } + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + if ( $max_pages > $page ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Prepares the item for the REST response. + * + * @param MarketingCampaign $item WordPress representation of the item. + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $data = [ + 'id' => $item->get_id(), + 'channel' => $item->get_type()->get_channel()->get_slug(), + 'title' => $item->get_title(), + 'manage_url' => $item->get_manage_url(), + ]; + + if ( $item->get_cost() instanceof Price ) { + $data['cost'] = [ + 'value' => wc_format_decimal( $item->get_cost()->get_value() ), + 'currency' => $item->get_cost()->get_currency(), + ]; + } + + $context = $request['context'] ?? 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'marketing_campaign', + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'description' => __( 'The unique identifier for the marketing campaign.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'channel' => [ + 'description' => __( 'The unique identifier for the marketing channel that this campaign belongs to.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'title' => [ + 'description' => __( 'Title of the marketing campaign.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'manage_url' => [ + 'description' => __( 'URL to the campaign management page.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'cost' => [ + 'description' => __( 'Cost of the marketing campaign.', 'woocommerce' ), + 'context' => [ 'view' ], + 'readonly' => true, + 'type' => 'object', + 'properties' => [ + 'value' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'currency' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + ], + ], + ], + ]; + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for the collections. + * + * @return array Query parameters for the collection. + */ + public function get_collection_params() { + $params = parent::get_collection_params(); + unset( $params['search'] ); + + return $params; + } + + +} diff --git a/plugins/woocommerce/src/Admin/API/MarketingChannels.php b/plugins/woocommerce/src/Admin/API/MarketingChannels.php new file mode 100644 index 00000000000..bb22cd01c74 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/MarketingChannels.php @@ -0,0 +1,192 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Check whether a given request has permission to view marketing channels. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { + return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Return installed marketing channels. + * + * @param WP_REST_Request $request Request data. + * + * @return WP_Error|WP_REST_Response + */ + public function get_items( $request ) { + /** + * MarketingChannels class. + * + * @var MarketingChannelsService $marketing_channels_service + */ + $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); + + $channels = $marketing_channels_service->get_registered_channels(); + + $responses = []; + foreach ( $channels as $item ) { + $response = $this->prepare_item_for_response( $item, $request ); + $responses[] = $this->prepare_response_for_collection( $response ); + } + + return rest_ensure_response( $responses ); + } + + /** + * Prepares the item for the REST response. + * + * @param MarketingChannelInterface $item WordPress representation of the item. + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $data = [ + 'slug' => $item->get_slug(), + 'is_setup_completed' => $item->is_setup_completed(), + 'settings_url' => $item->get_setup_url(), + 'name' => $item->get_name(), + 'description' => $item->get_description(), + 'product_listings_status' => $item->get_product_listings_status(), + 'errors_count' => $item->get_errors_count(), + 'icon' => $item->get_icon_url(), + ]; + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'marketing_channel', + 'type' => 'object', + 'properties' => [ + 'slug' => [ + 'description' => __( 'Unique identifier string for the marketing channel extension, also known as the plugin slug.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'name' => [ + 'description' => __( 'Name of the marketing channel.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'description' => [ + 'description' => __( 'Description of the marketing channel.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'icon' => [ + 'description' => __( 'Path to the channel icon.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'is_setup_completed' => [ + 'type' => 'boolean', + 'description' => __( 'Whether or not the marketing channel is set up.', 'woocommerce' ), + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'settings_url' => [ + 'description' => __( 'URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'product_listings_status' => [ + 'description' => __( 'Status of the marketing channel\'s product listings.', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'errors_count' => [ + 'description' => __( 'Number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.).', 'woocommerce' ), + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + ], + ]; + + return $this->add_additional_fields_schema( $schema ); + } +} diff --git a/plugins/woocommerce/src/Admin/API/MarketingRecommendations.php b/plugins/woocommerce/src/Admin/API/MarketingRecommendations.php new file mode 100644 index 00000000000..7e9cbf398b8 --- /dev/null +++ b/plugins/woocommerce/src/Admin/API/MarketingRecommendations.php @@ -0,0 +1,235 @@ +namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + 'args' => [ + 'category' => [ + 'type' => 'string', + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'sanitize_title_with_dashes', + 'enum' => [ 'channels', 'extensions' ], + 'required' => true, + ], + ], + ], + 'schema' => [ $this, 'get_public_item_schema' ], + ] + ); + } + + /** + * Check whether a given request has permission to view marketing recommendations. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|boolean + */ + public function get_items_permissions_check( $request ) { + if ( ! current_user_can( 'install_plugins' ) ) { + return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view marketing channels.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Retrieves a collection of recommendations. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + /** + * MarketingSpecs class. + * + * @var MarketingSpecs $marketing_specs + */ + $marketing_specs = wc_get_container()->get( MarketingSpecs::class ); + + $category = $request->get_param( 'category' ); + if ( 'channels' === $category ) { + $items = $marketing_specs->get_recommended_marketing_channels(); + } elseif ( 'extensions' === $category ) { + $items = $marketing_specs->get_recommended_marketing_extensions_excluding_channels(); + } else { + return new WP_Error( 'woocommerce_rest_invalid_category', __( 'The specified category for recommendations is invalid. Allowed values: "channels", "extensions".', 'woocommerce' ), array( 'status' => 400 ) ); + } + + $responses = []; + foreach ( $items as $item ) { + $response = $this->prepare_item_for_response( $item, $request ); + $responses[] = $this->prepare_response_for_collection( $response ); + } + + return rest_ensure_response( $responses ); + } + + /** + * Prepares the item for the REST response. + * + * @param array $item WordPress representation of the item. + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $item, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'marketing_recommendation', + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'description' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'url' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'direct_install' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'icon' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'product' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'plugin' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'categories' => [ + 'type' => 'array', + 'context' => [ 'view' ], + 'readonly' => true, + 'items' => [ + 'type' => 'string', + ], + ], + 'subcategories' => [ + 'type' => 'array', + 'context' => [ 'view' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'context' => [ 'view' ], + 'readonly' => true, + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'name' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + ], + ], + ], + 'tags' => [ + 'type' => 'array', + 'context' => [ 'view' ], + 'readonly' => true, + 'items' => [ + 'type' => 'object', + 'context' => [ 'view' ], + 'readonly' => true, + 'properties' => [ + 'slug' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + 'name' => [ + 'type' => 'string', + 'context' => [ 'view' ], + 'readonly' => true, + ], + ], + ], + ], + ], + ]; + + return $this->add_additional_fields_schema( $schema ); + } +} diff --git a/plugins/woocommerce/src/Admin/Marketing/InstalledExtensions.php b/plugins/woocommerce/src/Admin/Marketing/InstalledExtensions.php index 3f90c964d28..b1e9ad82b80 100644 --- a/plugins/woocommerce/src/Admin/Marketing/InstalledExtensions.php +++ b/plugins/woocommerce/src/Admin/Marketing/InstalledExtensions.php @@ -241,7 +241,7 @@ class InstalledExtensions { $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/facebook-icon.svg'; - if ( $data['status'] === 'activated' && function_exists( 'facebook_for_woocommerce' ) ) { + if ( 'activated' === $data['status'] && function_exists( 'facebook_for_woocommerce' ) ) { $integration = facebook_for_woocommerce()->get_integration(); if ( $integration->is_configured() ) { @@ -270,7 +270,6 @@ class InstalledExtensions { $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/pinterest.svg'; - // TODO: Finalise docs url. $data['docsUrl'] = 'https://woocommerce.com/document/pinterest-for-woocommerce/?utm_medium=product'; if ( 'activated' === $data['status'] && class_exists( 'Pinterest_For_Woocommerce' ) ) { diff --git a/plugins/woocommerce/src/Admin/Marketing/MarketingCampaign.php b/plugins/woocommerce/src/Admin/Marketing/MarketingCampaign.php new file mode 100644 index 00000000000..c85ced83f21 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Marketing/MarketingCampaign.php @@ -0,0 +1,112 @@ +id = $id; + $this->type = $type; + $this->title = $title; + $this->manage_url = $manage_url; + $this->cost = $cost; + } + + /** + * Returns the marketing campaign's unique identifier. + * + * @return string + */ + public function get_id(): string { + return $this->id; + } + + /** + * Returns the marketing campaign type. + * + * @return MarketingCampaignType + */ + public function get_type(): MarketingCampaignType { + return $this->type; + } + + /** + * Returns the title of the marketing campaign. + * + * @return string + */ + public function get_title(): string { + return $this->title; + } + + /** + * Returns the URL to manage the marketing campaign. + * + * @return string + */ + public function get_manage_url(): string { + return $this->manage_url; + } + + /** + * Returns the cost of the marketing campaign with the currency. + * + * @return Price|null + */ + public function get_cost(): ?Price { + return $this->cost; + } +} diff --git a/plugins/woocommerce/src/Admin/Marketing/MarketingCampaignType.php b/plugins/woocommerce/src/Admin/Marketing/MarketingCampaignType.php new file mode 100644 index 00000000000..032dc6f249d --- /dev/null +++ b/plugins/woocommerce/src/Admin/Marketing/MarketingCampaignType.php @@ -0,0 +1,130 @@ +id = $id; + $this->channel = $channel; + $this->name = $name; + $this->description = $description; + $this->create_url = $create_url; + $this->icon_url = $icon_url; + } + + /** + * Returns the marketing campaign's unique identifier. + * + * @return string + */ + public function get_id(): string { + return $this->id; + } + + /** + * Returns the marketing channel that this campaign type belongs to. + * + * @return MarketingChannelInterface + */ + public function get_channel(): MarketingChannelInterface { + return $this->channel; + } + + /** + * Returns the name of the marketing campaign type. + * + * @return string + */ + public function get_name(): string { + return $this->name; + } + + /** + * Returns the description of the marketing campaign type. + * + * @return string + */ + public function get_description(): string { + return $this->description; + } + + /** + * Returns the URL to the create campaign page. + * + * @return string + */ + public function get_create_url(): string { + return $this->create_url; + } + + /** + * Returns the URL to an image/icon for the campaign type. + * + * @return string + */ + public function get_icon_url(): string { + return $this->icon_url; + } +} diff --git a/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php b/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php new file mode 100644 index 00000000000..5bdf72194c6 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php @@ -0,0 +1,90 @@ +registered_channels[ $channel->get_slug() ] ) ) { + throw new Exception( __( 'Marketing channel cannot be registered because there is already a channel registered with the same slug!', 'woocommerce' ) ); + } + + $this->registered_channels[ $channel->get_slug() ] = $channel; + } + + /** + * Unregisters all marketing channels. + * + * @return void + */ + public function unregister_all(): void { + unset( $this->registered_channels ); + } + + /** + * Returns an array of all registered marketing channels. + * + * @return MarketingChannelInterface[] + */ + public function get_registered_channels(): array { + /** + * Filter the list of registered marketing channels. + * + * @param MarketingChannelInterface[] $channels Array of registered marketing channels. + * + * @since x.x.x + */ + $channels = apply_filters( 'woocommerce_marketing_channels', $this->registered_channels ); + + return array_values( $channels ); + } +} diff --git a/plugins/woocommerce/src/Admin/Marketing/Price.php b/plugins/woocommerce/src/Admin/Marketing/Price.php new file mode 100644 index 00000000000..961228d5730 --- /dev/null +++ b/plugins/woocommerce/src/Admin/Marketing/Price.php @@ -0,0 +1,56 @@ +value = $value; + $this->currency = $currency; + } + + /** + * Get value of the price. + * + * @return string + */ + public function get_value(): string { + return $this->value; + } + + /** + * Get the currency of the price. + * + * @return string + */ + public function get_currency(): string { + return $this->currency; + } +} diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index 3815db81840..0e64a37d322 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMig use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\FeaturesServiceProvider; +use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\MarketingServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersControllersServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderAdminServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderMetaBoxServiceProvider; @@ -65,6 +66,7 @@ final class Container { OrderMetaBoxServiceProvider::class, OrderAdminServiceProvider::class, FeaturesServiceProvider::class, + MarketingServiceProvider::class, ); /** diff --git a/plugins/woocommerce/src/Internal/Admin/Marketing.php b/plugins/woocommerce/src/Internal/Admin/Marketing.php index 65cf4a5b464..47d4ab241bb 100644 --- a/plugins/woocommerce/src/Internal/Admin/Marketing.php +++ b/plugins/woocommerce/src/Internal/Admin/Marketing.php @@ -7,7 +7,6 @@ namespace Automattic\WooCommerce\Internal\Admin; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions; -use Automattic\WooCommerce\Internal\Admin\Loader; use Automattic\WooCommerce\Admin\PageController; /** @@ -17,20 +16,6 @@ class Marketing { use CouponsMovedTrait; - /** - * Name of recommended plugins transient. - * - * @var string - */ - const RECOMMENDED_PLUGINS_TRANSIENT = 'wc_marketing_recommended_plugins'; - - /** - * Name of knowledge base post transient. - * - * @var string - */ - const KNOWLEDGE_BASE_TRANSIENT = 'wc_marketing_knowledge_base'; - /** * Class instance. * @@ -184,120 +169,4 @@ class Marketing { return $settings; } - - /** - * Load recommended plugins from WooCommerce.com - * - * @return array - */ - public function get_recommended_plugins() { - $plugins = get_transient( self::RECOMMENDED_PLUGINS_TRANSIENT ); - - if ( false === $plugins ) { - $request = wp_remote_get( - 'https://woocommerce.com/wp-json/wccom/marketing-tab/1.2/recommendations.json', - array( - 'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ), - ) - ); - $plugins = []; - - if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) { - $plugins = json_decode( $request['body'], true ); - } - - set_transient( - self::RECOMMENDED_PLUGINS_TRANSIENT, - $plugins, - // Expire transient in 15 minutes if remote get failed. - // Cache an empty result to avoid repeated failed requests. - empty( $plugins ) ? 900 : 3 * DAY_IN_SECONDS - ); - } - - return array_values( $plugins ); - } - - /** - * Load knowledge base posts from WooCommerce.com - * - * @param string $category Category of posts to retrieve. - * @return array - */ - public function get_knowledge_base_posts( $category ) { - - $kb_transient = self::KNOWLEDGE_BASE_TRANSIENT; - - $categories = array( - 'marketing' => 1744, - 'coupons' => 25202, - ); - - // Default to marketing category (if no category set on the kb component). - if ( ! empty( $category ) && array_key_exists( $category, $categories ) ) { - $category_id = $categories[ $category ]; - $kb_transient = $kb_transient . '_' . strtolower( $category ); - } else { - $category_id = $categories['marketing']; - } - - $posts = get_transient( $kb_transient ); - - if ( false === $posts ) { - $request_url = add_query_arg( - array( - 'categories' => $category_id, - 'page' => 1, - 'per_page' => 8, - '_embed' => 1, - ), - 'https://woocommerce.com/wp-json/wp/v2/posts?utm_medium=product' - ); - - $request = wp_remote_get( - $request_url, - array( - 'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ), - ) - ); - $posts = []; - - if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) { - $raw_posts = json_decode( $request['body'], true ); - - foreach ( $raw_posts as $raw_post ) { - $post = [ - 'title' => html_entity_decode( $raw_post['title']['rendered'] ), - 'date' => $raw_post['date_gmt'], - 'link' => $raw_post['link'], - 'author_name' => isset( $raw_post['author_name'] ) ? html_entity_decode( $raw_post['author_name'] ) : '', - 'author_avatar' => isset( $raw_post['author_avatar_url'] ) ? $raw_post['author_avatar_url'] : '', - ]; - - $featured_media = $raw_post['_embedded']['wp:featuredmedia'] ?? []; - if ( count( $featured_media ) > 0 ) { - $image = current( $featured_media ); - $post['image'] = add_query_arg( - array( - 'resize' => '650,340', - 'crop' => 1, - ), - $image['source_url'] - ); - } - - $posts[] = $post; - } - } - - set_transient( - $kb_transient, - $posts, - // Expire transient in 15 minutes if remote get failed. - empty( $posts ) ? 900 : DAY_IN_SECONDS - ); - } - - return $posts; - } } diff --git a/plugins/woocommerce/src/Internal/Admin/Marketing/MarketingSpecs.php b/plugins/woocommerce/src/Internal/Admin/Marketing/MarketingSpecs.php new file mode 100644 index 00000000000..e6ed4e875d9 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Admin/Marketing/MarketingSpecs.php @@ -0,0 +1,217 @@ + 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ), + ) + ); + $plugins = []; + + if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) { + $plugins = json_decode( $request['body'], true ); + } + + set_transient( + self::RECOMMENDED_PLUGINS_TRANSIENT, + $plugins, + // Expire transient in 15 minutes if remote get failed. + // Cache an empty result to avoid repeated failed requests. + empty( $plugins ) ? 900 : 3 * DAY_IN_SECONDS + ); + } + + return array_values( $plugins ); + } + + /** + * Return only the recommended marketing channels from WooCommerce.com. + * + * @return array + */ + public function get_recommended_marketing_channels(): array { + return array_filter( $this->get_recommended_plugins(), [ $this, 'is_marketing_channel_plugin' ] ); + } + + /** + * Return all recommended marketing extensions EXCEPT the marketing channels from WooCommerce.com. + * + * @return array + */ + public function get_recommended_marketing_extensions_excluding_channels(): array { + return array_filter( + $this->get_recommended_plugins(), + function ( array $plugin_data ) { + return $this->is_marketing_plugin( $plugin_data ) && ! $this->is_marketing_channel_plugin( $plugin_data ); + } + ); + } + + /** + * Returns whether a plugin is a marketing extension. + * + * @param array $plugin_data The plugin properties returned by the API. + * + * @return bool + */ + protected function is_marketing_plugin( array $plugin_data ): bool { + $categories = $plugin_data['categories'] ?? []; + + return in_array( self::MARKETING_EXTENSION_CATEGORY_SLUG, $categories, true ); + } + + /** + * Returns whether a plugin is a marketing channel. + * + * @param array $plugin_data The plugin properties returned by the API. + * + * @return bool + */ + protected function is_marketing_channel_plugin( array $plugin_data ): bool { + if ( ! $this->is_marketing_plugin( $plugin_data ) ) { + return false; + } + + $subcategories = $plugin_data['subcategories'] ?? []; + foreach ( $subcategories as $subcategory ) { + if ( isset( $subcategory['slug'] ) && self::MARKETING_CHANNEL_SUBCATEGORY_SLUG === $subcategory['slug'] ) { + return true; + } + } + + return false; + } + + /** + * Load knowledge base posts from WooCommerce.com + * + * @param string|null $category Category of posts to retrieve. + * @return array + */ + public function get_knowledge_base_posts( ?string $category ): array { + $kb_transient = self::KNOWLEDGE_BASE_TRANSIENT; + + $categories = array( + 'marketing' => 1744, + 'coupons' => 25202, + ); + + // Default to marketing category (if no category set on the kb component). + if ( ! empty( $category ) && array_key_exists( $category, $categories ) ) { + $category_id = $categories[ $category ]; + $kb_transient = $kb_transient . '_' . strtolower( $category ); + } else { + $category_id = $categories['marketing']; + } + + $posts = get_transient( $kb_transient ); + + if ( false === $posts ) { + $request_url = add_query_arg( + array( + 'categories' => $category_id, + 'page' => 1, + 'per_page' => 8, + '_embed' => 1, + ), + 'https://woocommerce.com/wp-json/wp/v2/posts?utm_medium=product' + ); + + $request = wp_remote_get( + $request_url, + array( + 'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ), + ) + ); + $posts = []; + + if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) { + $raw_posts = json_decode( $request['body'], true ); + + foreach ( $raw_posts as $raw_post ) { + $post = [ + 'title' => html_entity_decode( $raw_post['title']['rendered'] ), + 'date' => $raw_post['date_gmt'], + 'link' => $raw_post['link'], + 'author_name' => isset( $raw_post['author_name'] ) ? html_entity_decode( $raw_post['author_name'] ) : '', + 'author_avatar' => isset( $raw_post['author_avatar_url'] ) ? $raw_post['author_avatar_url'] : '', + ]; + + $featured_media = $raw_post['_embedded']['wp:featuredmedia'] ?? []; + if ( count( $featured_media ) > 0 ) { + $image = current( $featured_media ); + $post['image'] = add_query_arg( + array( + 'resize' => '650,340', + 'crop' => 1, + ), + $image['source_url'] + ); + } + + $posts[] = $post; + } + } + + set_transient( + $kb_transient, + $posts, + // Expire transient in 15 minutes if remote get failed. + empty( $posts ) ? 900 : DAY_IN_SECONDS + ); + } + + return $posts; + } +} diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/MarketingServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/MarketingServiceProvider.php new file mode 100644 index 00000000000..c00e484fe89 --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/MarketingServiceProvider.php @@ -0,0 +1,41 @@ +share( MarketingSpecs::class ); + $this->share( MarketingChannels::class ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignTypesTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignTypesTest.php new file mode 100644 index 00000000000..f38b49e1bb5 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignTypesTest.php @@ -0,0 +1,101 @@ +user = $this->factory->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->user ); + + $this->marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); + } + + /** + * Test teardown. + */ + public function tearDown(): void { + $this->marketing_channels_service->unregister_all(); + parent::tearDown(); + } + + /** + * Tests that the marketing campaigns for all registered channels are aggregated and returned by the endpoint. + */ + public function test_returns_aggregated_marketing_campaigns() { + // Create a mock marketing channel. + $test_channel_1 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + // Create a mock marketing campaign type. + $test_campaign_type_1 = $this->createMock( MarketingCampaignType::class ); + $test_campaign_type_1->expects( $this->any() )->method( 'get_id' )->willReturn( 'test-campaign-type-1' ); + $test_campaign_type_1->expects( $this->any() )->method( 'get_channel' )->willReturn( $test_channel_1 ); + // Return the sample campaign type by the mock marketing channel. + $test_channel_1->expects( $this->any() )->method( 'get_supported_campaign_types' )->willReturn( [ $test_campaign_type_1 ] ); + // Register the marketing channel. + $this->marketing_channels_service->register( $test_channel_1 ); + + // Create a second mock marketing channel. + $test_channel_2 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_2->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-2' ); + // Create a mock marketing campaign type for the second marketing channel. + $test_campaign_type_2 = $this->createMock( MarketingCampaignType::class ); + $test_campaign_type_2->expects( $this->any() )->method( 'get_id' )->willReturn( 'test-campaign-type-2' ); + $test_campaign_type_2->expects( $this->any() )->method( 'get_channel' )->willReturn( $test_channel_2 ); + // Return the sample campaign by the second mock marketing channel. + $test_channel_2->expects( $this->any() )->method( 'get_supported_campaign_types' )->willReturn( [ $test_campaign_type_2 ] ); + // Register the second marketing channel. + $this->marketing_channels_service->register( $test_channel_2 ); + + $request = new WP_REST_Request( 'GET', self::ENDPOINT ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 2, $data ); + $this->assertEquals( + [ + 'test-campaign-type-1', + 'test-campaign-type-2', + ], + array_column( $data, 'id' ) + ); + } + +} diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignsTest.php new file mode 100644 index 00000000000..341799b6127 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Admin/API/MarketingCampaignsTest.php @@ -0,0 +1,155 @@ +user = $this->factory->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->user ); + + $this->marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); + } + + /** + * Test teardown. + */ + public function tearDown(): void { + $this->marketing_channels_service->unregister_all(); + parent::tearDown(); + } + + /** + * Tests that the marketing campaigns for all registered channels are aggregated and returned by the endpoint. + */ + public function test_returns_aggregated_marketing_campaigns() { + // Create a mock marketing channel. + $test_channel_1 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + // Create a mock marketing campaign type. + $test_campaign_type_1 = $this->createMock( MarketingCampaignType::class ); + $test_campaign_type_1->expects( $this->any() )->method( 'get_channel' )->willReturn( $test_channel_1 ); + // Create a mock marketing campaign. + $test_campaign_1 = $this->createMock( MarketingCampaign::class ); + $test_campaign_1->expects( $this->any() )->method( 'get_id' )->willReturn( 'test-campaign-1' ); + $test_campaign_1->expects( $this->any() )->method( 'get_type' )->willReturn( $test_campaign_type_1 ); + // Return the sample campaign by the mock marketing channel. + $test_channel_1->expects( $this->any() )->method( 'get_campaigns' )->willReturn( [ $test_campaign_1 ] ); + // Register the marketing channel. + $this->marketing_channels_service->register( $test_channel_1 ); + + // Create a second mock marketing channel. + $test_channel_2 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_2->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-2' ); + // Create a mock marketing campaign type for the second marketing channel. + $test_campaign_type_2 = $this->createMock( MarketingCampaignType::class ); + $test_campaign_type_2->expects( $this->any() )->method( 'get_channel' )->willReturn( $test_channel_2 ); + // Create a mock marketing campaign for the second marketing channel. + $test_campaign_2 = $this->createMock( MarketingCampaign::class ); + $test_campaign_2->expects( $this->any() )->method( 'get_id' )->willReturn( 'test-campaign-2' ); + $test_campaign_2->expects( $this->any() )->method( 'get_type' )->willReturn( $test_campaign_type_2 ); + // Return the sample campaign by the second mock marketing channel. + $test_channel_2->expects( $this->any() )->method( 'get_campaigns' )->willReturn( [ $test_campaign_2 ] ); + // Register the second marketing channel. + $this->marketing_channels_service->register( $test_channel_2 ); + + $request = new WP_REST_Request( 'GET', self::ENDPOINT ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 2, $data ); + $this->assertEquals( + [ + 'test-campaign-1', + 'test-campaign-2', + ], + array_column( $data, 'id' ) + ); + $this->assertEquals( + [ + 'test-channel-1', + 'test-channel-2', + ], + array_column( $data, 'channel' ) + ); + } + + /** + * Tests that the marketing campaigns are paginated and then returned by the endpoint. + */ + public function test_paginates_marketing_campaigns() { + // Create a mock marketing channel. + $test_channel_1 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + // Return mock campaigns by the mock marketing channel. + $test_channel_1->expects( $this->any() )->method( 'get_campaigns' )->willReturn( + [ + $this->createMock( MarketingCampaign::class ), + $this->createMock( MarketingCampaign::class ), + $this->createMock( MarketingCampaign::class ), + $this->createMock( MarketingCampaign::class ), + $this->createMock( MarketingCampaign::class ), + ] + ); + // Register the marketing channel. + $this->marketing_channels_service->register( $test_channel_1 ); + + $endpoint = self::ENDPOINT; + $request = new WP_REST_Request( 'GET', $endpoint ); + $request->set_query_params( + [ + 'page' => '1', + 'per_page' => '2', + ] + ); + $response = $this->server->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertCount( 2, $response->get_data() ); + + $this->assertArrayHasKey( 'Link', $headers ); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + $this->assertEquals( 5, $headers['X-WP-Total'] ); + $this->assertEquals( 3, $headers['X-WP-TotalPages'] ); + } + +} diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MarketingChannelsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MarketingChannelsTest.php new file mode 100644 index 00000000000..9159b977ba4 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Admin/API/MarketingChannelsTest.php @@ -0,0 +1,77 @@ +user = $this->factory->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->user ); + + $this->marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); + } + + /** + * Test teardown. + */ + public function tearDown(): void { + $this->marketing_channels_service->unregister_all(); + parent::tearDown(); + } + + /** + * Tests that the registered marketing channels are returned by the endpoint. + */ + public function test_returns_registered_marketing_channels() { + // Register marketing channel. + $test_channel_1 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + $test_channel_1->expects( $this->any() )->method( 'get_name' )->willReturn( 'Test Channel One' ); + $this->marketing_channels_service->register( $test_channel_1 ); + + $request = new WP_REST_Request( 'GET', self::ENDPOINT ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( 'test-channel-1', $data[0]['slug'] ); + $this->assertEquals( 'Test Channel One', $data[0]['name'] ); + } + +} diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MarketingRecommendationsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MarketingRecommendationsTest.php new file mode 100644 index 00000000000..46335da52e3 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Admin/API/MarketingRecommendationsTest.php @@ -0,0 +1,138 @@ +user = $this->factory->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->user ); + + set_transient( + MarketingSpecs::RECOMMENDED_PLUGINS_TRANSIENT, + [ + [ + 'title' => 'Example Marketing Channel', + 'description' => 'List your products and create ads, etc.', + 'url' => 'https://woocommerce.com/products/example-channel', + 'direct_install' => true, + 'icon' => 'https://woocommerce.com/example.svg', + 'product' => 'example-channel', + 'plugin' => 'example-channel/example-channel.php', + 'categories' => [ MarketingSpecs::MARKETING_EXTENSION_CATEGORY_SLUG ], + 'subcategories' => [ + [ + 'slug' => MarketingSpecs::MARKETING_CHANNEL_SUBCATEGORY_SLUG, + 'name' => 'Sales channels', + ], + ], + 'tags' => [], + ], + [ + 'title' => 'Example Marketing Extension', + 'description' => 'Automate your customer communications, etc.', + 'url' => 'https://woocommerce.com/products/example-marketing-extension', + 'direct_install' => true, + 'icon' => 'https://woocommerce.com/example-marketing-extension.svg', + 'product' => 'example-marketing-extension', + 'plugin' => 'example-marketing-extension/example-marketing-extension.php', + 'categories' => [ MarketingSpecs::MARKETING_EXTENSION_CATEGORY_SLUG ], + 'subcategories' => [ + [ + 'slug' => 'email', + 'name' => 'Email', + ], + ], + 'tags' => [], + ], + [ + 'title' => 'Example NON Marketing Extension', + 'description' => 'Handle coupons, etc.', + 'url' => 'https://woocommerce.com/products/example-random-extension', + 'direct_install' => true, + 'icon' => 'https://woocommerce.com/example-random-extension.svg', + 'product' => 'example-random-extension', + 'plugin' => 'example-random-extension/example-random-extension.php', + 'categories' => [ 'coupons' ], + 'subcategories' => [], + 'tags' => [], + ], + ] + ); + } + + /** + * Tests that the marketing channel recommendations are returned by the endpoint. + */ + public function test_returns_recommended_marketing_channels() { + $endpoint = self::ENDPOINT; + $request = new WP_REST_Request( 'GET', $endpoint ); + $request->set_query_params( [ 'category' => 'channels' ] ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( 'Example Marketing Channel', $data[0]['title'] ); + $this->assertEquals( 'example-channel', $data[0]['product'] ); + } + + /** + * Tests that the marketing extension recommendations are returned by the endpoint. + */ + public function test_returns_recommended_marketing_extensions() { + $endpoint = self::ENDPOINT; + $request = new WP_REST_Request( 'GET', $endpoint ); + $request->set_query_params( [ 'category' => 'extensions' ] ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( 'Example Marketing Extension', $data[0]['title'] ); + $this->assertEquals( 'example-marketing-extension', $data[0]['product'] ); + } + + /** + * Tests that the endpoint returns an error if the provided category is invalid. + */ + public function test_returns_error_if_invalid_category_provided() { + $endpoint = self::ENDPOINT; + $request = new WP_REST_Request( 'GET', $endpoint ); + $request->set_query_params( [ 'category' => 'test-non-existing-invalid-category' ] ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'rest_invalid_param', $data['code'] ); + } + +} diff --git a/plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingCampaignTest.php b/plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingCampaignTest.php new file mode 100644 index 00000000000..4277cff9437 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingCampaignTest.php @@ -0,0 +1,42 @@ +createMock( MarketingCampaignType::class ); + + $marketing_campaign = new MarketingCampaign( '1234', $test_campaign_type_1, 'Ad #1234', 'https://example.com/manage-campaigns', new Price( '1000', 'USD' ) ); + + $this->assertEquals( '1234', $marketing_campaign->get_id() ); + $this->assertEquals( $test_campaign_type_1, $marketing_campaign->get_type() ); + $this->assertEquals( 'Ad #1234', $marketing_campaign->get_title() ); + $this->assertEquals( 'https://example.com/manage-campaigns', $marketing_campaign->get_manage_url() ); + $this->assertNotNull( $marketing_campaign->get_cost() ); + $this->assertEquals( 'USD', $marketing_campaign->get_cost()->get_currency() ); + $this->assertEquals( '1000', $marketing_campaign->get_cost()->get_value() ); + } + + /** + * @testdox `cost` property can be null. + */ + public function test_cost_can_be_null() { + $test_campaign_type_1 = $this->createMock( MarketingCampaignType::class ); + + $marketing_campaign = new MarketingCampaign( '1234', $test_campaign_type_1, 'Ad #1234', 'https://example.com/manage-campaigns' ); + + $this->assertNull( $marketing_campaign->get_cost() ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingChannelsTest.php b/plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingChannelsTest.php new file mode 100644 index 00000000000..fdc112ae370 --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Admin/Marketing/MarketingChannelsTest.php @@ -0,0 +1,103 @@ +createMock( MarketingChannelInterface::class ); + $test_channel->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + + $marketing_channels = new MarketingChannels(); + $marketing_channels->register( $test_channel ); + + $this->assertNotEmpty( $marketing_channels->get_registered_channels() ); + $this->assertEquals( $test_channel, $marketing_channels->get_registered_channels()[0] ); + } + + /** + * @testdox A marketing channel can NOT be registered using the `register` method if it is previously registered. + */ + public function test_throws_exception_if_registering_existing_channels() { + $test_channel_1 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + + $test_channel_1_duplicate = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1_duplicate->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + + $marketing_channels = new MarketingChannels(); + $marketing_channels->register( $test_channel_1 ); + + $this->expectException( \Exception::class ); + $marketing_channels->register( $test_channel_1_duplicate ); + + $this->assertCount( 1, $marketing_channels->get_registered_channels() ); + $this->assertEquals( $test_channel_1, $marketing_channels->get_registered_channels()[0] ); + } + + /** + * @testdox A marketing channel can be registered using the `woocommerce_marketing_channels` WordPress filter if the same channel slug is NOT previously registered. + */ + public function test_registers_channel_using_wp_filter() { + $test_channel = $this->createMock( MarketingChannelInterface::class ); + $test_channel->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + + $marketing_channels = new MarketingChannels(); + + add_filter( + 'woocommerce_marketing_channels', + function ( array $channels ) use ( $test_channel ) { + $channels[ $test_channel->get_slug() ] = $test_channel; + + return $channels; + } + ); + + $this->assertNotEmpty( $marketing_channels->get_registered_channels() ); + $this->assertEquals( $test_channel, $marketing_channels->get_registered_channels()[0] ); + } + + /** + * @testdox A marketing channel can NOT be registered using the `woocommerce_marketing_channels` WordPress filter if it is previously registered. + */ + public function test_overrides_existing_channel_if_registered_using_wp_filter() { + $marketing_channels = new MarketingChannels(); + + $test_channel_1 = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + + $marketing_channels->register( $test_channel_1 ); + + $test_channel_1_duplicate = $this->createMock( MarketingChannelInterface::class ); + $test_channel_1_duplicate->expects( $this->any() )->method( 'get_slug' )->willReturn( 'test-channel-1' ); + + add_filter( + 'woocommerce_marketing_channels', + function ( array $channels ) use ( $test_channel_1_duplicate ) { + $channels[ $test_channel_1_duplicate->get_slug() ] = $test_channel_1_duplicate; + + return $channels; + } + ); + + $this->assertCount( 1, $marketing_channels->get_registered_channels() ); + $this->assertEquals( $test_channel_1_duplicate, $marketing_channels->get_registered_channels()[0] ); + } +} From 7d8f7ad57c99fe1653b474bcc944eaf1b7e830b4 Mon Sep 17 00:00:00 2001 From: RJ <27843274+rjchow@users.noreply.github.com> Date: Fri, 20 Jan 2023 22:01:20 +0800 Subject: [PATCH 58/58] add: slot for header banner slotfill (#36467) --- .../header-banner-slot/header-banner-slot.tsx | 36 ++++++++++++ .../homescreen/header-banner-slot/index.ts | 2 + .../homescreen/header-banner-slot/utils.tsx | 58 +++++++++++++++++++ .../client/homescreen/layout.js | 12 +++- .../add-wcadmin-home-header-banner-slotfill | 4 ++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx create mode 100644 plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts create mode 100644 plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx create mode 100644 plugins/woocommerce/changelog/add-wcadmin-home-header-banner-slotfill diff --git a/plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx new file mode 100644 index 00000000000..1504f14878d --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { useSlot } from '@woocommerce/experimental'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import { + EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME, + WooHomescreenHeaderBannerItem, +} from './utils'; + +export const WooHomescreenHeaderBanner = ( { + className, +}: { + className: string; +} ) => { + const slot = useSlot( EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME ); + const hasFills = Boolean( slot?.fills?.length ); + + if ( ! hasFills ) { + return null; + } + return ( +
    + +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts new file mode 100644 index 00000000000..652d2ba1b61 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts @@ -0,0 +1,2 @@ +export * from './header-banner-slot'; +export * from './utils'; diff --git a/plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx new file mode 100644 index 00000000000..1bae78f7456 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { createOrderedChildren, sortFillsByOrder } from '../../utils'; + +export const EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME = + 'woocommerce_homescreen_experimental_header_banner_item'; +/** + * Create a Fill for extensions to add items to the WooCommerce Admin Homescreen header banner. + * + * @slotFill WooHomescreenHeaderBannerItem + * @scope woocommerce-admin + * @example + * const MyHeaderItem = () => ( + * My header item + * ); + * + * registerPlugin( 'my-extension', { + * render: MyHeaderItem, + * scope: 'woocommerce-admin', + * } ); + * @param {Object} param0 + * @param {Array} param0.children - Node children. + * @param {Array} param0.order - Node order. + */ +export const WooHomescreenHeaderBannerItem = ( { + children, + order = 1, +}: { + children: React.ReactNode; + order?: number; +} ) => { + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooHomescreenHeaderBannerItem.Slot = ( { + fillProps, +}: { + fillProps?: Slot.Props; +} ) => ( + + { sortFillsByOrder } + +); diff --git a/plugins/woocommerce-admin/client/homescreen/layout.js b/plugins/woocommerce-admin/client/homescreen/layout.js index c33b10bd92d..6fc310ebdcd 100644 --- a/plugins/woocommerce-admin/client/homescreen/layout.js +++ b/plugins/woocommerce-admin/client/homescreen/layout.js @@ -43,6 +43,7 @@ import './style.scss'; import '../dashboard/style.scss'; import { getAdminSetting } from '~/utils/admin-settings'; import { ProgressTitle } from '../task-lists'; +import { WooHomescreenHeaderBanner } from './header-banner-slot'; const Tasks = lazy( () => import( /* webpackChunkName: "tasks" */ '../tasks' ).then( ( module ) => ( { @@ -126,7 +127,9 @@ export const Layout = ( { return ( }> { activeSetupTaskList && isDashboardShown && ( - + <> + + ) } @@ -135,6 +138,13 @@ export const Layout = ( { return ( <> + { isDashboardShown && ( + + ) }