Merge branch 'trunk' into fix/wp-l2-retrieval

This commit is contained in:
rodelgc 2023-04-13 18:51:53 +08:00
commit 08a6eb6c17
31 changed files with 797 additions and 595 deletions

View File

@ -29,32 +29,19 @@ jobs:
outputs:
freeze: ${{ steps.check-freeze.outputs.freeze }}
steps:
- name: 'Install PHP'
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
- name: Checkout code
uses: actions/checkout@v3
with:
php-version: '7.4'
fetch-depth: 0
- name: Install prerequisites
run: |
npm install -g pnpm@7
pnpm install --filter monorepo-utils
- name: 'Check whether today is the code freeze day'
id: check-freeze
shell: php {0}
run: |
<?php
$now = time();
if ( getenv( 'TIME_OVERRIDE' ) ) {
$now = strtotime( getenv( 'TIME_OVERRIDE' ) );
}
// Code freeze comes 22 days prior to release day.
$release_time = strtotime( '+22 days', $now );
$release_day_of_week = date( 'l', $release_time );
$release_day_of_month = (int) date( 'j', $release_time );
// If 22 days from now isn't the second Tuesday, then it's not code freeze day.
if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=1\n", FILE_APPEND );
} else {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=0\n", FILE_APPEND );
}
run: pnpm utils code-freeze verify-day -g -o $TIME_OVERRIDE
maybe-create-next-milestone-and-release-branch:
name: 'Maybe create next milestone and release branch'
@ -63,12 +50,16 @@ jobs:
contents: write
issues: write
needs: verify-code-freeze
if: needs.verify-code-freeze.outputs.freeze == 0
if: needs.verify-code-freeze.outputs.freeze == 'true'
outputs:
branch: ${{ steps.freeze.outputs.branch }}
release_version: ${{ steps.freeze.outputs.release_version }}
next_version: ${{ steps.freeze.outputs.next_version }}
steps:
- name: 'Install PHP'
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: '7.4'
- name: Checkout code
uses: actions/checkout@v3
with:

View File

