rebase on oct-10-2023

This commit is contained in:
Rachit Bhargava
2023-10-10 17:23:21 -04:00
parent d37566ffb6
commit d096058d7d
4789 changed files with 254611 additions and 307223 deletions

View File

@@ -5,8 +5,11 @@
* Description: A robust scheduling library for use in WordPress plugins.
* Author: Automattic
* Author URI: https://automattic.com/
* Version: 3.5.4
* Version: 3.6.2
* License: GPLv3
* Tested up to: 6.3
* Requires at least: 5.2
* Requires PHP: 5.6
*
* Copyright 2019 Automattic, Inc. (https://automattic.com/contact/)
*
@@ -26,27 +29,27 @@
* @package ActionScheduler
*/
if ( ! function_exists( 'action_scheduler_register_3_dot_5_dot_4' ) && function_exists( 'add_action' ) ) { // WRCS: DEFINED_VERSION.
if ( ! function_exists( 'action_scheduler_register_3_dot_6_dot_2' ) && function_exists( 'add_action' ) ) { // WRCS: DEFINED_VERSION.
if ( ! class_exists( 'ActionScheduler_Versions', false ) ) {
require_once __DIR__ . '/classes/ActionScheduler_Versions.php';
add_action( 'plugins_loaded', array( 'ActionScheduler_Versions', 'initialize_latest_version' ), 1, 0 );
}
add_action( 'plugins_loaded', 'action_scheduler_register_3_dot_5_dot_4', 0, 0 ); // WRCS: DEFINED_VERSION.
add_action( 'plugins_loaded', 'action_scheduler_register_3_dot_6_dot_2', 0, 0 ); // WRCS: DEFINED_VERSION.
/**
* Registers this version of Action Scheduler.
*/
function action_scheduler_register_3_dot_5_dot_4() { // WRCS: DEFINED_VERSION.
function action_scheduler_register_3_dot_6_dot_2() { // WRCS: DEFINED_VERSION.
$versions = ActionScheduler_Versions::instance();
$versions->register( '3.5.4', 'action_scheduler_initialize_3_dot_5_dot_4' ); // WRCS: DEFINED_VERSION.
$versions->register( '3.6.2', 'action_scheduler_initialize_3_dot_6_dot_2' ); // WRCS: DEFINED_VERSION.
}
/**
* Initializes this version of Action Scheduler.
*/
function action_scheduler_initialize_3_dot_5_dot_4() { // WRCS: DEFINED_VERSION.
function action_scheduler_initialize_3_dot_6_dot_2() { // WRCS: DEFINED_VERSION.
// A final safety check is required even here, because historic versions of Action Scheduler
// followed a different pattern (in some unusual cases, we could reach this point and the
// ActionScheduler class is already defined—so we need to guard against that).
@@ -58,7 +61,7 @@ if ( ! function_exists( 'action_scheduler_register_3_dot_5_dot_4' ) && function_
// Support usage in themes - load this version if no plugin has loaded a version yet.
if ( did_action( 'plugins_loaded' ) && ! doing_action( 'plugins_loaded' ) && ! class_exists( 'ActionScheduler', false ) ) {
action_scheduler_initialize_3_dot_5_dot_4(); // WRCS: DEFINED_VERSION.
action_scheduler_initialize_3_dot_6_dot_2(); // WRCS: DEFINED_VERSION.
do_action( 'action_scheduler_pre_theme_init' );
ActionScheduler_Versions::initialize_latest_version();
}

View File

@@ -13,10 +13,14 @@ class ActionScheduler_ActionFactory {
* @param array $args Args to pass to callbacks when the hook is triggered.
* @param ActionScheduler_Schedule $schedule The action's schedule.
* @param string $group A group to put the action in.
* @param int $priority The action priority.
*
* @return ActionScheduler_Action An instance of the stored action.
*/
public function get_stored_action( $status, $hook, array $args = array(), ActionScheduler_Schedule $schedule = null, $group = '' ) {
// The 6th parameter ($priority) is not formally declared in the method signature to maintain compatibility with
// third-party subclasses created before this param was added.
$priority = func_num_args() >= 6 ? (int) func_get_arg( 5 ) : 10;
switch ( $status ) {
case ActionScheduler_Store::STATUS_PENDING:
@@ -36,17 +40,19 @@ class ActionScheduler_ActionFactory {
$action_class = apply_filters( 'action_scheduler_stored_action_class', $action_class, $status, $hook, $args, $schedule, $group );
$action = new $action_class( $hook, $args, $schedule, $group );
$action->set_priority( $priority );
/**
* Allow 3rd party code to change the instantiated action for a given hook, args, schedule and group.
*
* @param ActionScheduler_Action $action The instantiated action.
* @param string $hook The instantiated action's hook.
* @param array $args The instantiated action's args.
* @param ActionScheduler_Action $action The instantiated action.
* @param string $hook The instantiated action's hook.
* @param array $args The instantiated action's args.
* @param ActionScheduler_Schedule $schedule The instantiated action's schedule.
* @param string $group The instantiated action's group.
* @param string $group The instantiated action's group.
* @param int $priority The action priority.
*/
return apply_filters( 'action_scheduler_stored_action_instance', $action, $hook, $args, $schedule, $group );
return apply_filters( 'action_scheduler_stored_action_instance', $action, $hook, $args, $schedule, $group, $priority );
}
/**
@@ -229,9 +235,86 @@ class ActionScheduler_ActionFactory {
$schedule_class = get_class( $schedule );
$new_schedule = new $schedule( $next, $schedule->get_recurrence(), $schedule->get_first_date() );
$new_action = new ActionScheduler_Action( $action->get_hook(), $action->get_args(), $new_schedule, $action->get_group() );
$new_action->set_priority( $action->get_priority() );
return $this->store( $new_action );
}
/**
* Creates a scheduled action.
*
* This general purpose method can be used in place of specific methods such as async(),
* async_unique(), single() or single_unique(), etc.
*
* @internal Not intended for public use, should not be overriden by subclasses.
* @throws Exception May be thrown if invalid options are passed.
*
* @param array $options {
* Describes the action we wish to schedule.
*
* @type string $type Must be one of 'async', 'cron', 'recurring', or 'single'.
* @type string $hook The hook to be executed.
* @type array $arguments Arguments to be passed to the callback.
* @type string $group The action group.
* @type bool $unique If the action should be unique.
* @type int $when Timestamp. Indicates when the action, or first instance of the action in the case
* of recurring or cron actions, becomes due.
* @type int|string $pattern Recurrence pattern. This is either an interval in seconds for recurring actions
* or a cron expression for cron actions.
* @type int $priority Lower values means higher priority. Should be in the range 0-255.
* }
*
* @return int
*/
public function create( array $options = array() ) {
$defaults = array(
'type' => 'single',
'hook' => '',
'arguments' => array(),
'group' => '',
'unique' => false,
'when' => time(),
'pattern' => null,
'priority' => 10,
);
$options = array_merge( $defaults, $options );
// Cron/recurring actions without a pattern are treated as single actions (this gives calling code the ability
// to use functions like as_schedule_recurring_action() to schedule recurring as well as single actions).
if ( ( 'cron' === $options['type'] || 'recurring' === $options['type'] ) && empty( $options['pattern'] ) ) {
$options['type'] = 'single';
}
switch ( $options['type'] ) {
case 'async':
$schedule = new ActionScheduler_NullSchedule();
break;
case 'cron':
$date = as_get_datetime_object( $options['when'] );
$cron = CronExpression::factory( $options['pattern'] );
$schedule = new ActionScheduler_CronSchedule( $date, $cron );
break;
case 'recurring':
$date = as_get_datetime_object( $options['when'] );
$schedule = new ActionScheduler_IntervalSchedule( $date, $options['pattern'] );
break;
case 'single':
$date = as_get_datetime_object( $options['when'] );
$schedule = new ActionScheduler_SimpleSchedule( $date );
break;
default:
throw new Exception( "Unknown action type '{$options['type']}' specified when trying to create an action for '{$options['hook']}'." );
}
$action = new ActionScheduler_Action( $options['hook'], $options['arguments'], $schedule, $options['group'] );
$action->set_priority( $options['priority'] );
return $options['unique'] ? $this->store_unique_action( $action ) : $this->store( $action );
}
/**
* Save action to database.
*

View File

@@ -4,7 +4,6 @@
* Class ActionScheduler_Compatibility
*/
class ActionScheduler_Compatibility {
/**
* Converts a shorthand byte value to an integer byte value.
*
@@ -89,21 +88,18 @@ class ActionScheduler_Compatibility {
$limit = (int) $limit;
$max_execution_time = (int) ini_get( 'max_execution_time' );
/*
* If the max execution time is already unlimited (zero), or if it exceeds or is equal to the proposed
* limit, there is no reason for us to make further changes (we never want to lower it).
*/
if (
0 === $max_execution_time
|| ( $max_execution_time >= $limit && $limit !== 0 )
) {
// If the max execution time is already set to zero (unlimited), there is no reason to make a further change.
if ( 0 === $max_execution_time ) {
return;
}
// Whichever of $max_execution_time or $limit is higher is the amount by which we raise the time limit.
$raise_by = 0 === $limit || $limit > $max_execution_time ? $limit : $max_execution_time;
if ( function_exists( 'wc_set_time_limit' ) ) {
wc_set_time_limit( $limit );
wc_set_time_limit( $raise_by );
} elseif ( function_exists( 'set_time_limit' ) && false === strpos( ini_get( 'disable_functions' ), 'set_time_limit' ) && ! ini_get( 'safe_mode' ) ) { // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.safe_modeDeprecatedRemoved
@set_time_limit( $limit ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( $raise_by ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
}
}
}

View File

@@ -252,7 +252,7 @@ class ActionScheduler_ListTable extends ActionScheduler_Abstract_ListTable {
*/
protected function get_recurrence( $action ) {
$schedule = $action->get_schedule();
if ( $schedule->is_recurring() ) {
if ( $schedule->is_recurring() && method_exists( $schedule, 'get_recurrence' ) ) {
$recurrence = $schedule->get_recurrence();
if ( is_numeric( $recurrence ) ) {
@@ -471,7 +471,7 @@ class ActionScheduler_ListTable extends ActionScheduler_Abstract_ListTable {
return __( 'async', 'woocommerce' );
}
if ( ! $schedule->get_date() ) {
if ( ! method_exists( $schedule, 'get_date' ) || ! $schedule->get_date() ) {
return '0000-00-00 00:00:00';
}
@@ -502,7 +502,20 @@ class ActionScheduler_ListTable extends ActionScheduler_Abstract_ListTable {
*/
protected function bulk_delete( array $ids, $ids_sql ) {
foreach ( $ids as $id ) {
$this->store->delete_action( $id );
try {
$this->store->delete_action( $id );
} catch ( Exception $e ) {
// A possible reason for an exception would include a scenario where the same action is deleted by a
// concurrent request.
error_log(
sprintf(
/* translators: 1: action ID 2: exception message. */
__( 'Action Scheduler was unable to delete action %1$d. Reason: %2$s', 'woocommerce' ),
$id,
$e->getMessage()
)
);
}
}
}

View File

@@ -24,7 +24,37 @@ class ActionScheduler_OptionLock extends ActionScheduler_Lock {
* @bool True if lock value has changed, false if not or if set failed.
*/
public function set( $lock_type ) {
return update_option( $this->get_key( $lock_type ), time() + $this->get_duration( $lock_type ) );
global $wpdb;
$lock_key = $this->get_key( $lock_type );
$existing_lock_value = $this->get_existing_lock( $lock_type );
$new_lock_value = $this->new_lock_value( $lock_type );
// The lock may not exist yet, or may have been deleted.
if ( empty( $existing_lock_value ) ) {
return (bool) $wpdb->insert(
$wpdb->options,
array(
'option_name' => $lock_key,
'option_value' => $new_lock_value,
'autoload' => 'no',
)
);
}
if ( $this->get_expiration_from( $existing_lock_value ) >= time() ) {
return false;
}
// Otherwise, try to obtain the lock.
return (bool) $wpdb->update(
$wpdb->options,
array( 'option_value' => $new_lock_value ),
array(
'option_name' => $lock_key,
'option_value' => $existing_lock_value,
)
);
}
/**
@@ -34,7 +64,30 @@ class ActionScheduler_OptionLock extends ActionScheduler_Lock {
* @return bool|int False if no lock is set, otherwise the timestamp for when the lock is set to expire.
*/
public function get_expiration( $lock_type ) {
return get_option( $this->get_key( $lock_type ) );
return $this->get_expiration_from( $this->get_existing_lock( $lock_type ) );
}
/**
* Given the lock string, derives the lock expiration timestamp (or false if it cannot be determined).
*
* @param string $lock_value String containing a timestamp, or pipe-separated combination of unique value and timestamp.
*
* @return false|int
*/
private function get_expiration_from( $lock_value ) {
$lock_string = explode( '|', $lock_value );
// Old style lock?
if ( count( $lock_string ) === 1 && is_numeric( $lock_string[0] ) ) {
return (int) $lock_string[0];
}
// New style lock?
if ( count( $lock_string ) === 2 && is_numeric( $lock_string[1] ) ) {
return (int) $lock_string[1];
}
return false;
}
/**
@@ -46,4 +99,37 @@ class ActionScheduler_OptionLock extends ActionScheduler_Lock {
protected function get_key( $lock_type ) {
return sprintf( 'action_scheduler_lock_%s', $lock_type );
}
/**
* Supplies the existing lock value, or an empty string if not set.
*
* @param string $lock_type A string to identify different lock types.
*
* @return string
*/
private function get_existing_lock( $lock_type ) {
global $wpdb;
// Now grab the existing lock value, if there is one.
return (string) $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM $wpdb->options WHERE option_name = %s",
$this->get_key( $lock_type )
)
);
}
/**
* Supplies a lock value consisting of a unique value and the current timestamp, which are separated by a pipe
* character.
*
* Example: (string) "649de012e6b262.09774912|1688068114"
*
* @param string $lock_type A string to identify different lock types.
*
* @return string
*/
private function new_lock_value( $lock_type ) {
return uniqid( '', true ) . '|' . ( time() + $this->get_duration( $lock_type ) );
}
}

View File

@@ -18,6 +18,14 @@ class ActionScheduler_QueueCleaner {
*/
private $month_in_seconds = 2678400;
/**
* @var string[] Default list of statuses purged by the cleaner process.
*/
private $default_statuses_to_purge = [
ActionScheduler_Store::STATUS_COMPLETE,
ActionScheduler_Store::STATUS_CANCELED,
];
/**
* ActionScheduler_QueueCleaner constructor.
*
@@ -29,46 +37,113 @@ class ActionScheduler_QueueCleaner {
$this->batch_size = $batch_size;
}
/**
* Default queue cleaner process used by queue runner.
*
* @return array
*/
public function delete_old_actions() {
/**
* Filter the minimum scheduled date age for action deletion.
*
* @param int $retention_period Minimum scheduled age in seconds of the actions to be deleted.
*/
$lifespan = apply_filters( 'action_scheduler_retention_period', $this->month_in_seconds );
$cutoff = as_get_datetime_object($lifespan.' seconds ago');
$statuses_to_purge = array(
ActionScheduler_Store::STATUS_COMPLETE,
ActionScheduler_Store::STATUS_CANCELED,
);
try {
$cutoff = as_get_datetime_object( $lifespan . ' seconds ago' );
} catch ( Exception $e ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* Translators: %s is the exception message. */
esc_html__( 'It was not possible to determine a valid cut-off time: %s.', 'woocommerce' ),
esc_html( $e->getMessage() )
),
'3.5.5'
);
return array();
}
/**
* Filter the statuses when cleaning the queue.
*
* @param string[] $default_statuses_to_purge Action statuses to clean.
*/
$statuses_to_purge = (array) apply_filters( 'action_scheduler_default_cleaner_statuses', $this->default_statuses_to_purge );
return $this->clean_actions( $statuses_to_purge, $cutoff, $this->get_batch_size() );
}
/**
* Delete selected actions limited by status and date.
*
* @param string[] $statuses_to_purge List of action statuses to purge. Defaults to canceled, complete.
* @param DateTime $cutoff_date Date limit for selecting actions. Defaults to 31 days ago.
* @param int|null $batch_size Maximum number of actions per status to delete. Defaults to 20.
* @param string $context Calling process context. Defaults to `old`.
* @return array Actions deleted.
*/
public function clean_actions( array $statuses_to_purge, DateTime $cutoff_date, $batch_size = null, $context = 'old' ) {
$batch_size = $batch_size !== null ? $batch_size : $this->batch_size;
$cutoff = $cutoff_date !== null ? $cutoff_date : as_get_datetime_object( $this->month_in_seconds . ' seconds ago' );
$lifespan = time() - $cutoff->getTimestamp();
if ( empty( $statuses_to_purge ) ) {
$statuses_to_purge = $this->default_statuses_to_purge;
}
$deleted_actions = [];
foreach ( $statuses_to_purge as $status ) {
$actions_to_delete = $this->store->query_actions( array(
'status' => $status,
'modified' => $cutoff,
'modified_compare' => '<=',
'per_page' => $this->get_batch_size(),
'per_page' => $batch_size,
'orderby' => 'none',
) );
foreach ( $actions_to_delete as $action_id ) {
try {
$this->store->delete_action( $action_id );
} catch ( Exception $e ) {
$deleted_actions = array_merge( $deleted_actions, $this->delete_actions( $actions_to_delete, $lifespan, $context ) );
}
/**
* Notify 3rd party code of exceptions when deleting a completed action older than the retention period
*
* This hook provides a way for 3rd party code to log or otherwise handle exceptions relating to their
* actions.
*
* @since 2.0.0
*
* @param int $action_id The scheduled actions ID in the data store
* @param Exception $e The exception thrown when attempting to delete the action from the data store
* @param int $lifespan The retention period, in seconds, for old actions
* @param int $count_of_actions_to_delete The number of old actions being deleted in this batch
*/
do_action( 'action_scheduler_failed_old_action_deletion', $action_id, $e, $lifespan, count( $actions_to_delete ) );
}
return $deleted_actions;
}
/**
* @param int[] $actions_to_delete List of action IDs to delete.
* @param int $lifespan Minimum scheduled age in seconds of the actions being deleted.
* @param string $context Context of the delete request.
* @return array Deleted action IDs.
*/
private function delete_actions( array $actions_to_delete, $lifespan = null, $context = 'old' ) {
$deleted_actions = [];
if ( $lifespan === null ) {
$lifespan = $this->month_in_seconds;
}
foreach ( $actions_to_delete as $action_id ) {
try {
$this->store->delete_action( $action_id );
$deleted_actions[] = $action_id;
} catch ( Exception $e ) {
/**
* Notify 3rd party code of exceptions when deleting a completed action older than the retention period
*
* This hook provides a way for 3rd party code to log or otherwise handle exceptions relating to their
* actions.
*
* @param int $action_id The scheduled actions ID in the data store
* @param Exception $e The exception thrown when attempting to delete the action from the data store
* @param int $lifespan The retention period, in seconds, for old actions
* @param int $count_of_actions_to_delete The number of old actions being deleted in this batch
* @since 2.0.0
*
*/
do_action( "action_scheduler_failed_{$context}_action_deletion", $action_id, $e, $lifespan, count( $actions_to_delete ) );
}
}
return $deleted_actions;
}
/**

View File

@@ -103,9 +103,12 @@ class ActionScheduler_QueueRunner extends ActionScheduler_Abstract_QueueRunner {
* should dispatch a request to process pending actions.
*/
public function maybe_dispatch_async_request() {
if ( is_admin() && ! ActionScheduler::lock()->is_locked( 'async-request-runner' ) ) {
// Only start an async queue at most once every 60 seconds
ActionScheduler::lock()->set( 'async-request-runner' );
// Only start an async queue at most once every 60 seconds.
if (
is_admin()
&& ! ActionScheduler::lock()->is_locked( 'async-request-runner' )
&& ActionScheduler::lock()->set( 'async-request-runner' )
) {
$this->async_request->maybe_dispatch();
}
}
@@ -185,9 +188,15 @@ class ActionScheduler_QueueRunner extends ActionScheduler_Abstract_QueueRunner {
protected function clear_caches() {
/*
* Calling wp_cache_flush_runtime() lets us clear the runtime cache without invalidating the external object
* cache, so we will always prefer this when it is available (but it was only introduced in WordPress 6.0).
* cache, so we will always prefer this method (as compared to calling wp_cache_flush()) when it is available.
*
* However, this function was only introduced in WordPress 6.0. Additionally, the preferred way of detecting if
* it is supported changed in WordPress 6.1 so we use two different methods to decide if we should utilize it.
*/
if ( function_exists( 'wp_cache_flush_runtime' ) ) {
$flushing_runtime_cache_explicitly_supported = function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' );
$flushing_runtime_cache_implicitly_supported = ! function_exists( 'wp_cache_supports' ) && function_exists( 'wp_cache_flush_runtime' );
if ( $flushing_runtime_cache_explicitly_supported || $flushing_runtime_cache_implicitly_supported ) {
wp_cache_flush_runtime();
} elseif (
! wp_using_ext_object_cache()

View File

@@ -0,0 +1,125 @@
<?php
/**
* Commands for Action Scheduler.
*/
class ActionScheduler_WPCLI_Clean_Command extends WP_CLI_Command {
/**
* Run the Action Scheduler Queue Cleaner
*
* ## OPTIONS
*
* [--batch-size=<size>]
* : The maximum number of actions to delete per batch. Defaults to 20.
*
* [--batches=<size>]
* : Limit execution to a number of batches. Defaults to 0, meaning batches will continue all eligible actions are deleted.
*
* [--status=<status>]
* : Only clean actions with the specified status. Defaults to Canceled, Completed. Define multiple statuses as a comma separated string (without spaces), e.g. `--status=complete,failed,canceled`
*
* [--before=<datestring>]
* : Only delete actions with scheduled date older than this. Defaults to 31 days. e.g `--before='7 days ago'`, `--before='02-Feb-2020 20:20:20'`
*
* [--pause=<seconds>]
* : The number of seconds to pause between batches. Default no pause.
*
* @param array $args Positional arguments.
* @param array $assoc_args Keyed arguments.
* @throws \WP_CLI\ExitException When an error occurs.
*
* @subcommand clean
*/
public function clean( $args, $assoc_args ) {
// Handle passed arguments.
$batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 20 ) );
$batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) );
$status = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'status', '' ) );
$status = array_filter( array_map( 'trim', $status ) );
$before = \WP_CLI\Utils\get_flag_value( $assoc_args, 'before', '' );
$sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 );
$batches_completed = 0;
$actions_deleted = 0;
$unlimited = $batches === 0;
try {
$lifespan = as_get_datetime_object( $before );
} catch ( Exception $e ) {
$lifespan = null;
}
try {
// Custom queue cleaner instance.
$cleaner = new ActionScheduler_QueueCleaner( null, $batch );
// Clean actions for as long as possible.
while ( $unlimited || $batches_completed < $batches ) {
if ( $sleep && $batches_completed > 0 ) {
sleep( $sleep );
}
$deleted = count( $cleaner->clean_actions( $status, $lifespan, null,'CLI' ) );
if ( $deleted <= 0 ) {
break;
}
$actions_deleted += $deleted;
$batches_completed++;
$this->print_success( $deleted );
}
} catch ( Exception $e ) {
$this->print_error( $e );
}
$this->print_total_batches( $batches_completed );
if ( $batches_completed > 1 ) {
$this->print_success( $actions_deleted );
}
}
/**
* Print WP CLI message about how many batches of actions were processed.
*
* @param int $batches_processed
*/
protected function print_total_batches( int $batches_processed ) {
WP_CLI::log(
sprintf(
/* translators: %d refers to the total number of batches processed */
_n( '%d batch processed.', '%d batches processed.', $batches_processed, 'woocommerce' ),
$batches_processed
)
);
}
/**
* Convert an exception into a WP CLI error.
*
* @param Exception $e The error object.
*
* @throws \WP_CLI\ExitException
*/
protected function print_error( Exception $e ) {
WP_CLI::error(
sprintf(
/* translators: %s refers to the exception error message */
__( 'There was an error deleting an action: %s', 'woocommerce' ),
$e->getMessage()
)
);
}
/**
* Print a success message with the number of completed actions.
*
* @param int $actions_deleted
*/
protected function print_success( int $actions_deleted ) {
WP_CLI::success(
sprintf(
/* translators: %d refers to the total number of actions deleted */
_n( '%d action deleted.', '%d actions deleted.', $actions_deleted, 'woocommerce' ),
$actions_deleted
)
);
}
}

View File

@@ -90,7 +90,7 @@ class ActionScheduler_WPCLI_QueueRunner extends ActionScheduler_Abstract_QueueRu
$count = count( $this->actions );
$this->progress_bar = new ProgressBar(
/* translators: %d: amount of actions */
sprintf( _n( 'Running %d action', 'Running %d actions', $count, 'woocommerce' ), number_format_i18n( $count ) ),
sprintf( _n( 'Running %d action', 'Running %d actions', $count, 'woocommerce' ), $count ),
$count
);
}

View File

@@ -55,6 +55,9 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
* [--group=<group>]
* : Only run actions from the specified group. Omitting this option runs actions from all groups.
*
* [--exclude-groups=<groups>]
* : Run actions from all groups except the specified group(s). Define multiple groups as a comma separated string (without spaces), e.g. '--group_a,group_b'. This option is ignored when `--group` is used.
*
* [--free-memory-on=<count>]
* : The number of actions to process between freeing memory. 0 disables freeing memory. Default 50.
*
@@ -72,15 +75,16 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
*/
public function run( $args, $assoc_args ) {
// Handle passed arguments.
$batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 ) );
$batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) );
$clean = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'cleanup-batch-size', $batch ) );
$hooks = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'hooks', '' ) );
$hooks = array_filter( array_map( 'trim', $hooks ) );
$group = \WP_CLI\Utils\get_flag_value( $assoc_args, 'group', '' );
$free_on = \WP_CLI\Utils\get_flag_value( $assoc_args, 'free-memory-on', 50 );
$sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 );
$force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false );
$batch = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 ) );
$batches = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'batches', 0 ) );
$clean = absint( \WP_CLI\Utils\get_flag_value( $assoc_args, 'cleanup-batch-size', $batch ) );
$hooks = explode( ',', WP_CLI\Utils\get_flag_value( $assoc_args, 'hooks', '' ) );
$hooks = array_filter( array_map( 'trim', $hooks ) );
$group = \WP_CLI\Utils\get_flag_value( $assoc_args, 'group', '' );
$exclude_groups = \WP_CLI\Utils\get_flag_value( $assoc_args, 'exclude-groups', '' );
$free_on = \WP_CLI\Utils\get_flag_value( $assoc_args, 'free-memory-on', 50 );
$sleep = \WP_CLI\Utils\get_flag_value( $assoc_args, 'pause', 0 );
$force = \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false );
ActionScheduler_DataController::set_free_ticks( $free_on );
ActionScheduler_DataController::set_sleep_time( $sleep );
@@ -88,6 +92,13 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
$batches_completed = 0;
$actions_completed = 0;
$unlimited = $batches === 0;
if ( is_callable( [ ActionScheduler::store(), 'set_claim_filter' ] ) ) {
$exclude_groups = $this->parse_comma_separated_string( $exclude_groups );
if ( ! empty( $exclude_groups ) ) {
ActionScheduler::store()->set_claim_filter('exclude-groups', $exclude_groups );
}
}
try {
// Custom queue cleaner instance.
@@ -116,6 +127,17 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
$this->print_success( $actions_completed );
}
/**
* Converts a string of comma-separated values into an array of those same values.
*
* @param string $string The string of one or more comma separated values.
*
* @return array
*/
private function parse_comma_separated_string( $string ): array {
return array_filter( str_getcsv( $string ) );
}
/**
* Print WP CLI message about how many actions are about to be processed.
*
@@ -126,9 +148,9 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
protected function print_total_actions( $total ) {
WP_CLI::log(
sprintf(
/* translators: %d refers to how many scheduled taks were found to run */
/* translators: %d refers to how many scheduled tasks were found to run */
_n( 'Found %d scheduled task', 'Found %d scheduled tasks', $total, 'woocommerce' ),
number_format_i18n( $total )
$total
)
);
}
@@ -145,7 +167,7 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
sprintf(
/* translators: %d refers to the total number of batches executed */
_n( '%d batch executed.', '%d batches executed.', $batches_completed, 'woocommerce' ),
number_format_i18n( $batches_completed )
$batches_completed
)
);
}
@@ -179,9 +201,9 @@ class ActionScheduler_WPCLI_Scheduler_command extends WP_CLI_Command {
protected function print_success( $actions_completed ) {
WP_CLI::success(
sprintf(
/* translators: %d refers to the total number of taskes completed */
/* translators: %d refers to the total number of tasks completed */
_n( '%d scheduled task completed.', '%d scheduled tasks completed.', $actions_completed, 'woocommerce' ),
number_format_i18n( $actions_completed )
$actions_completed
)
);
}

View File

@@ -153,11 +153,41 @@ abstract class ActionScheduler {
add_action( 'init', array( $store, 'init' ), 1, 0 );
add_action( 'init', array( $logger, 'init' ), 1, 0 );
add_action( 'init', array( $runner, 'init' ), 1, 0 );
add_action(
'init',
/**
* Runs after the active store's init() method has been called.
*
* It would probably be preferable to have $store->init() (or it's parent method) set this itself,
* once it has initialized, however that would cause problems in cases where a custom data store is in
* use and it has not yet been updated to follow that same logic.
*/
function () {
self::$data_store_initialized = true;
/**
* Fires when Action Scheduler is ready: it is safe to use the procedural API after this point.
*
* @since 3.5.5
*/
do_action( 'action_scheduler_init' );
},
1
);
} else {
$admin_view->init();
$store->init();
$logger->init();
$runner->init();
self::$data_store_initialized = true;
/**
* Fires when Action Scheduler is ready: it is safe to use the procedural API after this point.
*
* @since 3.5.5
*/
do_action( 'action_scheduler_init' );
}
if ( apply_filters( 'action_scheduler_load_deprecated_functions', true ) ) {
@@ -166,14 +196,13 @@ abstract class ActionScheduler {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command' );
WP_CLI::add_command( 'action-scheduler', 'ActionScheduler_WPCLI_Clean_Command' );
if ( ! ActionScheduler_DataController::is_migration_complete() && Controller::instance()->allow_migration() ) {
$command = new Migration_Command();
$command->register();
}
}
self::$data_store_initialized = true;
/**
* Handle WP comment cleanup after migration.
*/
@@ -192,8 +221,12 @@ abstract class ActionScheduler {
*/
public static function is_initialized( $function_name = null ) {
if ( ! self::$data_store_initialized && ! empty( $function_name ) ) {
$message = sprintf( __( '%s() was called before the Action Scheduler data store was initialized', 'woocommerce' ), esc_attr( $function_name ) );
error_log( $message, E_WARNING );
$message = sprintf(
/* translators: %s function name. */
__( '%s() was called before the Action Scheduler data store was initialized', 'woocommerce' ),
esc_attr( $function_name )
);
error_log( $message );
}
return self::$data_store_initialized;

View File

@@ -673,24 +673,34 @@ abstract class ActionScheduler_Abstract_ListTable extends WP_List_Table {
// Helper to set 'all' filter when not set on status counts passed in.
if ( ! isset( $this->status_counts['all'] ) ) {
$this->status_counts = array( 'all' => array_sum( $this->status_counts ) ) + $this->status_counts;
$all_count = array_sum( $this->status_counts );
if ( isset( $this->status_counts['past-due'] ) ) {
$all_count -= $this->status_counts['past-due'];
}
$this->status_counts = array( 'all' => $all_count ) + $this->status_counts;
}
foreach ( $this->status_counts as $status_name => $count ) {
// Translated status labels.
$status_labels = ActionScheduler_Store::instance()->get_status_labels();
$status_labels['all'] = _x( 'All', 'status labels', 'woocommerce' );
$status_labels['past-due'] = _x( 'Past-due', 'status labels', 'woocommerce' );
foreach ( $this->status_counts as $status_slug => $count ) {
if ( 0 === $count ) {
continue;
}
if ( $status_name === $request_status || ( empty( $request_status ) && 'all' === $status_name ) ) {
if ( $status_slug === $request_status || ( empty( $request_status ) && 'all' === $status_slug ) ) {
$status_list_item = '<li class="%1$s"><a href="%2$s" class="current">%3$s</a> (%4$d)</li>';
} else {
$status_list_item = '<li class="%1$s"><a href="%2$s">%3$s</a> (%4$d)</li>';
}
$status_filter_url = ( 'all' === $status_name ) ? remove_query_arg( 'status' ) : add_query_arg( 'status', $status_name );
$status_name = isset( $status_labels[ $status_slug ] ) ? $status_labels[ $status_slug ] : ucfirst( $status_slug );
$status_filter_url = ( 'all' === $status_slug ) ? remove_query_arg( 'status' ) : add_query_arg( 'status', $status_slug );
$status_filter_url = remove_query_arg( array( 'paged', 's' ), $status_filter_url );
$status_list_items[] = sprintf( $status_list_item, esc_attr( $status_name ), esc_url( $status_filter_url ), esc_html( ucfirst( $status_name ) ), absint( $count ) );
$status_list_items[] = sprintf( $status_list_item, esc_attr( $status_slug ), esc_url( $status_filter_url ), esc_html( $status_name ), absint( $count ) );
}
if ( $status_list_items ) {

View File

@@ -48,30 +48,56 @@ abstract class ActionScheduler_Abstract_QueueRunner extends ActionScheduler_Abst
* Generally, this should be capitalised and not localised as it's a proper noun.
*/
public function process_action( $action_id, $context = '' ) {
// Temporarily override the error handler while we process the current action.
set_error_handler(
/**
* Temporary error handler which can catch errors and convert them into exceptions. This faciliates more
* robust error handling across all supported PHP versions.
*
* @throws Exception
*
* @param int $type Error level expressed as an integer.
* @param string $message Error message.
*/
function ( $type, $message ) {
throw new Exception( $message );
},
E_USER_ERROR | E_RECOVERABLE_ERROR
);
/*
* The nested try/catch structure is required because we potentially need to convert thrown errors into
* exceptions (and an exception thrown from a catch block cannot be caught by a later catch block in the *same*
* structure).
*/
try {
$valid_action = false;
do_action( 'action_scheduler_before_execute', $action_id, $context );
try {
$valid_action = false;
do_action( 'action_scheduler_before_execute', $action_id, $context );
if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
do_action( 'action_scheduler_execution_ignored', $action_id, $context );
return;
if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
do_action( 'action_scheduler_execution_ignored', $action_id, $context );
return;
}
$valid_action = true;
do_action( 'action_scheduler_begin_execute', $action_id, $context );
$action = $this->store->fetch_action( $action_id );
$this->store->log_execution( $action_id );
$action->execute();
do_action( 'action_scheduler_after_execute', $action_id, $action, $context );
$this->store->mark_complete( $action_id );
} catch ( Throwable $e ) {
// Throwable is defined when executing under PHP 7.0 and up. We convert it to an exception, for
// compatibility with ActionScheduler_Logger.
throw new Exception( $e->getMessage(), $e->getCode(), $e->getPrevious() );
}
$valid_action = true;
do_action( 'action_scheduler_begin_execute', $action_id, $context );
$action = $this->store->fetch_action( $action_id );
$this->store->log_execution( $action_id );
$action->execute();
do_action( 'action_scheduler_after_execute', $action_id, $action, $context );
$this->store->mark_complete( $action_id );
} catch ( Exception $e ) {
if ( $valid_action ) {
$this->store->mark_failure( $action_id );
do_action( 'action_scheduler_failed_execution', $action_id, $e, $context );
} else {
do_action( 'action_scheduler_failed_validation', $action_id, $e, $context );
}
// This catch block exists for compatibility with PHP 5.6.
$this->handle_action_error( $action_id, $e, $context, $valid_action );
} finally {
restore_error_handler();
}
if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) {
@@ -79,6 +105,39 @@ abstract class ActionScheduler_Abstract_QueueRunner extends ActionScheduler_Abst
}
}
/**
* Marks actions as either having failed execution or failed validation, as appropriate.
*
* @param int $action_id Action ID.
* @param Exception $e Exception instance.
* @param string $context Execution context.
* @param bool $valid_action If the action is valid.
*
* @return void
*/
private function handle_action_error( $action_id, $e, $context, $valid_action ) {
if ( $valid_action ) {
$this->store->mark_failure( $action_id );
/**
* Runs when action execution fails.
*
* @param int $action_id Action ID.
* @param Exception $e Exception instance.
* @param string $context Execution context.
*/
do_action( 'action_scheduler_failed_execution', $action_id, $e, $context );
} else {
/**
* Runs when action validation fails.
*
* @param int $action_id Action ID.
* @param Exception $e Exception instance.
* @param string $context Execution context.
*/
do_action( 'action_scheduler_failed_validation', $action_id, $e, $context );
}
}
/**
* Schedule the next instance of the action if necessary.
*
@@ -143,12 +202,22 @@ abstract class ActionScheduler_Abstract_QueueRunner extends ActionScheduler_Abst
return false;
}
// Now let's fetch the first action (having the same hook) of *any status*ithin the same window.
// Now let's fetch the first action (having the same hook) of *any status* within the same window.
unset( $query_args['status'] );
$first_action_id_with_the_same_hook = $this->store->query_actions( $query_args );
// If the IDs match, then actions for this hook must be consistently failing.
return $first_action_id_with_the_same_hook === $first_failing_action_id;
/**
* If a recurring action is assessed as consistently failing, it will not be rescheduled. This hook provides a
* way to observe and optionally override that assessment.
*
* @param bool $is_consistently_failing If the action is considered to be consistently failing.
* @param ActionScheduler_Action $action The action being assessed.
*/
return (bool) apply_filters(
'action_scheduler_recurring_action_is_consistently_failing',
$first_action_id_with_the_same_hook === $first_failing_action_id,
$action
);
}
/**

View File

@@ -25,7 +25,7 @@ abstract class ActionScheduler_Abstract_Schema {
/**
* @var array Names of tables that will be registered by this class.
*/
protected $tables = [];
protected $tables = array();
/**
* Can optionally be used by concrete classes to carry out additional initialization work
@@ -90,10 +90,10 @@ abstract class ActionScheduler_Abstract_Schema {
$plugin_option_name = 'schema-';
switch ( static::class ) {
case 'ActionScheduler_StoreSchema' :
case 'ActionScheduler_StoreSchema':
$plugin_option_name .= 'Action_Scheduler\Custom_Tables\DB_Store_Table_Maker';
break;
case 'ActionScheduler_LoggerSchema' :
case 'ActionScheduler_LoggerSchema':
$plugin_option_name .= 'Action_Scheduler\Custom_Tables\DB_Logger_Table_Maker';
break;
}
@@ -129,7 +129,7 @@ abstract class ActionScheduler_Abstract_Schema {
* @return void
*/
private function update_table( $table ) {
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$definition = $this->get_table_definition( $table );
if ( $definition ) {
$updated = dbDelta( $definition );
@@ -148,7 +148,7 @@ abstract class ActionScheduler_Abstract_Schema {
* table prefix for the current blog
*/
protected function get_full_table_name( $table ) {
return $GLOBALS[ 'wpdb' ]->prefix . $table;
return $GLOBALS['wpdb']->prefix . $table;
}
/**
@@ -159,14 +159,19 @@ abstract class ActionScheduler_Abstract_Schema {
public function tables_exist() {
global $wpdb;
$existing_tables = $wpdb->get_col( 'SHOW TABLES' );
$expected_tables = array_map(
function ( $table_name ) use ( $wpdb ) {
return $wpdb->prefix . $table_name;
},
$this->tables
);
$tables_exist = true;
return count( array_intersect( $existing_tables, $expected_tables ) ) === count( $expected_tables );
foreach ( $this->tables as $table_name ) {
$table_name = $wpdb->prefix . $table_name;
$pattern = str_replace( '_', '\\_', $table_name );
$existing_table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $pattern ) );
if ( $existing_table !== $table_name ) {
$tables_exist = false;
break;
}
}
return $tables_exist;
}
}

View File

@@ -26,6 +26,8 @@ abstract class ActionScheduler_Lock {
/**
* Set a lock.
*
* To prevent race conditions, implementations should avoid setting the lock if the lock is already held.
*
* @param string $lock_type A string to identify different lock types.
* @return bool
*/

View File

@@ -10,6 +10,19 @@ class ActionScheduler_Action {
protected $schedule = NULL;
protected $group = '';
/**
* Priorities are conceptually similar to those used for regular WordPress actions.
* Like those, a lower priority takes precedence over a higher priority and the default
* is 10.
*
* Unlike regular WordPress actions, the priority of a scheduled action is strictly an
* integer and should be kept within the bounds 0-255 (anything outside the bounds will
* be brought back into the acceptable range).
*
* @var int
*/
protected $priority = 10;
public function __construct( $hook, array $args = array(), ActionScheduler_Schedule $schedule = NULL, $group = '' ) {
$schedule = empty( $schedule ) ? new ActionScheduler_NullSchedule() : $schedule;
$this->set_hook($hook);
@@ -93,4 +106,30 @@ class ActionScheduler_Action {
public function is_finished() {
return FALSE;
}
/**
* Sets the priority of the action.
*
* @param int $priority Priority level (lower is higher priority). Should be in the range 0-255.
*
* @return void
*/
public function set_priority( $priority ) {
if ( $priority < 0 ) {
$priority = 0;
} elseif ( $priority > 255 ) {
$priority = 255;
}
$this->priority = (int) $priority;
}
/**
* Gets the action priority.
*
* @return int
*/
public function get_priority() {
return $this->priority;
}
}

View File

@@ -25,6 +25,13 @@ class ActionScheduler_DBStore extends ActionScheduler_Store {
/** @var int */
protected static $max_index_length = 191;
/** @var array List of claim filters. */
protected $claim_filters = [
'group' => '',
'hooks' => '',
'exclude-groups' => '',
];
/**
* Initialize the data store
*
@@ -84,7 +91,8 @@ class ActionScheduler_DBStore extends ActionScheduler_Store {
'scheduled_date_gmt' => $this->get_scheduled_date_string( $action, $date ),
'scheduled_date_local' => $this->get_scheduled_date_string_local( $action, $date ),
'schedule' => serialize( $action->get_schedule() ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
'group_id' => $this->get_group_id( $action->get_group() ),
'group_id' => current( $this->get_group_ids( $action->get_group() ) ),
'priority' => $action->get_priority(),
);
$args = wp_json_encode( $action->get_args() );
@@ -172,6 +180,7 @@ WHERE ( $where_clause ) IS NULL",
ActionScheduler_Store::STATUS_RUNNING,
);
$pending_status_placeholders = implode( ', ', array_fill( 0, count( $pending_statuses ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $pending_status_placeholders is hardcoded.
$where_clause = $wpdb->prepare(
"
@@ -242,23 +251,35 @@ AND `group_id` = %d
/**
* Get a group's ID based on its name/slug.
*
* @param string $slug The string name of a group.
* @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group.
* @param string|array $slugs The string name of a group, or names for several groups.
* @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group.
*
* @return int The group's ID, if it exists or is created, or 0 if it does not exist and is not created.
* @return array The group IDs, if they exist or were successfully created. May be empty.
*/
protected function get_group_id( $slug, $create_if_not_exists = true ) {
if ( empty( $slug ) ) {
return 0;
}
/** @var \wpdb $wpdb */
global $wpdb;
$group_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT group_id FROM {$wpdb->actionscheduler_groups} WHERE slug=%s", $slug ) );
if ( empty( $group_id ) && $create_if_not_exists ) {
$group_id = $this->create_group( $slug );
protected function get_group_ids( $slugs, $create_if_not_exists = true ) {
$slugs = (array) $slugs;
$group_ids = array();
if ( empty( $slugs ) ) {
return array();
}
return $group_id;
/** @var \wpdb $wpdb */
global $wpdb;
foreach ( $slugs as $slug ) {
$group_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT group_id FROM {$wpdb->actionscheduler_groups} WHERE slug=%s", $slug ) );
if ( empty( $group_id ) && $create_if_not_exists ) {
$group_id = $this->create_group( $slug );
}
if ( $group_id ) {
$group_ids[] = $group_id;
}
}
return $group_ids;
}
/**
@@ -355,7 +376,7 @@ AND `group_id` = %d
}
$group = $data->group ? $data->group : '';
return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group );
return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group, $data->priority );
}
/**
@@ -796,6 +817,33 @@ AND `group_id` = %d
return $wpdb->insert_id;
}
/**
* Set a claim filter.
*
* @param string $filter_name Claim filter name.
* @param mixed $filter_values Values to filter.
* @return void
*/
public function set_claim_filter( $filter_name, $filter_values ) {
if ( isset( $this->claim_filters[ $filter_name ] ) ) {
$this->claim_filters[ $filter_name ] = $filter_values;
}
}
/**
* Get the claim filter value.
*
* @param string $filter_name Claim filter name.
* @return mixed
*/
public function get_claim_filter( $filter_name ) {
if ( isset( $this->claim_filters[ $filter_name ] ) ) {
return $this->claim_filters[ $filter_name ];
}
return '';
}
/**
* Mark actions claimed.
*
@@ -813,9 +861,8 @@ AND `group_id` = %d
/** @var \wpdb $wpdb */
global $wpdb;
$now = as_get_datetime_object();
$date = is_null( $before_date ) ? $now : clone $before_date;
$now = as_get_datetime_object();
$date = is_null( $before_date ) ? $now : clone $before_date;
// can't use $wpdb->update() because of the <= condition.
$update = "UPDATE {$wpdb->actionscheduler_actions} SET claim_id=%d, last_attempt_gmt=%s, last_attempt_local=%s";
$params = array(
@@ -824,6 +871,18 @@ AND `group_id` = %d
current_time( 'mysql' ),
);
// Set claim filters.
if ( ! empty( $hooks ) ) {
$this->set_claim_filter( 'hooks', $hooks );
} else {
$hooks = $this->get_claim_filter( 'hooks' );
}
if ( ! empty( $group ) ) {
$this->set_claim_filter( 'group', $group );
} else {
$group = $this->get_claim_filter( 'group' );
}
$where = 'WHERE claim_id = 0 AND scheduled_date_gmt <= %s AND status=%s';
$params[] = $date->format( 'Y-m-d H:i:s' );
$params[] = self::STATUS_PENDING;
@@ -834,18 +893,33 @@ AND `group_id` = %d
$params = array_merge( $params, array_values( $hooks ) );
}
$group_operator = 'IN';
if ( empty( $group ) ) {
$group = $this->get_claim_filter( 'exclude-groups' );
$group_operator = 'NOT IN';
}
if ( ! empty( $group ) ) {
$group_ids = $this->get_group_ids( $group, false );
$group_id = $this->get_group_id( $group, false );
// throw exception if no matching group found, this matches ActionScheduler_wpPostStore's behaviour.
if ( empty( $group_id ) ) {
/* translators: %s: group name */
throw new InvalidArgumentException( sprintf( __( 'The group "%s" does not exist.', 'woocommerce' ), $group ) );
// throw exception if no matching group(s) found, this matches ActionScheduler_wpPostStore's behaviour.
if ( empty( $group_ids ) ) {
throw new InvalidArgumentException(
sprintf(
/* translators: %s: group name(s) */
_n(
'The group "%s" does not exist.',
'The groups "%s" do not exist.',
is_array( $group ) ? count( $group ) : 1,
'woocommerce'
),
$group
)
);
}
$where .= ' AND group_id = %d';
$params[] = $group_id;
$id_list = implode( ',', array_map( 'intval', $group_ids ) );
$where .= " AND group_id {$group_operator} ( $id_list )";
}
/**
@@ -855,13 +929,23 @@ AND `group_id` = %d
*
* @param string $order_by_sql
*/
$order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY attempts ASC, scheduled_date_gmt ASC, action_id ASC' );
$order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC' );
$params[] = $limit;
$sql = $wpdb->prepare( "{$update} {$where} {$order} LIMIT %d", $params ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
$rows_affected = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
if ( false === $rows_affected ) {
throw new \RuntimeException( __( 'Unable to claim actions. Database error.', 'woocommerce' ) );
$error = empty( $wpdb->last_error )
? _x( 'unknown', 'database error', 'woocommerce' )
: $wpdb->last_error;
throw new \RuntimeException(
sprintf(
/* translators: %s database error. */
__( 'Unable to claim actions. Database error: %s.', 'woocommerce' ),
$error
)
);
}
return (int) $rows_affected;
@@ -912,7 +996,7 @@ AND `group_id` = %d
$cut_off = $before_date->format( 'Y-m-d H:i:s' );
$sql = $wpdb->prepare(
"SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d",
"SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC",
$claim_id
);
@@ -1005,6 +1089,8 @@ AND `group_id` = %d
/**
* Add execution message to action log.
*
* @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress').
*
* @param int $action_id Action ID.
*
* @return void
@@ -1015,7 +1101,20 @@ AND `group_id` = %d
$sql = "UPDATE {$wpdb->actionscheduler_actions} SET attempts = attempts+1, status=%s, last_attempt_gmt = %s, last_attempt_local = %s WHERE action_id = %d";
$sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time( 'mysql', true ), current_time( 'mysql' ), $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$status_updated = $wpdb->query( $sql );
if ( ! $status_updated ) {
throw new Exception(
sprintf(
/* translators: 1: action ID. 2: status slug. */
__( 'Unable to update the status of action %1$d to %2$s.', 'woocommerce' ),
$action_id,
self::STATUS_RUNNING
)
);
}
}
/**

View File

@@ -936,6 +936,8 @@ class ActionScheduler_wpPostStore extends ActionScheduler_Store {
/**
* Log Execution.
*
* @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress').
*
* @param string $action_id Action ID.
*/
public function log_execution( $action_id ) {
@@ -947,7 +949,7 @@ class ActionScheduler_wpPostStore extends ActionScheduler_Store {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$status_updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->posts} SET menu_order = menu_order+1, post_status=%s, post_modified_gmt = %s, post_modified = %s WHERE ID = %d AND post_type = %s",
self::STATUS_RUNNING,
@@ -957,6 +959,17 @@ class ActionScheduler_wpPostStore extends ActionScheduler_Store {
self::POST_TYPE
)
);
if ( ! $status_updated ) {
throw new Exception(
sprintf(
/* translators: 1: action ID. 2: status slug. */
__( 'Unable to update the status of action %1$d to %2$s.', 'woocommerce' ),
$action_id,
self::STATUS_RUNNING
)
);
}
}
/**

View File

@@ -79,7 +79,7 @@ class Runner {
if ( $this->progress_bar ) {
/* translators: %d: amount of actions */
$this->progress_bar->set_message( sprintf( _n( 'Migrating %d action', 'Migrating %d actions', $batch_size, 'woocommerce' ), number_format_i18n( $batch_size ) ) );
$this->progress_bar->set_message( sprintf( _n( 'Migrating %d action', 'Migrating %d actions', $batch_size, 'woocommerce' ), $batch_size ) );
$this->progress_bar->set_count( $batch_size );
}

View File

@@ -16,7 +16,7 @@ class ActionScheduler_StoreSchema extends ActionScheduler_Abstract_Schema {
/**
* @var int Increment this value to trigger a schema update.
*/
protected $schema_version = 6;
protected $schema_version = 7;
public function __construct() {
$this->tables = [
@@ -49,6 +49,7 @@ class ActionScheduler_StoreSchema extends ActionScheduler_Abstract_Schema {
status varchar(20) NOT NULL,
scheduled_date_gmt datetime NULL default '{$default_date}',
scheduled_date_local datetime NULL default '{$default_date}',
priority tinyint unsigned NOT NULL default '10',
args varchar($max_index_length),
schedule longtext,
group_id bigint(20) unsigned NOT NULL default '0',

View File

@@ -12,10 +12,11 @@
* @param array $args Arguments to pass when the hook triggers.
* @param string $group The group to assign this job to.
* @param bool $unique Whether the action should be unique.
* @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
*
* @return int The action ID.
*/
function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false ) {
function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) {
if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) {
return 0;
}
@@ -33,13 +34,23 @@ function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique =
* @param string $hook Action hook.
* @param array $args Action arguments.
* @param string $group Action group.
* @param int $priority Action priority.
*/
$pre = apply_filters( 'pre_as_enqueue_async_action', null, $hook, $args, $group );
$pre = apply_filters( 'pre_as_enqueue_async_action', null, $hook, $args, $group, $priority );
if ( null !== $pre ) {
return is_int( $pre ) ? $pre : 0;
}
return ActionScheduler::factory()->async_unique( $hook, $args, $group, $unique );
return ActionScheduler::factory()->create(
array(
'type' => 'async',
'hook' => $hook,
'arguments' => $args,
'group' => $group,
'unique' => $unique,
'priority' => $priority,
)
);
}
/**
@@ -50,10 +61,11 @@ function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique =
* @param array $args Arguments to pass when the hook triggers.
* @param string $group The group to assign this job to.
* @param bool $unique Whether the action should be unique.
* @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
*
* @return int The action ID.
*/
function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false ) {
function as_schedule_single_action( $timestamp, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) {
if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) {
return 0;
}
@@ -72,13 +84,24 @@ function as_schedule_single_action( $timestamp, $hook, $args = array(), $group =
* @param string $hook Action hook.
* @param array $args Action arguments.
* @param string $group Action group.
* @param int $priorities Action priority.
*/
$pre = apply_filters( 'pre_as_schedule_single_action', null, $timestamp, $hook, $args, $group );
$pre = apply_filters( 'pre_as_schedule_single_action', null, $timestamp, $hook, $args, $group, $priority );
if ( null !== $pre ) {
return is_int( $pre ) ? $pre : 0;
}
return ActionScheduler::factory()->single_unique( $hook, $args, $timestamp, $group, $unique );
return ActionScheduler::factory()->create(
array(
'type' => 'single',
'hook' => $hook,
'arguments' => $args,
'when' => $timestamp,
'group' => $group,
'unique' => $unique,
'priority' => $priority,
)
);
}
/**
@@ -90,14 +113,34 @@ function as_schedule_single_action( $timestamp, $hook, $args = array(), $group =
* @param array $args Arguments to pass when the hook triggers.
* @param string $group The group to assign this job to.
* @param bool $unique Whether the action should be unique.
* @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
*
* @return int The action ID.
*/
function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false ) {
function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) {
if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) {
return 0;
}
$interval = (int) $interval_in_seconds;
// We expect an integer and allow it to be passed using float and string types, but otherwise
// should reject unexpected values.
if ( ! is_numeric( $interval_in_seconds ) || $interval_in_seconds != $interval ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1: provided value 2: provided type. */
esc_html__( 'An integer was expected but "%1$s" (%2$s) was received.', 'woocommerce' ),
esc_html( $interval_in_seconds ),
esc_html( gettype( $interval_in_seconds ) )
),
'3.6.0'
);
return 0;
}
/**
* Provides an opportunity to short-circuit the default process for enqueuing recurring
* actions.
@@ -113,13 +156,25 @@ function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook,
* @param string $hook Action hook.
* @param array $args Action arguments.
* @param string $group Action group.
* @param int $priority Action priority.
*/
$pre = apply_filters( 'pre_as_schedule_recurring_action', null, $timestamp, $interval_in_seconds, $hook, $args, $group );
$pre = apply_filters( 'pre_as_schedule_recurring_action', null, $timestamp, $interval_in_seconds, $hook, $args, $group, $priority );
if ( null !== $pre ) {
return is_int( $pre ) ? $pre : 0;
}
return ActionScheduler::factory()->recurring_unique( $hook, $args, $timestamp, $interval_in_seconds, $group, $unique );
return ActionScheduler::factory()->create(
array(
'type' => 'recurring',
'hook' => $hook,
'arguments' => $args,
'when' => $timestamp,
'pattern' => $interval_in_seconds,
'group' => $group,
'unique' => $unique,
'priority' => $priority,
)
);
}
/**
@@ -143,10 +198,11 @@ function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook,
* @param array $args Arguments to pass when the hook triggers.
* @param string $group The group to assign this job to.
* @param bool $unique Whether the action should be unique.
* @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
*
* @return int The action ID.
*/
function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false ) {
function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) {
if ( ! ActionScheduler::is_initialized( __FUNCTION__ ) ) {
return 0;
}
@@ -166,13 +222,25 @@ function as_schedule_cron_action( $timestamp, $schedule, $hook, $args = array(),
* @param string $hook Action hook.
* @param array $args Action arguments.
* @param string $group Action group.
* @param int $priority Action priority.
*/
$pre = apply_filters( 'pre_as_schedule_cron_action', null, $timestamp, $schedule, $hook, $args, $group );
$pre = apply_filters( 'pre_as_schedule_cron_action', null, $timestamp, $schedule, $hook, $args, $group, $priority );
if ( null !== $pre ) {
return is_int( $pre ) ? $pre : 0;
}
return ActionScheduler::factory()->cron_unique( $hook, $args, $timestamp, $schedule, $group, $unique );
return ActionScheduler::factory()->create(
array(
'type' => 'cron',
'hook' => $hook,
'arguments' => $args,
'when' => $timestamp,
'pattern' => $schedule,
'group' => $group,
'unique' => $unique,
'priority' => $priority,
)
);
}
/**

View File

@@ -1,11 +1,9 @@
=== Action Scheduler ===
Contributors: Automattic, wpmuguru, claudiosanches, peterfabian1000, vedjain, jamosova, obliviousharmony, konamiman, sadowski, royho, barryhughes-1
Tags: scheduler, cron
Requires at least: 5.2
Tested up to: 6.0
Stable tag: 3.5.4
Stable tag: 3.6.2
License: GPLv3
Requires PHP: 5.6
Tested up to: 6.3
Action Scheduler - Job Queue for WordPress
@@ -47,6 +45,44 @@ Collaboration is cool. We'd love to work with you to improve Action Scheduler. [
== Changelog ==
= 3.6.2 - 2023-08-09 =
* Add guidance about passing arguments.
* Atomic option locking.
* Improve bulk delete handling.
* Include database error in the exception message.
* Tweak - WP 6.3 compatibility.
= 3.6.1 - 2023-06-14 =
* Document new optional `$priority` arg for various API functions.
* Document the new `--exclude-groups` WP CLI option.
* Document the new `action_scheduler_init` hook.
* Ensure actions within each claim are executed in the expected order.
* Fix incorrect text domain.
* Remove SHOW TABLES usage when checking if tables exist.
= 3.6.0 - 2023-05-10 =
* Add $unique parameter to function signatures.
* Add a cast-to-int for extra safety before forming new DateTime object.
* Add a hook allowing exceptions for consistently failing recurring actions.
* Add action priorities.
* Add init hook.
* Always raise the time limit.
* Bump minimatch from 3.0.4 to 3.0.8.
* Bump yaml from 2.2.1 to 2.2.2.
* Defensive coding relating to gaps in declared schedule types.
* Do not process an action if it cannot be set to `in-progress`.
* Filter view labels (status names) should be translatable | #919.
* Fix WPCLI progress messages.
* Improve data-store initialization flow.
* Improve error handling across all supported PHP versions.
* Improve logic for flushing the runtime cache.
* Support exclusion of multiple groups.
* Update lint-staged and Node/NPM requirements.
* add CLI clean command.
* add CLI exclude-group filter.
* exclude past-due from list table all filter count.
* throwing an exception if as_schedule_recurring_action interval param is not of type integer.
= 3.5.4 - 2023-01-17 =
* Add pre filters during action registration.
* Async scheduling.

View File

@@ -92,7 +92,6 @@ $fontSizes: (
word-wrap: normal !important;
padding: 0;
position: absolute !important;
width: 1px;
}
@mixin visually-hidden-focus-reveal() {
@@ -123,8 +122,11 @@ $fontSizes: (
vertical-align: baseline;
}
@mixin reset-typography() {
@mixin reset-color() {
color: inherit;
}
@mixin reset-typography() {
font-family: inherit;
font-size: inherit;
font-style: inherit;
@@ -138,6 +140,7 @@ $fontSizes: (
// Reset <h1>, <h2>, etc. styles as if they were text. Useful for elements that must be headings for a11y but don't need those styles.
@mixin text-heading() {
@include reset-box();
@include reset-color();
@include reset-typography();
box-shadow: none;
display: inline;
@@ -148,6 +151,7 @@ $fontSizes: (
// Reset <button> style as if it was text. Useful for elements that must be `<button>` for a11y but don't need those styles.
@mixin text-button() {
@include reset-box();
@include reset-color();
@include reset-typography();
background: transparent;
box-shadow: none;

View File

@@ -8,6 +8,10 @@
.wc-block-grid__product {
margin: 0 0 $gap-large 0;
.wc-block-grid__product-onsale {
position: absolute;
}
}
}

View File

@@ -139,7 +139,8 @@
}
}
}
.wc-block-grid__product-onsale {
.wc-block-grid__product-image .wc-block-grid__product-onsale,
.wc-block-grid .wc-block-grid__product-onsale {
@include font-size(small);
padding: em($gap-smallest) em($gap-small);
display: inline-block;
@@ -152,7 +153,10 @@
text-transform: uppercase;
font-weight: 600;
z-index: 9;
position: relative;
position: absolute;
top: 4px;
right: 4px;
left: auto;
}
// Element spacing.
@@ -336,3 +340,7 @@
.screen-reader-text:focus {
@include visually-hidden-focus-reveal();
}
.wp-block-group.woocommerce.product .up-sells.upsells.products {
max-width: var(--wp--style--global--wide-size);
}

View File

@@ -45,6 +45,33 @@ registerBlockComponent( {
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating-stars',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating-stars" */ './product-elements/rating-stars/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating-counter',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating-counter" */ './product-elements/rating-counter/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-average-rating',
component: lazy( () =>
import(
/* webpackChunkName: "product-average-rating" */ './product-elements/average-rating/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-button',
component: lazy( () =>

View File

@@ -5,6 +5,9 @@ import './product-elements/title';
import './product-elements/price';
import './product-elements/image';
import './product-elements/rating';
import './product-elements/rating-stars';
import './product-elements/rating-counter';
import './product-elements/average-rating';
import './product-elements/button';
import './product-elements/summary';
import './product-elements/sale-badge';

View File

@@ -1,8 +1,14 @@
{
"name": "woocommerce/add-to-cart-form",
"version": "1.0.0",
"title": "Add to Cart form",
"title": "Add to Cart with Options",
"description": "Display a button so the customer can add a product to their cart. Options will also be displayed depending on product type. e.g. quantity, variation.",
"attributes": {
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
}
},
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"usesContext": ["postId"],

View File

@@ -1,23 +1,38 @@
/**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { Button, Disabled, Tooltip } from '@wordpress/components';
import { Skeleton } from '@woocommerce/base-components/skeleton';
import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
export interface Attributes {
className?: string;
isDescendentOfSingleProductBlock: boolean;
}
const Edit = () => {
const Edit = ( props: BlockEditProps< Attributes > ) => {
const { setAttributes } = props;
const blockProps = useBlockProps( {
className: 'wc-block-add-to-cart-form',
} );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
useEffect( () => {
setAttributes( {
isDescendentOfSingleProductBlock,
} );
}, [ setAttributes, isDescendentOfSingleProductBlock ] );
return (
<div { ...blockProps }>
@@ -34,6 +49,7 @@ const Edit = () => {
className={
'wc-block-editor-add-to-cart-form__quantity'
}
readOnly
/>
<Button
variant={ 'primary' }

View File

@@ -9,6 +9,8 @@ import { Icon, button } from '@wordpress/icons';
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
import './editor.scss';
const blockSettings = {
edit,
@@ -30,4 +32,5 @@ registerBlockSingleProductTemplate( {
blockName: metadata.name,
blockMetadata: metadata,
blockSettings,
isAvailableOnPostEditor: true,
} );

View File

@@ -1,7 +1,12 @@
.wp-block-add-to-cart-form {
.woocommerce-Price-amount.amount,
.woocommerce-grouped-product-list-item__price del {
font-size: var(--wp--preset--font-size--large);
}
width: unset;
/**
* This is a base style for the input text element in WooCommerce that prevents inputs from appearing too small.
*
* @link https://github.com/woocommerce/woocommerce/blob/95ca53675f2817753d484583c96ca9ab9f725172/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss#L203-L206
*/
.input-text {
font-size: var(--wp--preset--font-size--small);
padding: 0.9rem 1.1rem;
}
}

View File

@@ -1,14 +1,13 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import {
AddToCartFormContextProvider,
useAddToCartFormContext,
} from '@woocommerce/base-context';
import { useProductDataContext } from '@woocommerce/shared-context';
import { isEmpty } from 'lodash';
import { isEmpty } from '@woocommerce/types';
import { withProductDataContext } from '@woocommerce/shared-hocs';
/**
@@ -85,8 +84,4 @@ const Block = ( { className, showFormElements }: Props ) => {
);
};
Block.propTypes = {
className: PropTypes.string,
};
export default withProductDataContext( Block );

View File

@@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { keyBy } from 'lodash';
import { decodeEntities } from '@wordpress/html-entities';
import {
Dictionary,
@@ -10,6 +9,7 @@ import {
ProductResponseTermItem,
ProductResponseVariationsItem,
} from '@woocommerce/types';
import { keyBy } from '@woocommerce/base-utils';
/**
* Internal dependencies

View File

@@ -0,0 +1,17 @@
{
"name": "woocommerce/product-average-rating",
"version": "1.0.0",
"title": "Product Average Rating (Beta)",
"description": "Display the average rating of a product",
"attributes": {
"textAlign": {
"type": "string"
}
},
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { useProductDataContext } from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { __ } from '@wordpress/i18n';
import { withProductDataContext } from '@woocommerce/shared-hocs';
type ProductAverageRatingProps = {
className?: string;
textAlign?: string;
};
export const Block = ( props: ProductAverageRatingProps ): JSX.Element => {
const { textAlign } = props;
const styleProps = useStyleProps( props );
const { product } = useProductDataContext();
const className = classnames(
styleProps.className,
'wc-block-components-product-average-rating',
{
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
return (
<div className={ className } style={ styleProps.style }>
{ Number( product.average_rating ) > 0
? product.average_rating
: __( 'No ratings', 'woo-gutenberg-products-block' ) }
</div>
);
};
export default withProductDataContext( Block );

View File

@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
export interface BlockAttributes {
textAlign: string;
}
const Edit = ( props: BlockEditProps< BlockAttributes > ): JSX.Element => {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-average-rating',
} );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...attributes } />
</div>
</>
);
};
export default Edit;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starHalf } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starHalf }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );

View File

@@ -0,0 +1,26 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: true,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
__experimentalSkipSerialization: true,
},
typography: {
fontSize: true,
__experimentalFontWeight: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-average-rating',
} ),
};

View File

@@ -1,24 +0,0 @@
/**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
textAlign: {
type: 'string',
default: '',
},
width: {
type: 'number',
},
};
export default blockAttributes;

View File

@@ -0,0 +1,66 @@
{
"name": "woocommerce/product-button",
"version": "1.0.0",
"title": "Add to Cart Button",
"description": "Display a call to action button which either adds the product to the cart, or links to the product page.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"usesContext": [ "query", "queryId", "postId" ],
"textdomain": "woocommerce",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"textAlign": {
"type": "string",
"default": ""
},
"width": {
"type": "number"
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
}
},
"supports": {
"align": [ "wide", "full" ],
"color": {
"background": false,
"link": true
},
"interactivity": true,
"html": false,
"typography": {
"fontSize": true,
"lineHeight": true
}
},
"ancestor": [
"woocommerce/all-products",
"woocommerce/single-product",
"core/post-template",
"woocommerce/product-template"
],
"styles": [
{
"name": "fill",
"label": "Fill",
"isDefault": true
},
{
"name": "outline",
"label": "Outline"
}
],
"viewScript": [
"wc-product-button-interactivity-frontend"
],
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -7,12 +7,7 @@ import {
useStoreEvents,
useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import {
useBorderProps,
useColorProps,
useTypographyProps,
useSpacingProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { CART_URL } from '@woocommerce/block-settings';
import { getSetting } from '@woocommerce/settings';
@@ -35,22 +30,18 @@ import type {
/**
* Product Button Block Component.
*
* @param {Object} props Incoming props.
* @param {Object} [props.product] Product.
* @param {Object} [props.colorStyles] Object contains CSS class and CSS style for color.
* @param {Object} [props.borderStyles] Object contains CSS class and CSS style for border.
* @param {Object} [props.typographyStyles] Object contains CSS class and CSS style for typography.
* @param {Object} [props.spacingStyles] Object contains CSS style for spacing.
* @param {Object} [props.textAlign] Text alignment.
* @param {Object} props Incoming props.
* @param {Object} [props.product] Product.
* @param {Object} [props.style] Object contains CSS Styles.
* @param {string} [props.className] String contains CSS class.
* @param {Object} [props.textAlign] Text alignment.
*
* @return {*} The component.
*/
const AddToCartButton = ( {
product,
colorStyles,
borderStyles,
typographyStyles,
spacingStyles,
className,
style,
textAlign,
}: AddToCartButtonAttributes ): JSX.Element => {
const {
@@ -114,14 +105,15 @@ const AddToCartButton = ( {
return (
<ButtonTag
{ ...buttonProps }
aria-label={ buttonAriaLabel }
disabled={ addingToCart }
className={ classnames(
className,
'wp-block-button__link',
'wp-element-button',
'add_to_cart_button',
'wc-block-components-product-button__button',
colorStyles.className,
borderStyles.className,
{
loading: addingToCart,
added: addedToCart,
@@ -130,14 +122,7 @@ const AddToCartButton = ( {
[ `has-text-align-${ textAlign }` ]: textAlign,
}
) }
style={ {
...colorStyles.style,
...borderStyles.style,
...typographyStyles.style,
...spacingStyles.style,
} }
disabled={ addingToCart }
{ ...buttonProps }
style={ style }
>
{ buttonText }
</ButtonTag>
@@ -147,19 +132,15 @@ const AddToCartButton = ( {
/**
* Product Button Block Component.
*
* @param {Object} props Incoming props.
* @param {Object} [props.colorStyles] Object contains CSS class and CSS style for color.
* @param {Object} [props.borderStyles] Object contains CSS class and CSS style for border.
* @param {Object} [props.typographyStyles] Object contains CSS class and CSS style for typography.
* @param {Object} [props.spacingStyles] Object contains CSS style for spacing.
* @param {Object} props Incoming props.
* @param {Object} [props.style] Object contains CSS Styles.
* @param {string} [props.className] String contains CSS class.
*
* @return {*} The component.
*/
const AddToCartButtonPlaceholder = ( {
colorStyles,
borderStyles,
typographyStyles,
spacingStyles,
className,
style,
}: AddToCartButtonPlaceholderAttributes ): JSX.Element => {
return (
<button
@@ -169,15 +150,9 @@ const AddToCartButtonPlaceholder = ( {
'add_to_cart_button',
'wc-block-components-product-button__button',
'wc-block-components-product-button__button--placeholder',
colorStyles.className,
borderStyles.className
className
) }
style={ {
...colorStyles.style,
...borderStyles.style,
...typographyStyles.style,
...spacingStyles.style,
} }
style={ style }
disabled={ true }
/>
);
@@ -193,12 +168,9 @@ const AddToCartButtonPlaceholder = ( {
*/
export const Block = ( props: BlockAttributes ): JSX.Element => {
const { className, textAlign } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const colorProps = useColorProps( props );
const borderProps = useBorderProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
return (
<div
@@ -218,17 +190,13 @@ export const Block = ( props: BlockAttributes ): JSX.Element => {
{ product.id ? (
<AddToCartButton
product={ product }
colorStyles={ colorProps }
borderStyles={ borderProps }
typographyStyles={ typographyProps }
spacingStyles={ spacingProps }
style={ styleProps.style }
className={ styleProps.className }
/>
) : (
<AddToCartButtonPlaceholder
colorStyles={ colorProps }
borderStyles={ borderProps }
typographyStyles={ typographyProps }
spacingStyles={ spacingProps }
style={ styleProps.style }
className={ styleProps.className }
/>
) }
</div>

View File

@@ -1,19 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, button } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Add to Cart Button',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ button } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display a call to action button which either adds the product to the cart, or links to the product page.',
'woo-gutenberg-products-block'
);
export const BLOCK_NAME = 'woocommerce/product-button';

View File

@@ -0,0 +1,279 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* External dependencies
*/
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { store as interactivityStore } from '@woocommerce/interactivity';
import { dispatch, select, subscribe } from '@wordpress/data';
import { Cart } from '@woocommerce/type-defs/cart';
import { createRoot } from '@wordpress/element';
import NoticeBanner from '@woocommerce/base-components/notice-banner';
type Context = {
woocommerce: {
isLoading: boolean;
addToCartText: string;
productId: number;
displayViewCart: boolean;
quantityToAdd: number;
temporaryNumberOfItems: number;
animationStatus: AnimationStatus;
};
};
enum AnimationStatus {
IDLE = 'IDLE',
SLIDE_OUT = 'SLIDE-OUT',
SLIDE_IN = 'SLIDE-IN',
}
type State = {
woocommerce: {
cart: Cart | undefined;
inTheCartText: string;
};
};
type Store = {
state: State;
context: Context;
selectors: any;
ref: HTMLElement;
};
const storeNoticeClass = '.wc-block-store-notices';
const createNoticeContainer = () => {
const noticeContainer = document.createElement( 'div' );
noticeContainer.classList.add( storeNoticeClass.replace( '.', '' ) );
return noticeContainer;
};
const injectNotice = ( domNode: Element, errorMessage: string ) => {
const root = createRoot( domNode );
root.render(
<NoticeBanner status="error" onRemove={ () => root.unmount() }>
{ errorMessage }
</NoticeBanner>
);
domNode?.scrollIntoView( {
behavior: 'smooth',
inline: 'nearest',
} );
};
const getProductById = ( cartState: Cart | undefined, productId: number ) => {
return cartState?.items.find( ( item ) => item.id === productId );
};
const getTextButton = ( {
addToCartText,
inTheCartText,
numberOfItems,
}: {
addToCartText: string;
inTheCartText: string;
numberOfItems: number;
} ) => {
if ( numberOfItems === 0 ) {
return addToCartText;
}
return inTheCartText.replace( '###', numberOfItems.toString() );
};
const productButtonSelectors = {
woocommerce: {
addToCartText: ( store: Store ) => {
const { context, state, selectors } = store;
// We use the temporary number of items when there's no animation, or the
// second part of the animation hasn't started.
if (
context.woocommerce.animationStatus === AnimationStatus.IDLE ||
context.woocommerce.animationStatus ===
AnimationStatus.SLIDE_OUT
) {
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems: context.woocommerce.temporaryNumberOfItems,
} );
}
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems:
selectors.woocommerce.numberOfItemsInTheCart( store ),
} );
},
displayViewCart: ( store: Store ) => {
const { context, selectors } = store;
if ( ! context.woocommerce.displayViewCart ) return false;
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
return context.woocommerce.temporaryNumberOfItems > 0;
}
return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0;
},
hasCartLoaded: ( { state }: { state: State } ) => {
return state.woocommerce.cart !== undefined;
},
numberOfItemsInTheCart: ( { state, context }: Store ) => {
const product = getProductById(
state.woocommerce.cart,
context.woocommerce.productId
);
return product?.quantity || 0;
},
slideOutAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT,
slideInAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN,
},
};
interactivityStore(
// @ts-expect-error: Store function isn't typed.
{
selectors: productButtonSelectors,
actions: {
woocommerce: {
addToCart: async ( store: Store ) => {
const { context, selectors, ref } = store;
if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) {
return;
}
context.woocommerce.isLoading = true;
// Allow 3rd parties to validate and quit early.
// https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77
const event = new CustomEvent(
'should_send_ajax_request.adding_to_cart',
{ detail: [ ref ], cancelable: true }
);
const shouldSendRequest =
document.body.dispatchEvent( event );
if ( shouldSendRequest === false ) {
const ajaxNotSentEvent = new CustomEvent(
'ajax_request_not_sent.adding_to_cart',
{ detail: [ false, false, ref ] }
);
document.body.dispatchEvent( ajaxNotSentEvent );
return true;
}
try {
await dispatch( storeKey ).addItemToCart(
context.woocommerce.productId,
context.woocommerce.quantityToAdd
);
// After the cart has been updated, sync the temporary number of
// items again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
} catch ( error ) {
const storeNoticeBlock =
document.querySelector( storeNoticeClass );
if ( ! storeNoticeBlock ) {
document
.querySelector( '.entry-content' )
?.prepend( createNoticeContainer() );
}
const domNode =
storeNoticeBlock ??
document.querySelector( storeNoticeClass );
if ( domNode ) {
injectNotice( domNode, error.message );
}
// We don't care about errors blocking execution, but will
// console.error for troubleshooting.
// eslint-disable-next-line no-console
console.error( error );
} finally {
context.woocommerce.displayViewCart = true;
context.woocommerce.isLoading = false;
}
},
handleAnimationEnd: (
store: Store & { event: AnimationEvent }
) => {
const { event, context, selectors } = store;
if ( event.animationName === 'slideOut' ) {
// When the first part of the animation (slide-out) ends, we move
// to the second part (slide-in).
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_IN;
} else if ( event.animationName === 'slideIn' ) {
// When the second part of the animation ends, we update the
// temporary number of items to sync it with the cart and reset the
// animation status so it can be triggered again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
context.woocommerce.animationStatus =
AnimationStatus.IDLE;
}
},
},
},
effects: {
woocommerce: {
startAnimation: ( store: Store ) => {
const { context, selectors } = store;
// We start the animation if the cart has loaded, the temporary number
// of items is out of sync with the number of items in the cart, the
// button is not loading (because that means the user started the
// interaction) and the animation hasn't started yet.
if (
selectors.woocommerce.hasCartLoaded( store ) &&
context.woocommerce.temporaryNumberOfItems !==
selectors.woocommerce.numberOfItemsInTheCart(
store
) &&
! context.woocommerce.isLoading &&
context.woocommerce.animationStatus ===
AnimationStatus.IDLE
) {
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_OUT;
}
},
},
},
},
{
afterLoad: ( store: Store ) => {
const { state, selectors } = store;
// Subscribe to changes in Cart data.
subscribe( () => {
const cartData = select( storeKey ).getCartData();
const isResolutionFinished =
select( storeKey ).hasFinishedResolution( 'getCartData' );
if ( isResolutionFinished ) {
state.woocommerce.cart = cartData;
}
}, storeKey );
// This selector triggers a fetch of the Cart data. It is done in a
// `requestIdleCallback` to avoid potential performance issues.
requestIdleCallback( () => {
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
select( storeKey ).getCartData();
}
} );
},
}
);

View File

@@ -1,52 +0,0 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { supports } from './supports';
import attributes from './attributes';
import sharedConfig from '../shared/config';
import edit from './edit';
import save from './save';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
BLOCK_NAME,
} from './constants';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
],
usesContext: [ 'query', 'queryId', 'postId' ],
icon: { src: icon },
attributes,
supports,
edit,
save,
styles: [
{
name: 'fill',
label: __( 'Fill', 'woo-gutenberg-products-block' ),
isDefault: true,
},
{
name: 'outline',
label: __( 'Outline', 'woo-gutenberg-products-block' ),
},
],
};
registerBlockType( BLOCK_NAME, { ...blockConfig } );

View File

@@ -1,11 +1,19 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { Icon, button } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
export const supports = {
const featurePluginSupport = {
...metadata.supports,
...( isFeaturePluginBuild() && {
color: {
text: true,
@@ -47,3 +55,22 @@ export const supports = {
},
} ),
};
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ button }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
},
supports: {
...featurePluginSupport,
},
edit,
save,
} );

View File

@@ -14,7 +14,10 @@ type Props = {
};
const Save = ( { attributes }: Props ): JSX.Element | null => {
if ( attributes.isDescendentOfQueryLoop ) {
if (
attributes.isDescendentOfQueryLoop ||
attributes.isDescendentOfSingleProductBlock
) {
return null;
}

View File

@@ -1,17 +1,79 @@
.wp-block-button.wc-block-components-product-button {
word-break: break-word;
white-space: normal;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
gap: $gap-small;
.wp-block-button__link {
word-break: break-word;
white-space: normal;
display: inline-flex;
justify-content: center;
text-align: center;
// Set button font size and padding so it inherits from parent.
padding: 0.5em 1em;
font-size: 1em;
&.loading {
opacity: 0.25;
}
&.loading::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e031";
animation: spin 2s linear infinite;
margin-left: 0.5em;
display: inline-block;
width: auto;
height: auto;
}
}
a[hidden] {
display: none;
}
@keyframes slideOut {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
@keyframes slideIn {
from {
transform: translateY(90%);
opacity: 0;
}
to {
transform: translate(0);
opacity: 1;
}
}
.wc-block-components-product-button__button {
border-style: none;
display: inline-flex;
font-family: inherit;
font-weight: inherit;
justify-content: center;
margin-right: auto;
margin-left: auto;
white-space: normal;
word-break: break-word;
width: 150px;
overflow: hidden;
span {
&.wc-block-slide-out {
animation: slideOut 0.1s linear 1 normal forwards;
}
&.wc-block-slide-in {
animation: slideIn 0.1s linear 1 normal;
}
}
}
.wc-block-components-product-button__button--placeholder {

View File

@@ -10,14 +10,13 @@ export interface BlockAttributes {
className?: string | undefined;
textAlign?: string | undefined;
isDescendentOfQueryLoop?: boolean | undefined;
isDescendentOfSingleProductBlock?: boolean | undefined;
width?: number | undefined;
}
export interface AddToCartButtonPlaceholderAttributes {
borderStyles: WithClass & WithStyle;
colorStyles: WithClass & WithStyle;
spacingStyles: WithStyle;
typographyStyles: WithStyle;
className: string;
style: React.CSSProperties;
}
export interface AddToCartButtonAttributes

View File

@@ -3,6 +3,11 @@
*/
import type { BlockAttributes } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { ImageSizing } from './types';
export const blockAttributes: BlockAttributes = {
showProductLink: {
type: 'boolean',
@@ -18,7 +23,7 @@ export const blockAttributes: BlockAttributes = {
},
imageSizing: {
type: 'string',
default: 'full-size',
default: ImageSizing.SINGLE,
},
productId: {
type: 'number',
@@ -32,6 +37,16 @@ export const blockAttributes: BlockAttributes = {
type: 'boolean',
default: false,
},
width: {
type: 'string',
},
height: {
type: 'string',
},
scale: {
type: 'string',
default: 'cover',
},
};
export default blockAttributes;

View File

@@ -9,11 +9,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useBorderProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import type { HTMLAttributes } from 'react';
@@ -23,11 +19,12 @@ import type { HTMLAttributes } from 'react';
*/
import ProductSaleBadge from '../sale-badge/block';
import './style.scss';
import type { BlockAttributes } from './types';
import { BlockAttributes, ImageSizing } from './types';
const ImagePlaceholder = (): JSX.Element => {
const ImagePlaceholder = ( props ): JSX.Element => {
return (
<img
{ ...props }
src={ PLACEHOLDER_IMG_SRC }
alt=""
width={ undefined }
@@ -49,6 +46,9 @@ interface ImageProps {
loaded: boolean;
showFullSize: boolean;
fallbackAlt: string;
scale: string;
width?: string | undefined;
height?: string | undefined;
}
const Image = ( {
@@ -56,6 +56,9 @@ const Image = ( {
loaded,
showFullSize,
fallbackAlt,
width,
scale,
height,
}: ImageProps ): JSX.Element => {
const { thumbnail, src, srcset, sizes, alt } = image || {};
const imageProps = {
@@ -65,13 +68,23 @@ const Image = ( {
...( showFullSize && { src, srcSet: srcset, sizes } ),
};
const imageStyles: Record< string, string | undefined > = {
height,
width,
objectFit: scale,
};
return (
<>
{ imageProps.src && (
/* eslint-disable-next-line jsx-a11y/alt-text */
<img data-testid="product-image" { ...imageProps } />
<img
style={ imageStyles }
data-testid="product-image"
{ ...imageProps }
/>
) }
{ ! image && <ImagePlaceholder /> }
{ ! image && <ImagePlaceholder style={ imageStyles } /> }
</>
);
};
@@ -81,17 +94,19 @@ type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const {
className,
imageSizing = 'full-size',
imageSizing = ImageSizing.SINGLE,
showProductLink = true,
showSaleBadge,
saleBadgeAlign = 'right',
height,
width,
scale,
...restProps
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product, isLoading } = useProductDataContext();
const { dispatchStoreEvent } = useStoreEvents();
const typographyProps = useTypographyProps( props );
const borderProps = useBorderProps( props );
const spacingProps = useSpacingProps( props );
if ( ! product.id ) {
return (
@@ -103,13 +118,8 @@ export const Block = ( props: Props ): JSX.Element | null => {
[ `${ parentClassName }__product-image` ]:
parentClassName,
},
borderProps.className
styleProps.className
) }
style={ {
...typographyProps.style,
...borderProps.style,
...spacingProps.style,
} }
>
<ImagePlaceholder />
</div>
@@ -141,26 +151,24 @@ export const Block = ( props: Props ): JSX.Element | null => {
{
[ `${ parentClassName }__product-image` ]: parentClassName,
},
borderProps.className
styleProps.className
) }
style={ {
...typographyProps.style,
...borderProps.style,
...spacingProps.style,
} }
>
<ParentComponent { ...( showProductLink && anchorProps ) }>
{ !! showSaleBadge && (
<ProductSaleBadge
align={ saleBadgeAlign }
product={ product }
{ ...restProps }
/>
) }
<Image
fallbackAlt={ product.name }
image={ image }
loaded={ ! isLoading }
showFullSize={ imageSizing !== 'cropped' }
showFullSize={ imageSizing !== ImageSizing.THUMBNAIL }
width={ width }
height={ height }
scale={ scale }
/>
</ParentComponent>
</div>

View File

@@ -32,22 +32,29 @@ import {
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import type { BlockAttributes } from './types';
import { BlockAttributes, ImageSizing } from './types';
import { ImageSizeSettings } from './image-size-settings';
type SaleBadgeAlignProps = 'left' | 'center' | 'right';
type ImageSizingProps = 'full-size' | 'cropped';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const { showProductLink, imageSizing, showSaleBadge, saleBadgeAlign } =
attributes;
const blockProps = useBlockProps();
const {
showProductLink,
imageSizing,
showSaleBadge,
saleBadgeAlign,
width,
height,
scale,
} = attributes;
const blockProps = useBlockProps( { style: { width, height } } );
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const isBlockThemeEnabled = getSettingWithCoercion(
'is_block_theme_enabled',
'isBlockThemeEnabled',
false,
isBoolean
);
@@ -57,15 +64,15 @@ const Edit = ( {
[ setAttributes, isDescendentOfQueryLoop ]
);
useEffect( () => {
if ( isBlockThemeEnabled && attributes.imageSizing !== 'full-size' ) {
setAttributes( { imageSizing: 'full-size' } );
}
}, [ attributes.imageSizing, isBlockThemeEnabled, setAttributes ] );
return (
<div { ...blockProps }>
<InspectorControls>
<ImageSizeSettings
scale={ scale }
width={ width }
height={ height }
setAttributes={ setAttributes }
/>
<PanelBody
title={ __( 'Content', 'woo-gutenberg-products-block' ) }
>
@@ -160,19 +167,19 @@ const Edit = ( {
}
) }
value={ imageSizing }
onChange={ ( value: ImageSizingProps ) =>
onChange={ ( value: ImageSizing ) =>
setAttributes( { imageSizing: value } )
}
>
<ToggleGroupControlOption
value="full-size"
value={ ImageSizing.SINGLE }
label={ __(
'Full Size',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="cropped"
value={ ImageSizing.THUMBNAIL }
label={ __(
'Cropped',
'woo-gutenberg-products-block'

View File

@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { BlockAttributes } from '@wordpress/blocks';
import {
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanel as ToolsPanel,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanelItem as ToolsPanelItem,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUnitControl as UnitControl,
} from '@wordpress/components';
interface ImageSizeSettingProps {
scale: string;
width: string | undefined;
height: string | undefined;
setAttributes: ( attrs: BlockAttributes ) => void;
}
const scaleHelp: Record< string, string > = {
cover: __(
'Image is scaled and cropped to fill the entire space without being distorted.',
'woo-gutenberg-products-block'
),
contain: __(
'Image is scaled to fill the space without clipping nor distorting.',
'woo-gutenberg-products-block'
),
fill: __(
'Image will be stretched and distorted to completely fill the space.',
'woo-gutenberg-products-block'
),
};
export const ImageSizeSettings = ( {
scale,
width,
height,
setAttributes,
}: ImageSizeSettingProps ) => {
return (
<ToolsPanel
className="wc-block-product-image__tools-panel"
label={ __( 'Image size', 'woo-gutenberg-products-block' ) }
>
<UnitControl
label={ __( 'Height', 'woo-gutenberg-products-block' ) }
onChange={ ( value: string ) => {
setAttributes( { height: value } );
} }
value={ height }
units={ [
{
value: 'px',
label: 'px',
},
] }
/>
<UnitControl
label={ __( 'Width', 'woo-gutenberg-products-block' ) }
onChange={ ( value: string ) => {
setAttributes( { width: value } );
} }
value={ width }
units={ [
{
value: 'px',
label: 'px',
},
] }
/>
{ height && (
<ToolsPanelItem
hasValue={ () => true }
label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
>
<ToggleGroupControl
label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
value={ scale }
help={ scaleHelp[ scale ] }
onChange={ ( value: string ) =>
setAttributes( {
scale: value,
} )
}
isBlock
>
<>
<ToggleGroupControlOption
value="cover"
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="contain"
label={ __(
'Contain',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="fill"
label={ __(
'Fill',
'woo-gutenberg-products-block'
) }
/>
</>
</ToggleGroupControl>
</ToolsPanelItem>
) }
</ToolsPanel>
);
};

View File

@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
textdomain: 'woo-gutenberg-products-block',
attributes,

View File

@@ -16,6 +16,7 @@
border-radius: inherit;
vertical-align: middle;
width: 100%;
height: auto;
&[hidden] {
display: none;
@@ -60,3 +61,7 @@
.wc-block-components-product-image {
margin: 0 0 $gap-small;
}
.wc-block-product-image__tools-panel .components-input-control {
margin-bottom: 8px;
}

View File

@@ -24,7 +24,6 @@ export const supports = {
spacing: {
margin: true,
padding: true,
__experimentalSkipSerialization: true,
},
} ),
__experimentalSelector: '.wc-block-components-product-image',

View File

@@ -9,19 +9,14 @@ import { ProductResponseItem } from '@woocommerce/types';
* Internal dependencies
*/
import { Block } from '../block';
import { ImageSizing } from '../types';
jest.mock( '@woocommerce/base-hooks', () => ( {
__esModule: true,
useBorderProps: jest.fn( () => ( {
useStyleProps: jest.fn( () => ( {
className: '',
style: {},
} ) ),
useTypographyProps: jest.fn( () => ( {
style: {},
} ) ),
useSpacingProps: jest.fn( () => ( {
style: {},
} ) ),
} ) );
const productWithoutImages: ProductResponseItem = {
@@ -152,7 +147,7 @@ describe( 'Product Image Block', () => {
productId={ productWithImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -186,7 +181,7 @@ describe( 'Product Image Block', () => {
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -219,7 +214,7 @@ describe( 'Product Image Block', () => {
productId={ productWithImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -249,7 +244,7 @@ describe( 'Product Image Block', () => {
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
@@ -277,7 +272,7 @@ describe( 'Product Image Block', () => {
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ 'full-size' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>

View File

@@ -1,3 +1,8 @@
export enum ImageSizing {
SINGLE = 'single',
THUMBNAIL = 'thumbnail',
}
export interface BlockAttributes {
// The product ID.
productId: number;
@@ -10,7 +15,13 @@ export interface BlockAttributes {
// How should the sale badge be aligned if displayed.
saleBadgeAlign: 'left' | 'center' | 'right';
// Size of image to use.
imageSizing: 'full-size' | 'cropped';
imageSizing: ImageSizing;
// Whether or not be a children of Query Loop Block.
isDescendentOfQueryLoop: boolean;
// Height of the image.
height?: string;
// Width of the image.
width?: string;
// Image scaling method.
scale: 'cover' | 'contain' | 'fill';
}

View File

@@ -8,11 +8,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { CurrencyCode } from '@woocommerce/type-defs/currency';
import type { HTMLAttributes } from 'react';
@@ -21,7 +17,6 @@ import type { HTMLAttributes } from 'react';
* Internal dependencies
*/
import type { BlockAttributes } from './types';
import './style.scss';
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
@@ -41,36 +36,36 @@ interface PriceProps {
export const Block = ( props: Props ): JSX.Element | null => {
const { className, textAlign, isDescendentOfSingleProductTemplate } = props;
const { parentClassName } = useInnerBlockLayoutContext();
const styleProps = useStyleProps( props );
const { parentName, parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const colorProps = useColorProps( props );
const spacingProps = useSpacingProps( props );
const typographyProps = useTypographyProps( props );
const isDescendentOfAllProductsBlock =
parentName === 'woocommerce/all-products';
const wrapperClassName = classnames(
'wc-block-components-product-price',
className,
colorProps.className,
styleProps.className,
{
[ `${ parentClassName }__product-price` ]: parentClassName,
},
typographyProps.className
}
);
if ( ! product.id && ! isDescendentOfSingleProductTemplate ) {
return (
const productPriceComponent = (
<ProductPrice align={ textAlign } className={ wrapperClassName } />
);
if ( isDescendentOfAllProductsBlock ) {
return (
<div className="wp-block-woocommerce-product-price">
{ productPriceComponent }
</div>
);
}
return productPriceComponent;
}
const style = {
...colorProps.style,
...typographyProps.style,
};
const spacingStyle = {
...spacingProps.style,
};
const prices: PriceProps = product.prices;
const currency = isDescendentOfSingleProductTemplate
? getCurrencyFromPriceResponse()
@@ -83,12 +78,13 @@ export const Block = ( props: Props ): JSX.Element | null => {
[ `${ parentClassName }__product-price__value--on-sale` ]: isOnSale,
} );
return (
const productPriceComponent = (
<ProductPrice
align={ textAlign }
className={ wrapperClassName }
regularPriceStyle={ style }
priceStyle={ style }
style={ styleProps.style }
regularPriceStyle={ styleProps.style }
priceStyle={ styleProps.style }
priceClassName={ priceClassName }
currency={ currency }
price={
@@ -109,9 +105,16 @@ export const Block = ( props: Props ): JSX.Element | null => {
[ `${ parentClassName }__product-price__regular` ]:
parentClassName,
} ) }
spacingStyle={ spacingStyle }
/>
);
if ( isDescendentOfAllProductsBlock ) {
return (
<div className="wp-block-woocommerce-product-price">
{ productPriceComponent }
</div>
);
}
return productPriceComponent;
};
export default ( props: Props ) => {

View File

@@ -8,12 +8,12 @@ import {
} from '@wordpress/block-editor';
import { useEffect } from '@wordpress/element';
import type { BlockAlignment } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import Block from './block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
type UnsupportedAligments = 'wide' | 'full';
type AllowedAlignments = Exclude< BlockAlignment, UnsupportedAligments >;
@@ -53,18 +53,12 @@ const PriceEdit = ( {
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const isDescendentOfSingleProductTemplate = useSelect(
( select ) => {
const store = select( 'core/edit-site' );
const postId = store?.getEditedPostId< string | undefined >();
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate( { isDescendentOfQueryLoop } );
return (
postId?.includes( '//single-product' ) &&
! isDescendentOfQueryLoop
);
},
[ isDescendentOfQueryLoop ]
);
if ( isDescendentOfQueryLoop ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect(
() =>

View File

@@ -1,7 +0,0 @@
.wc-block-components-product-price {
display: block;
.wc-block-all-products & {
margin-bottom: $gap-small;
}
}

View File

@@ -27,7 +27,8 @@ export const supports = {
__experimentalSkipSerialization: true,
__experimentalLetterSpacing: true,
},
__experimentalSelector: '.wc-block-components-product-price',
__experimentalSelector:
'.wp-block-woocommerce-product-price .wc-block-components-product-price',
} ),
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {

View File

@@ -3,11 +3,14 @@
"version": "1.0.0",
"icon": "info",
"title": "Product Details",
"description": "A block that allows your customers to see details and reviews about the product.",
"description": "Display a product's description, attributes, and reviews.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
"align": true,
"spacing": {
"margin": true
}
},
"textdomain": "woocommerce",
"apiVersion": 2,

View File

@@ -5,10 +5,6 @@ import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
interface SingleProductTab {
id: string;
title: string;

View File

@@ -2,19 +2,30 @@
* External dependencies
*/
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productDetails } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
icon: {
src: (
<Icon
icon={ productDetails }
className="wc-block-editor-components-block-icon"
/>
),
},
edit,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
icon,
// @ts-expect-error `edit` can be extended to include other attributes
edit,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -1,5 +1,8 @@
.woocommerce .wp-block-woocommerce-product-image-gallery {
position: relative;
// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
clear: both;
max-width: 512px;
span.onsale {
right: unset;
@@ -8,6 +11,14 @@
}
}
.woocommerce .wp-block-woocommerce-product-image-gallery .woocommerce-product-gallery.images {
width: initial;
// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
.woocommerce .wp-block-woocommerce-product-image-gallery::after {
clear: both;
content: "";
display: table;
}
.woocommerce .wp-block-woocommerce-product-image-gallery .woocommerce-product-gallery.images {
width: 100%;
}

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"title": "Product Meta",
"icon": "product",
"description": "Display Product Meta",
"description": "Display a products SKU, categories, tags, and more.",
"category": "woocommerce",
"supports": {
"align": true,

View File

@@ -8,8 +8,12 @@ import { InnerBlockTemplate } from '@wordpress/blocks';
* Internal dependencies
*/
import './editor.scss';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = () => {
const isDescendentOfSingleProductTemplate =
useIsDescendentOfSingleProductTemplate();
const TEMPLATE: InnerBlockTemplate[] = [
[
'core/group',
@@ -18,7 +22,7 @@ const Edit = () => {
[
'woocommerce/product-sku',
{
isDescendentOfSingleProductTemplate: true,
isDescendentOfSingleProductTemplate,
},
],
[

View File

@@ -1,13 +1,15 @@
/**
* External dependencies
*/
import { box as icon } from '@wordpress/icons';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productMeta } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockSingleProductTemplate( {
@@ -16,7 +18,16 @@ registerBlockSingleProductTemplate( {
blockMetadata: metadata,
blockSettings: {
edit,
icon,
save,
icon: {
src: (
<Icon
icon={ productMeta }
className="wc-block-editor-components-block-icon"
/>
),
},
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: true,
} );

View File

@@ -9,10 +9,6 @@ import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Notice } from '@wordpress/components';
/**
* Internal dependencies
*/
export const ProductReviews = () => {
const blockProps = useBlockProps();

View File

@@ -8,6 +8,7 @@ import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
@@ -15,6 +16,6 @@ registerBlockSingleProductTemplate( {
blockMetadata: metadata,
blockSettings: {
edit,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -0,0 +1,38 @@
{
"name": "woocommerce/product-rating-counter",
"version": "1.0.0",
"title": "Product Rating Counter",
"description": "Display the review count of a product",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
import { Disabled } from '@wordpress/components';
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
const reviewsCount = reviews
? sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woo-gutenberg-products-block'
),
reviews
)
: __( '(X customer reviews)', 'woo-gutenberg-products-block' );
return (
<span className="wc-block-components-product-rating-counter__reviews_count">
<Disabled>
<a href="/">{ reviewsCount }</a>
</Disabled>
</span>
);
};
type ProductRatingCounterProps = {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};
export const Block = (
props: ProductRatingCounterProps
): JSX.Element | undefined => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating-counter',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-counter__container">
<ReviewsCount reviews={ reviews } />
</div>
</div>
);
}
};
export default withProductDataContext( Block );

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating-counter',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starFilled }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );

View File

@@ -0,0 +1,24 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: false,
background: false,
link: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating-counter',
} ),
};

View File

@@ -0,0 +1,7 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}

View File

@@ -0,0 +1,38 @@
{
"name": "woocommerce/product-rating-stars",
"version": "1.0.0",
"title": "Product Rating Stars",
"description": "Display the average rating of a product with stars",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -0,0 +1,158 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import './style.scss';
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ classnames(
'wc-block-components-product-rating-stars__norating-container',
`${ parentClassName }-product-rating-stars__norating-container`
) }
>
<div
className={
'wc-block-components-product-rating-stars__norating'
}
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woo-gutenberg-products-block'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ classnames(
'wc-block-components-product-rating-stars__stars',
`${ parentClassName }__product-rating-stars__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
interface ProductRatingStarsProps {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
}
export const Block = ( props: ProductRatingStarsProps ): JSX.Element | null => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating-stars',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-stars__container">
{ content }
</div>
</div>
);
};
export default withProductDataContext( Block );

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starFilled }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );

View File

@@ -0,0 +1,107 @@
.wc-block-components-product-rating-stars {
display: block;
line-height: 1;
&__stars {
display: inline-block;
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
text-align: left;
&::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
opacity: 0.5;
color: inherit;
white-space: nowrap;
}
span {
overflow: hidden;
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
padding-top: 1.5em;
}
span::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
white-space: nowrap;
}
}
&__link {
display: inline-block;
height: 1.618em;
width: 100%;
text-align: inherit;
@include font-size(small);
}
.wc-block-all-products & {
margin-top: 0;
margin-bottom: $gap-small;
}
&__norating-container {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: $gap-smaller;
}
&__norating {
display: inline-block;
overflow: hidden;
position: relative;
width: 1.5em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
-webkit-text-stroke: 2px var(--wp--preset--color--black, #000);
&::before {
content: "\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: transparent;
white-space: nowrap;
text-align: center;
}
}
}
.wp-block-woocommerce-single-product {
.wc-block-components-product-rating__stars {
margin: 0;
}
}
.wc-block-all-products,
.wp-block-query {
.is-loading {
.wc-block-components-product-rating {
@include placeholder();
width: 7em;
}
}
}

View File

@@ -0,0 +1,25 @@
/* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: false,
link: false,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating',
} ),
};

View File

@@ -0,0 +1,7 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}

View File

@@ -1,25 +0,0 @@
/**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
textAlign: {
type: 'string',
default: '',
},
isDescendentOfSingleProductBlock: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;

View File

@@ -0,0 +1,38 @@
{
"name": "woocommerce/product-rating",
"version": "1.0.0",
"icon": "info",
"title": "Product Rating",
"description": "Display the average rating of a product.",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@@ -7,11 +7,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
@@ -20,21 +16,12 @@ import { isNumber, ProductResponseItem } from '@woocommerce/types';
*/
import './style.scss';
type Props = {
textAlign?: string;
className?: string;
};
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
type AddReviewProps = {
href?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
@@ -45,11 +32,6 @@ const getAverageRating = (
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getReviewsHref = ( product: ProductResponseItem ) => {
const { permalink } = product;
return `${ permalink }#reviews`;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
@@ -58,12 +40,35 @@ const getRatingCount = ( product: ProductResponseItem ) => {
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ classnames(
'wc-block-components-product-rating__norating-container',
`${ parentClassName }-product-rating__norating-container`
) }
>
<div
className={ 'wc-block-components-product-rating__norating' }
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = {
width: ( rating / 5 ) * 100 + '%',
};
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
@@ -98,45 +103,60 @@ const Rating = ( props: RatingProps ): JSX.Element => {
);
};
const AddReview = ( props: AddReviewProps ): JSX.Element | null => {
const { href } = props;
const label = __( 'Add review', 'woo-gutenberg-products-block' );
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
return href ? (
<a className="wc-block-components-product-rating__link" href={ href }>
{ label }
</a>
) : null;
const reviewsCount = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woo-gutenberg-products-block'
),
reviews
);
return (
<span className="wc-block-components-product-rating__reviews_count">
{ reviewsCount }
</span>
);
};
/**
* Product Rating Block Component.
*
* @param {Object} props Incoming props.
* @param {string} [props.className] CSS Class name for the component.
* @param {string} [props.textAlign] Text alignment.
*
* @return {*} The component.
*/
export const Block = ( props: Props ): JSX.Element | null => {
const { textAlign } = props;
type ProductRatingProps = {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};
export const Block = ( props: ProductRatingProps ): JSX.Element | undefined => {
const {
textAlign,
isDescendentOfSingleProductBlock,
shouldDisplayMockedReviewsWhenProductHasNoReviews,
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
const reviews = getRatingCount( product );
const href = getReviewsHref( product );
const className = classnames(
colorProps.className,
styleProps.className,
'wc-block-components-product-rating',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
@@ -145,21 +165,21 @@ export const Block = ( props: Props ): JSX.Element | null => {
parentClassName={ parentClassName }
/>
) : (
<AddReview href={ href } />
mockedRatings
);
return (
<div
className={ className }
style={ {
...colorProps.style,
...typographyProps.style,
...spacingProps.style,
} }
>
{ content }
</div>
);
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating__container">
{ content }
{ reviews && isDescendentOfSingleProductBlock ? (
<ReviewsCount reviews={ reviews } />
) : null }
</div>
</div>
);
}
};
export default withProductDataContext( Block );

View File

@@ -1,7 +1,6 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
@@ -15,29 +14,47 @@ import { ProductQueryContext as Context } from '@woocommerce/blocks/product-quer
* Internal dependencies
*/
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import { BLOCK_TITLE, BLOCK_ICON } from './constants';
import { BlockAttributes } from './types';
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
);
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
@@ -55,11 +72,5 @@ const Edit = ( {
</>
);
};
export default withProductSelector( {
icon: BLOCK_ICON,
label: BLOCK_TITLE,
description: __(
'Choose a product to display its rating.',
'woo-gutenberg-products-block'
),
} )( Edit );
export default Edit;

View File

@@ -1,37 +1,34 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import { BLOCK_ICON as icon } from './constants';
import metadata from './block.json';
import { supports } from './support';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
usesContext: [ 'query', 'queryId', 'postId' ],
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
icon: { src: icon },
attributes,
supports,
edit,
};
registerBlockType( 'woocommerce/product-rating', { ...blockConfig } );
registerBlockSingleProductTemplate( {
blockName: 'woocommerce/product-rating',
blockMetadata: metadata,
blockSettings: blockConfig,
isAvailableOnPostEditor: true,
} );

View File

@@ -1,75 +1,12 @@
.wc-block-components-product-rating {
display: block;
line-height: 1;
&__stars {
display: inline-block;
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
&::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
opacity: 0.5;
color: inherit;
white-space: nowrap;
}
span {
overflow: hidden;
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
padding-top: 1.5em;
}
span::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
white-space: nowrap;
.wc-block-components-product-rating__container {
> * {
vertical-align: middle;
}
}
&__link {
display: inline-block;
height: 1.618em;
width: 100%;
text-align: inherit;
@include font-size(small);
}
.wc-block-all-products & {
margin-top: 0;
margin-bottom: $gap-small;
}
}
.wc-block-single-product {
.wc-block-components-product-rating__stars {
display: inline-block;
margin: 0;
}
}
.wc-block-all-products,
.wp-block-query {
.is-loading {
.wc-block-components-product-rating {
@include placeholder();
width: 7em;
}
}
}

View File

@@ -1,5 +1,7 @@
export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}

View File

@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
icon,
edit,
save,
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: false,
} );

View File

@@ -8,12 +8,7 @@ import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import {
useBorderProps,
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
@@ -27,12 +22,9 @@ type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const { className, align } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const borderProps = useBorderProps( props );
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
if ( ! product.id || ! product.on_sale ) {
return null;
@@ -52,16 +44,9 @@ export const Block = ( props: Props ): JSX.Element | null => {
{
[ `${ parentClassName }__product-onsale` ]: parentClassName,
},
colorProps.className,
borderProps.className,
typographyProps.className
styleProps.className
) }
style={ {
...colorProps.style,
...borderProps.style,
...typographyProps.style,
...spacingProps.style,
} }
style={ styleProps.style }
>
<Label
label={ __( 'Sale', 'woo-gutenberg-products-block' ) }

View File

@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
};

View File

@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
interface UseIsDescendentOfSingleProductBlockProps {
blockClientId: string;
}
export const useIsDescendentOfSingleProductBlock = ( {
blockClientId,
}: UseIsDescendentOfSingleProductBlockProps ) => {
const { isDescendentOfSingleProductBlock } = useSelect(
( select ) => {
const { getBlockParentsByBlockName } =
select( 'core/block-editor' );
const blockParentBlocksIds = getBlockParentsByBlockName(
blockClientId?.replace( 'block-', '' ),
[ 'woocommerce/single-product' ]
);
return {
isDescendentOfSingleProductBlock:
blockParentBlocksIds.length > 0,
};
},
[ blockClientId ]
);
return { isDescendentOfSingleProductBlock };
};

View File

@@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
export const useIsDescendentOfSingleProductTemplate = () => {
const isDescendentOfSingleProductTemplate = useSelect( ( select ) => {
const store = select( 'core/edit-site' );
const postId = store?.getEditedPostId< string | undefined >();
return Boolean( postId?.includes( '//single-product' ) );
}, [] );
return { isDescendentOfSingleProductTemplate };
};

View File

@@ -9,11 +9,7 @@ import {
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
import {
useColorProps,
useSpacingProps,
useTypographyProps,
} from '@woocommerce/base-hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
/**
* Internal dependencies
@@ -47,14 +43,11 @@ const Preview = ( {
const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const sku = product.sku;
const colorProps = useColorProps( props );
const typographyProps = useTypographyProps( props );
const spacingProps = useSpacingProps( props );
if ( props.isDescendentOfSingleProductTemplate ) {
return (
<Preview
@@ -78,16 +71,10 @@ const Block = ( props: Props ): JSX.Element | null => {
className: classnames(
className,
'wc-block-components-product-sku wp-block-woocommerce-product-sku',
{
[ colorProps.className ]: colorProps.className,
[ typographyProps.className ]:
typographyProps.className,
}
styleProps.className
),
style: {
...colorProps.style,
...typographyProps.style,
...spacingProps.style,
...styleProps.style,
},
} ) }
/>

View File

@@ -12,6 +12,8 @@ import { useEffect } from '@wordpress/element';
*/
import Block from './block';
import type { Attributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = ( {
attributes,
@@ -27,10 +29,29 @@ const Edit = ( {
...context,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( { blockClientId: blockProps.id } );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
() =>
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
isDescendentOfSingleProductBlock,
} ),
[
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
isDescendentOfSingleProductBlock,
]
);
return (

View File

@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
'woocommerce/product-meta',
],
edit,

View File

@@ -2,6 +2,7 @@ export interface Attributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductTemplate: boolean;
isDescendentOfSingleProductBlock: boolean;
showProductSelector: boolean;
isDescendantOfAllProducts: boolean;
}

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