rebase on oct-10-2023
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
.wc-block-grid__product {
|
||||
margin: 0 0 $gap-large 0;
|
||||
|
||||
.wc-block-grid__product-onsale {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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( () =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
} );
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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 } );
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -14,7 +14,10 @@ type Props = {
|
||||
};
|
||||
|
||||
const Save = ( { attributes }: Props ): JSX.Element | null => {
|
||||
if ( attributes.isDescendentOfQueryLoop ) {
|
||||
if (
|
||||
attributes.isDescendentOfQueryLoop ||
|
||||
attributes.isDescendentOfSingleProductBlock
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export const supports = {
|
||||
spacing: {
|
||||
margin: true,
|
||||
padding: true,
|
||||
__experimentalSkipSerialization: true,
|
||||
},
|
||||
} ),
|
||||
__experimentalSelector: '.wc-block-components-product-image',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 ) => {
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.wc-block-components-product-price {
|
||||
display: block;
|
||||
|
||||
.wc-block-all-products & {
|
||||
margin-bottom: $gap-small;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
|
||||
icon,
|
||||
// @ts-expect-error `edit` can be extended to include other attributes
|
||||
edit,
|
||||
ancestor: [ 'woocommerce/single-product' ],
|
||||
},
|
||||
isAvailableOnPostEditor: false,
|
||||
} );
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"title": "Product Meta",
|
||||
"icon": "product",
|
||||
"description": "Display Product Meta",
|
||||
"description": "Display a product’s SKU, categories, tags, and more.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": true,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
textAlign: string;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
} ),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
textAlign: string;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface BlockAttributes {
|
||||
productId: number;
|
||||
isDescendentOfQueryLoop: boolean;
|
||||
isDescendentOfSingleProductBlock: boolean;
|
||||
isDescendentOfSingleProductTemplate: boolean;
|
||||
textAlign: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ registerBlockSingleProductTemplate( {
|
||||
icon,
|
||||
edit,
|
||||
save,
|
||||
ancestor: [ 'woocommerce/single-product' ],
|
||||
},
|
||||
isAvailableOnPostEditor: false,
|
||||
} );
|
||||
|
||||
@@ -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' ) }
|
||||
|
||||
@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
|
||||
'woocommerce/all-products',
|
||||
'woocommerce/single-product',
|
||||
'core/post-template',
|
||||
'woocommerce/product-template',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
} ) }
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -31,6 +31,7 @@ const blockConfig: BlockConfiguration = {
|
||||
'woocommerce/all-products',
|
||||
'woocommerce/single-product',
|
||||
'core/post-template',
|
||||
'woocommerce/product-template',
|
||||
'woocommerce/product-meta',
|
||||
],
|
||||
edit,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user