@ -11,7 +11,7 @@ To get up and running within the WooCommerce Monorepo, you will need to make sur
### Prerequisites
* [NVM](https://github.com/nvm-sh/nvm#installing-and-updating): While you can always install Node through other means, we recommend using NVM to ensure you're aligned with the version used by our development teams. Our repository contains [an `.nvmrc` file](.nvmrc) which helps ensure you are using the correct version of Node.
* [PNPM](https://pnpm.io/installation): Our repository utilizes PNPM to manage project dependencies and run various scripts involved in building and testing projects.
* [PNPM](https://pnpm.io/installation): Our repository utilizes PNPM to manage project dependencies and run various scripts involved in building and testing projects. Until the repository tooling has been updated to support PNPM 8, install PNPM 7 like so: `npm install pnpm@7 --global`
* [PHP 7.2+](https://www.php.net/manual/en/install.php): WooCommerce Core currently features a minimum PHP version of 7.2. It is also needed to run Composer and various project build scripts.
* [Composer](https://getcomposer.org/doc/00-intro.md): We use Composer to manage all of the dependencies for PHP packages and plugins.

View File

@ -18,7 +18,7 @@
"url": "https://github.com/woocommerce/woocommerce/issues"
},
"bin": {
"utils": "./tools/monorepo-utils/dist/index.js"
"utils": "./tools/monorepo-utils/bin/run"
},
"scripts": {
"build": "pnpm exec turbo run turbo:build",
@ -30,7 +30,7 @@
"create-extension": "node ./tools/create-extension/index.js",
"cherry-pick": "node ./tools/cherry-pick/bin/run",
"sync-dependencies": "pnpm exec syncpack -- fix-mismatches",
"utils": "./tools/monorepo-utils/dist/index.js"
"utils": "./tools/monorepo-utils/bin/run"
},
"devDependencies": {
"@babel/preset-env": "^7.20.2",

View File

@ -7,6 +7,11 @@ import { useSelect } from '@wordpress/data';
import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import sanitizeHTML from '../../lib/sanitize-html';
export type DefaultProgressTitleProps = {
taskListId: string;
};
@ -64,6 +69,9 @@ export const DefaultProgressTitle: React.FC< DefaultProgressTitleProps > = ( {
}
return (
<h1 className="woocommerce-task-progress-header__title">{ title }</h1>
<h1
className="woocommerce-task-progress-header__title"
dangerouslySetInnerHTML={ sanitizeHTML( title ) }
/>
);
};

View File

@ -7,7 +7,10 @@ import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { waitUntilElementIsPresent } from './utils';
import {
attachEventListenerToParentForChildren,
waitUntilElementIsPresent,
} from './utils';
/**
* Get the product data.
@ -244,9 +247,9 @@ const getDataForProductTabClickEvent = ( tabName: string ) => {
};
/**
* Initializes the product tabs Tracks events.
* Attaches the product tabs Tracks events.
*/
const initProductTabsTracks = () => {
const attachProductTabsTracks = () => {
const tabs = document.querySelectorAll( '.product_data_tabs > li' );
tabs.forEach( ( tab ) => {
@ -262,9 +265,9 @@ const initProductTabsTracks = () => {
};
/**
* Initializes the inventory tab Tracks events.
* Attaches the inventory tab Tracks events.
*/
const initInventoryTabTracks = () => {
const attachProductInventoryTabTracks = () => {
document
.querySelector( '#_manage_stock' )
?.addEventListener( 'click', ( event ) => {
@ -274,7 +277,7 @@ const initInventoryTabTracks = () => {
} );
document
.querySelector( '#_manage_stock_disabled' )
.querySelector( '#_manage_stock_disabled > a' )
?.addEventListener( 'click', () => {
recordEvent(
'product_manage_stock_disabled_store_settings_link_click'
@ -291,10 +294,225 @@ const initInventoryTabTracks = () => {
};
/**
* Initialize all product screen tracks.
* Attaches product tags tracks.
*/
const attachProductTagsTracks = () => {
function deleteTagEventListener(/* event: Event */) {
recordEvent( 'product_tags_delete', {
page: 'product',
tag_list_size:
document.querySelector( '.tagchecklist' )?.children.length || 0,
} );
}
export const initProductScreenTracks = () => {
function addTagsDeleteTracks() {
const tagsDeleteButtons = document.querySelectorAll(
'#product_tag .ntdelbutton'
);
tagsDeleteButtons.forEach( ( button ) => {
button.removeEventListener( 'click', deleteTagEventListener );
button.addEventListener( 'click', deleteTagEventListener );
} );
}
waitUntilElementIsPresent(
'#product_tag .tagchecklist',
addTagsDeleteTracks
);
document
.querySelector( '.tagadd' )
?.addEventListener( 'click', (/* event: Event */) => {
const tagInput = document.querySelector< HTMLInputElement >(
'#new-tag-product_tag'
);
if ( tagInput && tagInput.value && tagInput.value.length > 0 ) {
recordEvent( 'product_tags_add', {
page: 'product',
tag_string_length: tagInput.value.length,
tag_list_size:
( document.querySelector( '.tagchecklist' )?.children
.length || 0 ) + 1,
most_used: false,
} );
setTimeout( () => {
addTagsDeleteTracks();
}, 500 );
}
} );
function addMostUsedTagEventListener( event: Event ) {
recordEvent( 'product_tags_add', {
page: 'product',
tag_string_length: ( event.target as HTMLAnchorElement ).textContent
?.length,
tag_list_size:
document.querySelector( '.tagchecklist' )?.children.length || 0,
most_used: true,
} );
addTagsDeleteTracks();
}
function addMostUsedTagsTracks() {
const tagCloudLinks = document.querySelectorAll(
'#tagcloud-product_tag .tag-cloud-link'
);
tagCloudLinks.forEach( ( button ) => {
button.removeEventListener( 'click', addMostUsedTagEventListener );
button.addEventListener( 'click', addMostUsedTagEventListener );
} );
}
document
.querySelector( '.tagcloud-link' )
?.addEventListener( 'click', () => {
waitUntilElementIsPresent(
'#tagcloud-product_tag',
addMostUsedTagsTracks
);
} );
};
/**
* Attaches attributes tracks.
*/
const attachAttributesTracks = () => {
function addNewTermEventHandler() {
recordEvent( 'product_attributes_add_term', {
page: 'product',
} );
}
function addNewAttributeTermTracks() {
const addNewTermButtons = document.querySelectorAll(
'.woocommerce_attribute .add_new_attribute'
);
addNewTermButtons.forEach( ( button ) => {
button.removeEventListener( 'click', addNewTermEventHandler );
button.addEventListener( 'click', addNewTermEventHandler );
} );
}
addNewAttributeTermTracks();
document
.querySelector( '.add_attribute' )
?.addEventListener( 'click', () => {
setTimeout( () => {
addNewAttributeTermTracks();
}, 1000 );
} );
};
/**
* Attaches product attributes tracks.
*/
const attachProductAttributesTracks = () => {
const attributesCount = document.querySelectorAll(
'.woocommerce_attribute'
).length;
document
.querySelector( '.save_attributes' )
?.addEventListener( 'click', ( event ) => {
if (
event.target instanceof Element &&
event.target.classList.contains( 'disabled' )
) {
// skip in case the button is disabled
return;
}
const newAttributesCount = document.querySelectorAll(
'.woocommerce_attribute'
).length;
if ( newAttributesCount > attributesCount ) {
const local_attributes = [
...document.querySelectorAll(
'.woocommerce_attribute:not(.pa_glbattr)'
),
].map( ( attr ) => {
const terms =
(
attr.querySelector(
"[name^='attribute_values']"
) as HTMLTextAreaElement
)?.value.split( '|' ).length ?? 0;
return {
name: (
attr.querySelector(
'[name^="attribute_names"]'
) as HTMLInputElement
)?.value,
terms,
};
} );
recordEvent( 'product_attributes_add', {
page: 'product',
enable_archive: '',
default_sort_order: '',
local_attributes,
} );
}
} );
};
/**
* Attaches product variations tracks.
*/
const attachProductVariationsTracks = () => {
document
.querySelector(
'#variable_product_options_inner .variations-add-attributes-link'
)
?.addEventListener( 'click', () => {
recordEvent( 'product_variations_empty_state', {
action: 'add_attribute_link',
} );
} );
document
.querySelector(
'#variable_product_options_inner .variations-learn-more-link'
)
?.addEventListener( 'click', () => {
recordEvent( 'product_variations_empty_state', {
action: 'learn_more_link',
} );
} );
const variationsSection = '#variable_product_options';
// We attach the events in this way because the buttons are added dynamically.
attachEventListenerToParentForChildren( variationsSection, [
{
eventName: 'click',
childQuery: '.add_variation_manually',
callback: () => {
recordEvent( 'product_variations_buttons', {
action: 'add_variation_manually',
} );
},
},
{
eventName: 'change',
childQuery: '#field_to_edit',
callback: () => {
const selectElement = document.querySelector(
'#field_to_edit'
) as HTMLSelectElement;
// Get the index of the selected option
const selectedIndex = selectElement.selectedIndex;
recordEvent( 'product_variations_buttons', {
action: 'bulk_actions',
selected: selectElement.options[ selectedIndex ]?.value,
} );
},
},
] );
};
/**
* Attaches general product screen tracks.
*/
const attachProductScreenTracks = () => {
const initialPublishingData = getPublishingWidgetData();
document
@ -395,156 +613,6 @@ export const initProductScreenTracks = () => {
} );
} );
// Product tags
function deleteTagEventListener(/* event: Event */) {
recordEvent( 'product_tags_delete', {
page: 'product',
tag_list_size:
document.querySelector( '.tagchecklist' )?.children.length || 0,
} );
}
function addTagsDeleteTracks() {
const tagsDeleteButtons = document.querySelectorAll(
'#product_tag .ntdelbutton'
);
tagsDeleteButtons.forEach( ( button ) => {
button.removeEventListener( 'click', deleteTagEventListener );
button.addEventListener( 'click', deleteTagEventListener );
} );
}
waitUntilElementIsPresent(
'#product_tag .tagchecklist',
addTagsDeleteTracks
);
document
.querySelector( '.tagadd' )
?.addEventListener( 'click', (/* event: Event */) => {
const tagInput = document.querySelector< HTMLInputElement >(
'#new-tag-product_tag'
);
if ( tagInput && tagInput.value && tagInput.value.length > 0 ) {
recordEvent( 'product_tags_add', {
page: 'product',
tag_string_length: tagInput.value.length,
tag_list_size:
( document.querySelector( '.tagchecklist' )?.children
.length || 0 ) + 1,
most_used: false,
} );
setTimeout( () => {
addTagsDeleteTracks();
}, 500 );
}
} );
function addMostUsedTagEventListener( event: Event ) {
recordEvent( 'product_tags_add', {
page: 'product',
tag_string_length: ( event.target as HTMLAnchorElement ).textContent
?.length,
tag_list_size:
document.querySelector( '.tagchecklist' )?.children.length || 0,
most_used: true,
} );
addTagsDeleteTracks();
}
function addMostUsedTagsTracks() {
const tagCloudLinks = document.querySelectorAll(
'#tagcloud-product_tag .tag-cloud-link'
);
tagCloudLinks.forEach( ( button ) => {
button.removeEventListener( 'click', addMostUsedTagEventListener );
button.addEventListener( 'click', addMostUsedTagEventListener );
} );
}
document
.querySelector( '.tagcloud-link' )
?.addEventListener( 'click', () => {
waitUntilElementIsPresent(
'#tagcloud-product_tag',
addMostUsedTagsTracks
);
} );
// Attribute tracks.
function addNewTermEventHandler() {
recordEvent( 'product_attributes_add_term', {
page: 'product',
} );
}
function addNewAttributeTermTracks() {
const addNewTermButtons = document.querySelectorAll(
'.woocommerce_attribute .add_new_attribute'
);
addNewTermButtons.forEach( ( button ) => {
button.removeEventListener( 'click', addNewTermEventHandler );
button.addEventListener( 'click', addNewTermEventHandler );
} );
}
addNewAttributeTermTracks();
document
.querySelector( '.add_attribute' )
?.addEventListener( 'click', () => {
setTimeout( () => {
addNewAttributeTermTracks();
}, 1000 );
} );
const attributesCount = document.querySelectorAll(
'.woocommerce_attribute'
).length;
document
.querySelector( '.save_attributes' )
?.addEventListener( 'click', ( event ) => {
if (
event.target instanceof Element &&
event.target.classList.contains( 'disabled' )
) {
// skip in case the button is disabled
return;
}
const newAttributesCount = document.querySelectorAll(
'.woocommerce_attribute'
).length;
if ( newAttributesCount > attributesCount ) {
const local_attributes = [
...document.querySelectorAll(
'.woocommerce_attribute:not(.pa_glbattr)'
),
].map( ( attr ) => {
const terms =
(
attr.querySelector(
"[name^='attribute_values']"
) as HTMLTextAreaElement
)?.value.split( '|' ).length ?? 0;
return {
name: (
attr.querySelector(
'[name^="attribute_names"]'
) as HTMLInputElement
)?.value,
terms,
};
} );
recordEvent( 'product_attributes_add', {
page: 'product',
enable_archive: '',
default_sort_order: '',
local_attributes,
} );
}
} );
document
.querySelector(
'#woocommerce-product-updated-message-view-product__link'
@ -563,9 +631,19 @@ export const initProductScreenTracks = () => {
recordEvent( 'product_view_product_dismiss', getProductData() );
} );
} );
};
initProductTabsTracks();
initInventoryTabTracks();
/**
* Initialize all product screen tracks.
*/
export const initProductScreenTracks = () => {
attachAttributesTracks();
attachProductScreenTracks();
attachProductTagsTracks();
attachProductAttributesTracks();
attachProductVariationsTracks();
attachProductTabsTracks();
attachProductInventoryTabTracks();
};
export function addExitPageListener( pageId: string ) {

View File

@ -1,3 +1,39 @@
/**
* Attaches a click event listener to a parent element and calls a callback when one of the child elements,
* specified by the list of queries, is clicked. This allows handling events for child elements that may not
* exist in the DOM when the event listener is added.
*
* @param {string} parentQuery query of the parent element.
* @param {Array<Object>} children array of event, child query and callback pairs.
*/
export function attachEventListenerToParentForChildren(
parentQuery: string,
children: Array< {
eventName: 'click' | 'change';
childQuery: string;
callback: () => void;
} >
) {
const parent = document.querySelector( parentQuery );
if ( ! parent ) return;
const eventListener = ( event: Event ) => {
children.forEach( ( { eventName, childQuery, callback } ) => {
if (
event.type === eventName &&
( event.target as Element ).matches( childQuery )
) {
callback();
}
} );
};
children.forEach( ( { eventName } ) => {
parent.addEventListener( eventName, eventListener );
} );
}
/**
* Recursive function that waits up to 3 seconds until an element is found, then calls the callback.
*

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tracks events to variations tab

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Item controls for attribute creation are always visible

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Dev - Allow to filter wc_help_tip

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fixed the attributes table styling in TT2 tabs content area

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix special characters not rendered in admin titles

View File

@ -1,4 +0,0 @@
Significance: minor
Type: update
Support min_php_version and min_wp_version for the free extensions feed

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Reset variable product tour after running e2e tests.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: e2e test, not in release package

View File

@ -1130,6 +1130,7 @@ $default-line-height: 18px;
}
.toolbar-top {
.button,
.attribute_taxonomy,
.select2-container {
margin: 1px;
}
@ -5620,6 +5621,7 @@ img.help_tip {
a.delete,
a.edit {
float: right;
margin-right: 12px;
}
a.delete {
@ -5655,7 +5657,7 @@ img.help_tip {
.handlediv {
background-position: 6px 5px !important;
visibility: hidden;
margin: 4px 0 -1px !important;
height: 26px;
}
@ -5671,7 +5673,6 @@ img.help_tip {
a.delete,
a.edit,
.handlediv,
.sort {
margin-top: 0.25em;
}
@ -5684,14 +5685,6 @@ img.help_tip {
}
}
h3:hover,
&.ui-sortable-helper {
a.delete,
.handlediv {
visibility: visible;
}
}
table {
width: 100%;
position: relative;

View File

@ -594,9 +594,29 @@ ul.wc-tabs {
font-size: var(--wp--preset--font-size--small);
margin-left: 1em;
h2 {
// Hide repeated heading.
h2:first-of-type {
display: none;
}
// Attributes table styles.
table.woocommerce-product-attributes {
tbody {
td, th {
padding: 0.2rem 0.2rem 0.2rem 0;
p {
margin: 0;
}
}
th {
text-align: left;
padding-right: 1rem;
}
}
}
}
/**

View File

@ -1083,6 +1083,10 @@ jQuery( function ( $ ) {
} else {
wc_meta_boxes_product_variations_ajax.unblock();
}
window.wcTracks.recordEvent( 'product_variations_buttons', {
action: 'remove_variation',
} );
}
return false;
@ -1150,6 +1154,10 @@ jQuery( function ( $ ) {
}
}
);
window.wcTracks.recordEvent( 'product_variations_buttons', {
action: 'generate_variations',
} );
}
return false;

View File

@ -5,9 +5,9 @@ if ( ! defined( 'ABSPATH' ) ) {
?>
<div data-taxonomy="<?php echo esc_attr( $attribute->get_taxonomy() ); ?>" class="woocommerce_attribute wc-metabox postbox closed <?php echo esc_attr( implode( ' ', $metabox_class ) ); ?>" rel="<?php echo esc_attr( $attribute->get_position() ); ?>">
<h3>
<a href="#" class="remove_row delete"><?php esc_html_e( 'Remove', 'woocommerce' ); ?></a>
<div class="handlediv" title="<?php esc_attr_e( 'Click to toggle', 'woocommerce' ); ?>"></div>
<div class="tips sort" data-tip="<?php esc_attr_e( 'Drag and drop to set admin attribute order', 'woocommerce' ); ?>"></div>
<a href="#" class="remove_row delete"><?php esc_html_e( 'Remove', 'woocommerce' ); ?></a>
<strong class="attribute_name<?php echo esc_attr( $attribute->get_name() === '' ? ' placeholder' : '' ); ?>"><?php echo esc_html( $attribute->get_name() !== '' ? wc_attribute_label( $attribute->get_name() ) : __( 'Custom attribute', 'woocommerce' ) ); ?></strong>
</h3>
<div class="woocommerce_attribute_data wc-metabox-content hidden">

View File

@ -26,7 +26,7 @@ $arrow_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variati
echo wp_kses_post(
sprintf(
/* translators: %1$s: url for attributes tab, %2$s: url for variable product documentation */
__( 'Add some attributes in the <a href="%1$s">Attributes</a> tab to generate variations. Make sure to check the <b>Used for variations</b> box. <a href="%2$s" target="_blank" rel="noreferrer">Learn more</a>', 'woocommerce' ),
__( 'Add some attributes in the <a class="variations-add-attributes-link" href="%1$s">Attributes</a> tab to generate variations. Make sure to check the <b>Used for variations</b> box. <a class="variations-learn-more-link" href="%2$s" target="_blank" rel="noreferrer">Learn more</a>', 'woocommerce' ),
esc_url( '#product_attributes' ),
esc_url( 'https://woocommerce.com/document/variable-product/' )
)

View File

@ -1593,12 +1593,24 @@ function wc_back_link( $label, $url ) {
*/
function wc_help_tip( $tip, $allow_html = false ) {
if ( $allow_html ) {
$tip = wc_sanitize_tooltip( $tip );
$sanitized_tip = wc_sanitize_tooltip( $tip );
} else {
$tip = esc_attr( $tip );
$sanitized_tip = esc_attr( $tip );
}
return '<span class="woocommerce-help-tip" data-tip="' . $tip . '"></span>';
/**
* Filter the help tip.
*
* @since 7.7.0
*
* @param string $tip_html Help tip HTML.
* @param string $sanitized_tip Sanitized help tip text.
* @param string $tip Original help tip text.
* @param bool $allow_html Allow sanitized HTML if true or escape.
*
* @return string
*/
return apply_filters( 'wc_help_tip', '<span class="woocommerce-help-tip" data-tip="' . $sanitized_tip . '"></span>', $sanitized_tip, $tip, $allow_html );
}
/**

View File

@ -79,18 +79,17 @@ class DefaultFreeExtensions {
public static function get_plugin( $slug ) {
$plugins = array(
'google-listings-and-ads' => [
'min_php_version' => '7.4',
'name' => __( 'Google Listings & Ads', 'woocommerce' ),
'description' => sprintf(
'name' => __( 'Google Listings & Ads', 'woocommerce' ),
'description' => sprintf(
/* translators: 1: opening product link tag. 2: closing link tag */
__( 'Drive sales with %1$sGoogle Listings and Ads%2$s', 'woocommerce' ),
'<a href="https://woocommerce.com/products/google-listings-and-ads" target="_blank">',
'</a>'
),
'image_url' => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
'is_built_by_wc' => true,
'is_visible' => [
'image_url' => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
'is_built_by_wc' => true,
'is_visible' => [
[
'type' => 'not',
'operand' => [
@ -126,12 +125,11 @@ class DefaultFreeExtensions {
'is_built_by_wc' => false,
],
'pinterest-for-woocommerce' => [
'name' => __( 'Pinterest for WooCommerce', 'woocommerce' ),
'description' => __( 'Get your products in front of Pinners searching for ideas and things to buy.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
'is_built_by_wc' => true,
'min_php_version' => '7.3',
'name' => __( 'Pinterest for WooCommerce', 'woocommerce' ),
'description' => __( 'Get your products in front of Pinners searching for ideas and things to buy.', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
'manage_url' => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
'is_built_by_wc' => true,
],
'pinterest-for-woocommerce:alt' => [
'name' => __( 'Pinterest for WooCommerce', 'woocommerce' ),
@ -351,7 +349,6 @@ class DefaultFreeExtensions {
DefaultPaymentGateways::get_rules_for_cbd( false ),
],
'is_built_by_wc' => true,
'min_wp_version' => '5.9',
],
'woocommerce-services:shipping' => [
'description' => sprintf(
@ -519,7 +516,6 @@ class DefaultFreeExtensions {
],
],
'is_built_by_wc' => false,
'min_wp_version' => '6.0',
],
'mailpoet' => [
'name' => __( 'MailPoet', 'woocommerce' ),

View File

@ -21,7 +21,6 @@ class EvaluateExtension {
* @return object The evaluated extension.
*/
public static function evaluate( $extension ) {
global $wp_version;
$rule_evaluator = new RuleEvaluator();
if ( isset( $extension->is_visible ) ) {
@ -31,14 +30,6 @@ class EvaluateExtension {
$extension->is_visible = true;
}
if ( isset( $extension->min_php_version ) ) {
$extension->is_visible = version_compare( PHP_VERSION, $extension->min_php_version, '>=' );
}
if ( isset( $extension->min_wp_version ) ) {
$extension->is_visible = version_compare( $wp_version, $extension->min_wp_version, '>=' );
}
$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
$activated_plugins = PluginsHelper::get_active_plugin_slugs();
$extension->is_installed = in_array( explode( ':', $extension->key )[0], $installed_plugins, true );

View File

@ -1,6 +1,8 @@
const { test, expect } = require( '@playwright/test' );
const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default;
const productPageURL = 'wp-admin/post-new.php?post_type=product';
const variableProductName = 'Variable Product with Three Variations';
const manualVariableProduct = 'Manual Variable Product';
const variationOnePrice = '9.99';
@ -14,35 +16,68 @@ const defaultAttributes = [ 'val2', 'val1', 'val2' ];
const stockAmount = '100';
const lowStockAmount = '10';
async function deleteProductsAddedByTests( baseURL ) {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
const varProducts = await api
.get( 'products', { per_page: 100, search: variableProductName } )
.then( ( response ) => response.data );
const manualProducts = await api
.get( 'products', { per_page: 100, search: manualVariableProduct } )
.then( ( response ) => response.data );
const ids = varProducts
.map( ( { id } ) => id )
.concat( manualProducts.map( ( { id } ) => id ) );
await api.post( 'products/batch', { delete: ids } );
}
async function resetVariableProductTour( baseURL, browser ) {
// Go to the product page, so that the `window.wp.data` module is available
const page = await browser.newPage( { baseURL: baseURL } );
await page.goto( productPageURL );
// Get the current user's ID and user preferences
const { id: userId, woocommerce_meta } = await page.evaluate( () => {
return window.wp.data.select( 'core' ).getCurrentUser();
} );
// Reset the variable product tour preference, so that it will be shown again
const updatedWooCommerceMeta = {
...woocommerce_meta,
variable_product_tour_shown: '',
};
// Save the updated user preferences
await page.evaluate(
async ( { userId, updatedWooCommerceMeta } ) => {
await window.wp.data.dispatch( 'core' ).saveUser( {
id: userId,
woocommerce_meta: updatedWooCommerceMeta,
} );
},
{ userId, updatedWooCommerceMeta }
);
}
test.describe( 'Add New Variable Product Page', () => {
test.use( { storageState: process.env.ADMINSTATE } );
test.afterAll( async ( { baseURL } ) => {
const api = new wcApi( {
url: baseURL,
consumerKey: process.env.CONSUMER_KEY,
consumerSecret: process.env.CONSUMER_SECRET,
version: 'wc/v3',
} );
const varProducts = await api
.get( 'products', { per_page: 100, search: variableProductName } )
.then( ( response ) => response.data );
const manualProducts = await api
.get( 'products', { per_page: 100, search: manualVariableProduct } )
.then( ( response ) => response.data );
const ids = varProducts
.map( ( { id } ) => id )
.concat( manualProducts.map( ( { id } ) => id ) );
await api.post( 'products/batch', { delete: ids } );
test.afterAll( async ( { baseURL, browser } ) => {
await deleteProductsAddedByTests( baseURL );
await resetVariableProductTour( baseURL, browser );
} );
test( 'shows the variable product tour', async ( { page } ) => {
await page.goto( 'wp-admin/post-new.php?post_type=product' );
await page.selectOption( '#product-type', 'variable', { force: true } );
await page.selectOption( '#product-type', 'variable' );
// because of the way that the tour is dynamically positioned,
// Playwright can't automatically scroll the button into view,
@ -53,10 +88,12 @@ test.describe( 'Add New Variable Product Page', () => {
.getByRole( 'link', { name: 'Attributes' } )
.scrollIntoViewIfNeeded();
// the tour only seems to display when not running headless, so just make sure
if ( await page.locator( '.components-card-header' ).nth(1).isVisible() ) {
// dismiss the variable product tour
await page
.getByRole( 'button', { name: 'Got it' } )
.click( { force: true } );
.getByRole( 'button', { name: 'Close Tour' } )
.click();
// wait for the tour's dismissal to be saved
await page.waitForResponse(
@ -64,21 +101,25 @@ test.describe( 'Add New Variable Product Page', () => {
response.url().includes( '/users/' ) &&
response.status() === 200
);
}
} );
test( 'can create product, attributes and variations, edit variations and delete variations', async ( {
page,
} ) => {
await page.goto( 'wp-admin/post-new.php?post_type=product' );
await page.goto( productPageURL );
await page.fill( '#title', variableProductName );
await page.selectOption( '#product-type', 'variable', { force: true } );
await page.selectOption( '#product-type', 'variable' );
await page.click( 'a[href="#product_attributes"]' );
// add 3 attributes
for ( let i = 0; i < 3; i++ ) {
if ( i > 0 ) {
await page.click( 'button.add_attribute' );
await page.getByRole( 'button', { name: 'Add' } )
.nth(2)
.click();
}
await page.waitForSelector(
`input[name="attribute_names[${ i }]"]`
@ -93,11 +134,13 @@ test.describe( 'Add New Variable Product Page', () => {
.first()
.type( 'val1 | val2' );
}
await page.click( 'text=Save attributes' );
// wait for the attributes to be saved
await page.getByRole( 'button', { name: 'Save attributes'} ).click( { clickCount: 3 });
// wait for the tour's dismissal to be saved
await page.waitForResponse(
( response ) =>
response.url().includes( '/post.php?post=' ) &&
response.url().includes( '/post.php' ) &&
response.status() === 200
);
@ -107,14 +150,15 @@ test.describe( 'Add New Variable Product Page', () => {
page.getByText( 'Product draft updated. ' )
).toBeVisible();
page.on( 'dialog', ( dialog ) => dialog.accept() );
// manually create variations from all attributes
await page.click( 'a[href="#variable_product_options"]' );
// event listener for handling the link_all_variations confirmation dialog
page.on( 'dialog', ( dialog ) => dialog.accept() );
// generate variations from all attributes
await page.click( 'button.generate_variations' );
// add variation attributes
// verify variations have the correct attribute values
for ( let i = 0; i < 8; i++ ) {
const val1 = 'val1';
const val2 = 'val2';
@ -205,7 +249,7 @@ test.describe( 'Add New Variable Product Page', () => {
test( 'can manually add a variation, manage stock levels, set variation defaults and remove a variation', async ( {
page,
} ) => {
await page.goto( 'wp-admin/post-new.php?post_type=product' );
await page.goto( productPageURL );
await page.fill( '#title', manualVariableProduct );
await page.selectOption( '#product-type', 'variable', { force: true } );
await page.click( 'a[href="#product_attributes"]' );

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
dist/

5
tools/monorepo-utils/bin/run Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env node
const { program } = require( '../dist/index' );
program.parse( process.argv );

View File

@ -6,10 +6,12 @@
"homepage": "https://github.com/woocommerce/woocommerce",
"license": "GPLv2",
"repository": "woocommerce/woocommerce",
"main": "dist/index.js",
"dependencies": {
"@actions/core": "^1.10.0",
"@commander-js/extra-typings": "^0.1.0",
"chalk": "^4.1.2",
"commander": "^9.4.0",
"@commander-js/extra-typings": "^0.1.0",
"dotenv": "^10.0.0"
},
"devDependencies": {

View File

@ -2,11 +2,18 @@
* External dependencies
*/
import { Command } from '@commander-js/extra-typings';
import chalk from 'chalk';
import { setOutput } from '@actions/core';
/**
* Internal dependencies
*/
import { verifyDay } from '../utils/index';
import {
isTodayCodeFreezeDay,
DAYS_BETWEEN_CODE_FREEZE_AND_RELEASE,
getToday,
getFutureDate,
} from '../utils/index';
export const verifyDayCommand = new Command( 'verify-day' )
.description( 'Verify if today is the code freeze day' )
@ -14,8 +21,40 @@ export const verifyDayCommand = new Command( 'verify-day' )
'-o, --override <override>',
"Time Override: The time to use in checking whether the action should run (default: 'now')."
)
.action( () => {
console.log( verifyDay() );
.option(
'-g --github',
'CLI command is used in the Github Actions context.'
)
.action( ( { override, github } ) => {
const today = getToday( override );
const futureDate = getFutureDate( today );
console.log(
chalk.yellow( "Today's timestamp UTC is: " + today.toUTCString() )
);
console.log(
chalk.yellow(
`Checking to see if ${ DAYS_BETWEEN_CODE_FREEZE_AND_RELEASE } days from today is the second Tuesday of the month.`
)
);
const isCodeFreezeDay = isTodayCodeFreezeDay( override );
console.log(
chalk.green(
`${ futureDate.toUTCString() } ${
isCodeFreezeDay ? 'is' : 'is not'
} release day.`
)
);
console.log(
chalk.green(
`Today is ${
isCodeFreezeDay ? 'indeed' : 'not'
} code freeze day.`
)
);
if ( github ) {
setOutput( 'freeze', isCodeFreezeDay.toString() );
}
process.exit( 0 );
} );

View File

@ -1,10 +1,36 @@
/**
* Internal dependencies
*/
import { verifyDay } from '../index';
import { isTodayCodeFreezeDay } from '../index';
describe( 'verifyDay', () => {
it( 'should return a string', () => {
expect( verifyDay() ).toBe( 'Today is a good day to code freeze!' );
describe( 'isTodayCodeFreezeDay', () => {
it( 'should return false when given a day not 22 days before release', () => {
const JUNE_5_2023 = '2023-06-05T00:00:00.000Z';
const JUNE_12_2023 = '2023-06-12T00:00:00.000Z';
const JUNE_26_2023 = '2023-06-26T00:00:00.000Z';
const AUG_10_2023 = '2023-08-10T00:00:00.000Z';
const AUG_17_2023 = '2023-08-17T00:00:00.000Z';
const AUG_24_2023 = '2023-08-24T00:00:00.000Z';
expect( isTodayCodeFreezeDay( JUNE_5_2023 ) ).toBeFalsy();
expect( isTodayCodeFreezeDay( JUNE_12_2023 ) ).toBeFalsy();
expect( isTodayCodeFreezeDay( JUNE_26_2023 ) ).toBeFalsy();
expect( isTodayCodeFreezeDay( AUG_10_2023 ) ).toBeFalsy();
expect( isTodayCodeFreezeDay( AUG_17_2023 ) ).toBeFalsy();
expect( isTodayCodeFreezeDay( AUG_24_2023 ) ).toBeFalsy();
} );
it( 'should return true when given a day 22 days before release', () => {
const JUNE_19_2023 = '2023-06-19T00:00:00.000Z';
const JULY_17_2023 = '2023-07-17T00:00:00.000Z';
const AUGUST_21_2023 = '2023-08-21T00:00:00.000Z';
expect( isTodayCodeFreezeDay( JUNE_19_2023 ) ).toBeTruthy();
expect( isTodayCodeFreezeDay( JULY_17_2023 ) ).toBeTruthy();
expect( isTodayCodeFreezeDay( AUGUST_21_2023 ) ).toBeTruthy();
} );
it( 'should error out when passed an invalid date', () => {
expect( () => isTodayCodeFreezeDay( 'invalid date' ) ).toThrow();
} );
} );

View File

@ -1,3 +1,46 @@
export const verifyDay = () => {
return 'Today is a good day to code freeze!';
const MILLIS_IN_A_DAY = 24 * 60 * 60 * 1000;
export const DAYS_BETWEEN_CODE_FREEZE_AND_RELEASE = 22;
/**
* Get a Date object of now or the override time when specified.
*
* @param {string} now The time to use in checking if today is the day of the code freeze. Default to now.
* @return {Date} The Date object of now or the override time when specified.
*/
export const getToday = ( now = 'now' ): Date => {
const today = now === 'now' ? new Date() : new Date( now );
if ( isNaN( today.getTime() ) ) {
throw new Error(
'Invalid date: Check the override parameter (-o, --override) is a correct Date string'
);
}
return today;
};
/**
* Get a future date from today to see if its the release day.
*
* @param {string} today The time to use in checking if today is the day of the code freeze. Default to now.
* @return {Date} The Date object of the future date.
*/
export const getFutureDate = ( today: Date ) => {
return new Date(
today.getTime() + DAYS_BETWEEN_CODE_FREEZE_AND_RELEASE * MILLIS_IN_A_DAY
);
};
/**
* Determines if today is the day of the code freeze.
*
* @param {string} now The time to use in checking if today is the day of the code freeze. Default to now.
* @return {boolean} true if today is the day of the code freeze.
*/
export const isTodayCodeFreezeDay = ( now: string ) => {
const today = getToday( now );
const futureDate = getFutureDate( today );
const month = futureDate.getUTCMonth();
const year = futureDate.getUTCFullYear();
const firstDayOfMonth = new Date( Date.UTC( year, month, 1 ) );
const dayOfWeek = firstDayOfMonth.getUTCDay();
const secondTuesday = dayOfWeek <= 2 ? 10 - dayOfWeek : 17 - dayOfWeek;
return futureDate.getUTCDate() === secondTuesday;
};

View File

@ -1,4 +1,3 @@
#! /usr/bin/env node
/**
* External dependencies
*/
@ -9,9 +8,7 @@ import { Command } from '@commander-js/extra-typings';
*/
import CodeFreeze from './code-freeze/commands';
const program = new Command()
export const program = new Command()
.name( 'utils' )
.description( 'Monorepo utilities' )
.addCommand( CodeFreeze );
program.parse( process.argv );