ClassicVariationGalleryAdmin.php
1 month ago
LegacyVariationGalleryCompatibility.php
1 month ago
Migration.php
1 month ago
Package.php
1 month ago
Telemetry.php
1 month ago
ClassicVariationGalleryAdmin.php
373 lines
| 1 | <?php |
| 2 | declare( strict_types=1 ); |
| 3 | |
| 4 | namespace Automattic\WooCommerce\Internal\VariationGallery; |
| 5 | |
| 6 | use Automattic\Jetpack\Constants; |
| 7 | use Automattic\WooCommerce\Internal\RegisterHooksInterface; |
| 8 | use WC_Product_Variation; |
| 9 | use WP_Post; |
| 10 | |
| 11 | defined( 'ABSPATH' ) || exit; |
| 12 | |
| 13 | /** |
| 14 | * Adds a classic-editor authoring UI for variation galleries. |
| 15 | * |
| 16 | * The editor unifies the variation's featured image and gallery into a single |
| 17 | * ordered list. The legacy single-image slot is hidden visually, and kept |
| 18 | * in sync with the first gallery image via JS. |
| 19 | * |
| 20 | * This preserves the existing variation save path while giving merchants |
| 21 | * one control to manage. |
| 22 | */ |
| 23 | class ClassicVariationGalleryAdmin implements RegisterHooksInterface { |
| 24 | |
| 25 | private const SCRIPT_HANDLE = 'wc-admin-variation-gallery'; |
| 26 | |
| 27 | private const STYLE_HANDLE = 'wc-admin-variation-gallery-styles'; |
| 28 | |
| 29 | /** |
| 30 | * Register hooks. |
| 31 | * |
| 32 | * @return void |
| 33 | */ |
| 34 | public function register() { |
| 35 | add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 20 ); |
| 36 | add_action( 'woocommerce_variation_after_upload_image', array( $this, 'render_variation_gallery_field' ), 10, 3 ); |
| 37 | add_action( 'woocommerce_admin_process_variation_object', array( $this, 'persist_variation_gallery_field' ), 10, 2 ); |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Enqueue admin assets for the classic variation gallery editor. |
| 42 | * |
| 43 | * @return void |
| 44 | */ |
| 45 | public function enqueue_assets(): void { |
| 46 | if ( ! $this->is_product_edit_screen() ) { |
| 47 | return; |
| 48 | } |
| 49 | |
| 50 | $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; |
| 51 | |
| 52 | wp_enqueue_script( |
| 53 | self::SCRIPT_HANDLE, |
| 54 | \WC()->plugin_url() . '/assets/js/admin/variation-gallery' . $suffix . '.js', |
| 55 | array( 'wc-admin-variation-meta-boxes', 'wp-a11y' ), |
| 56 | Constants::get_constant( 'WC_VERSION' ), |
| 57 | true |
| 58 | ); |
| 59 | |
| 60 | wp_localize_script( |
| 61 | self::SCRIPT_HANDLE, |
| 62 | 'wcVariationGalleryL10n', |
| 63 | array( |
| 64 | 'manageTitle' => __( 'Manage variation gallery', 'woocommerce' ), |
| 65 | 'manageButton' => __( 'Update gallery', 'woocommerce' ), |
| 66 | 'replaceTitle' => __( 'Replace image', 'woocommerce' ), |
| 67 | 'replaceButton' => __( 'Use this image', 'woocommerce' ), |
| 68 | 'replaceLabel' => __( 'Replace', 'woocommerce' ), |
| 69 | 'addTitle' => __( 'Add images to variation gallery', 'woocommerce' ), |
| 70 | 'addButton' => __( 'Add to gallery', 'woocommerce' ), |
| 71 | 'emptyCtaLabel' => __( 'Add variation images', 'woocommerce' ), |
| 72 | 'announceUpdated' => __( 'Variation gallery updated.', 'woocommerce' ), |
| 73 | 'announceReplaced' => __( 'Image replaced.', 'woocommerce' ), |
| 74 | 'announceRemoved' => __( 'Image removed from variation gallery.', 'woocommerce' ), |
| 75 | 'announceReorder' => __( 'Variation gallery order updated.', 'woocommerce' ), |
| 76 | 'announcePrimary' => __( 'New primary image set.', 'woocommerce' ), |
| 77 | 'removeLabel' => __( 'Remove image', 'woocommerce' ), |
| 78 | 'countZero' => __( 'No images yet', 'woocommerce' ), |
| 79 | /* translators: %d: number of variation gallery images */ |
| 80 | 'countSingular' => __( '%d image', 'woocommerce' ), |
| 81 | /* translators: %d: number of variation gallery images */ |
| 82 | 'countPlural' => __( '%d images', 'woocommerce' ), |
| 83 | 'primaryLabel' => __( 'Primary', 'woocommerce' ), |
| 84 | /* translators: %d: gallery image position */ |
| 85 | 'thumbLabel' => __( 'Show gallery image %d', 'woocommerce' ), |
| 86 | 'missingFileLabel' => __( 'Attachment file missing', 'woocommerce' ), |
| 87 | ) |
| 88 | ); |
| 89 | |
| 90 | wp_enqueue_style( |
| 91 | self::STYLE_HANDLE, |
| 92 | \WC()->plugin_url() . '/assets/css/variation-gallery-admin.css', |
| 93 | array(), |
| 94 | Constants::get_constant( 'WC_VERSION' ) |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Render the variation gallery field. |
| 100 | * |
| 101 | * @param int $loop Variation row index. |
| 102 | * @param array $variation_data Variation data. |
| 103 | * @param WP_Post $variation Variation post object. |
| 104 | * @return void |
| 105 | */ |
| 106 | public function render_variation_gallery_field( int $loop, array $variation_data, WP_Post $variation ): void { |
| 107 | $variation_object = wc_get_product( $variation->ID ); |
| 108 | |
| 109 | if ( ! $variation_object instanceof WC_Product_Variation ) { |
| 110 | return; |
| 111 | } |
| 112 | |
| 113 | $image_ids = $this->get_display_image_ids( $variation_object ); |
| 114 | $count = count( $image_ids ); |
| 115 | $field_id = 'variable_gallery_image_ids_' . $loop; |
| 116 | $hero_id = $count > 0 ? $image_ids[0] : 0; |
| 117 | ?> |
| 118 | <div |
| 119 | class="wc-variation-gallery-field<?php echo 0 === $count ? ' is-empty' : ''; ?>" |
| 120 | data-variation-id="<?php echo esc_attr( (string) $variation->ID ); ?>" |
| 121 | > |
| 122 | <div class="wc-variation-gallery-field__header"> |
| 123 | <div class="wc-variation-gallery-field__title-block"> |
| 124 | <strong class="wc-variation-gallery-field__title"> |
| 125 | <?php esc_html_e( 'Variation gallery', 'woocommerce' ); ?> |
| 126 | </strong> |
| 127 | <span class="wc-variation-gallery-field__count" aria-live="polite"> |
| 128 | <?php echo esc_html( $this->get_count_text( $count ) ); ?> |
| 129 | </span> |
| 130 | </div> |
| 131 | <button |
| 132 | type="button" |
| 133 | class="button-link wc-variation-gallery-manage" |
| 134 | aria-label="<?php esc_attr_e( 'Manage variation gallery images', 'woocommerce' ); ?>" |
| 135 | > |
| 136 | <?php esc_html_e( 'Manage', 'woocommerce' ); ?> |
| 137 | </button> |
| 138 | </div> |
| 139 | |
| 140 | <div class="wc-variation-gallery-field__hero" data-active-index="0"> |
| 141 | <?php if ( $hero_id > 0 ) : ?> |
| 142 | <?php $this->render_hero_image( $hero_id ); ?> |
| 143 | <span class="wc-variation-gallery-field__badge" data-primary-badge aria-hidden="true"> |
| 144 | <span class="dashicons dashicons-star-filled"></span> |
| 145 | <?php esc_html_e( 'Primary', 'woocommerce' ); ?> |
| 146 | </span> |
| 147 | <button type="button" class="button wc-variation-gallery-replace"> |
| 148 | <?php esc_html_e( 'Replace', 'woocommerce' ); ?> |
| 149 | </button> |
| 150 | <?php else : ?> |
| 151 | <button type="button" class="wc-variation-gallery-field__empty-cta wc-variation-gallery-manage"> |
| 152 | <span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span> |
| 153 | <?php esc_html_e( 'Add variation images', 'woocommerce' ); ?> |
| 154 | </button> |
| 155 | <?php endif; ?> |
| 156 | </div> |
| 157 | |
| 158 | <ul class="wc-variation-gallery-field__thumbs"> |
| 159 | <?php foreach ( $image_ids as $index => $image_id ) : ?> |
| 160 | <?php $this->render_thumbnail( $image_id, 0 === $index ); ?> |
| 161 | <?php endforeach; ?> |
| 162 | </ul> |
| 163 | |
| 164 | <p class="wc-variation-gallery-field__hint"<?php echo 0 === $count ? ' hidden' : ''; ?>> |
| 165 | <?php esc_html_e( 'First image is used as the primary. Drag to reorder.', 'woocommerce' ); ?> |
| 166 | </p> |
| 167 | |
| 168 | <input |
| 169 | type="hidden" |
| 170 | id="<?php echo esc_attr( $field_id ); ?>" |
| 171 | name="variable_gallery_image_ids[<?php echo esc_attr( (string) $loop ); ?>]" |
| 172 | class="wc-variation-gallery-image-ids" |
| 173 | value="<?php echo esc_attr( implode( ',', $image_ids ) ); ?>" |
| 174 | /> |
| 175 | </div> |
| 176 | <?php |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Persist the variation gallery field. |
| 181 | * |
| 182 | * The merchant-facing UI presents featured + gallery as a single ordered |
| 183 | * list. |
| 184 | * |
| 185 | * @param WC_Product_Variation $variation Variation being saved. |
| 186 | * @param int $index Variation row index. |
| 187 | * @return void |
| 188 | * @throws \Throwable When setting the variation image or gallery fails. |
| 189 | */ |
| 190 | public function persist_variation_gallery_field( WC_Product_Variation $variation, int $index ): void { |
| 191 | // We verify the variation save nonce before firing `woocommerce_admin_process_variation_object`. |
| 192 | if ( ! isset( $_POST['variable_gallery_image_ids'][ $index ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing |
| 193 | return; |
| 194 | } |
| 195 | |
| 196 | $unified_ids = wp_parse_id_list( wp_unslash( $_POST['variable_gallery_image_ids'][ $index ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing |
| 197 | $featured_id = (int) ( $unified_ids[0] ?? 0 ); |
| 198 | $gallery_ids = array_values( array_slice( $unified_ids, 1 ) ); |
| 199 | |
| 200 | try { |
| 201 | $variation->set_image_id( $featured_id ); |
| 202 | $variation->set_gallery_image_ids( $gallery_ids ); |
| 203 | LegacyVariationGalleryCompatibility::mark_core_managed( $variation ); |
| 204 | } catch ( \Throwable $e ) { |
| 205 | Telemetry::record_event( |
| 206 | Telemetry::EVENT_SAVE_FAILED, |
| 207 | array( |
| 208 | 'context' => 'classic_admin', |
| 209 | 'reason' => get_class( $e ), |
| 210 | ) |
| 211 | ); |
| 212 | throw $e; |
| 213 | } |
| 214 | |
| 215 | Telemetry::record_event( |
| 216 | Telemetry::EVENT_SAVE_SUCCEEDED, |
| 217 | array( |
| 218 | 'context' => 'classic_admin', |
| 219 | 'image_count' => count( $unified_ids ), |
| 220 | 'is_multi' => count( $unified_ids ) > 1 ? 'yes' : 'no', |
| 221 | ) |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Display-time image list (render-only). |
| 227 | * |
| 228 | * Prepends the variation's featured image to the gallery when it's not |
| 229 | * already present, so the meta-box UI shows a single ordered set instead |
| 230 | * of two separate fields. This synthesis is **not persisted** — storage |
| 231 | * only changes when the merchant saves the variation, at which point |
| 232 | * `gallery[0]` becomes the canonical primary image. |
| 233 | * |
| 234 | * @param WC_Product_Variation $variation Variation object. |
| 235 | * @return array<int> |
| 236 | */ |
| 237 | private function get_display_image_ids( WC_Product_Variation $variation ): array { |
| 238 | $image_ids = array_values( array_map( 'intval', $variation->get_gallery_image_ids() ) ); |
| 239 | $featured_id = (int) $variation->get_image_id(); |
| 240 | |
| 241 | if ( $featured_id > 0 && ! in_array( $featured_id, $image_ids, true ) ) { |
| 242 | array_unshift( $image_ids, $featured_id ); |
| 243 | } |
| 244 | |
| 245 | if ( ! empty( $image_ids ) ) { |
| 246 | _prime_post_caches( $image_ids ); |
| 247 | } |
| 248 | |
| 249 | return $image_ids; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Render the hero image. |
| 254 | * |
| 255 | * @param int $image_id Attachment ID. |
| 256 | * @return void |
| 257 | */ |
| 258 | private function render_hero_image( int $image_id ): void { |
| 259 | $html = wp_get_attachment_image( |
| 260 | $image_id, |
| 261 | 'woocommerce_single', |
| 262 | false, |
| 263 | array( |
| 264 | 'class' => 'wc-variation-gallery-field__hero-img', |
| 265 | 'data-id' => (string) $image_id, |
| 266 | 'decoding' => 'async', |
| 267 | 'loading' => 'lazy', |
| 268 | ) |
| 269 | ); |
| 270 | |
| 271 | if ( '' === $html ) { |
| 272 | ?> |
| 273 | <span class="wc-variation-gallery-field__hero-broken" aria-hidden="true"> |
| 274 | <span class="dashicons dashicons-format-image"></span> |
| 275 | </span> |
| 276 | <span class="screen-reader-text"> |
| 277 | <?php esc_html_e( 'Attachment file missing', 'woocommerce' ); ?> |
| 278 | </span> |
| 279 | <?php |
| 280 | return; |
| 281 | } |
| 282 | |
| 283 | echo wp_kses_post( $html ); |
| 284 | } |
| 285 | |
| 286 | /** |
| 287 | * Render a single thumbnail list item. |
| 288 | * |
| 289 | * @param int $image_id Attachment ID. |
| 290 | * @param bool $is_active Whether this thumbnail is the active/primary one. |
| 291 | * @return void |
| 292 | */ |
| 293 | private function render_thumbnail( int $image_id, bool $is_active ): void { |
| 294 | $thumbnail = wp_get_attachment_image( $image_id, 'thumbnail' ); |
| 295 | $is_broken = '' === $thumbnail; |
| 296 | $classes = array( 'wc-variation-gallery-thumb' ); |
| 297 | |
| 298 | if ( $is_active ) { |
| 299 | $classes[] = 'is-active'; |
| 300 | } |
| 301 | |
| 302 | if ( $is_broken ) { |
| 303 | $classes[] = 'is-broken'; |
| 304 | } |
| 305 | |
| 306 | /* translators: %d attachment ID */ |
| 307 | $thumb_label = sprintf( __( 'Show gallery image %d', 'woocommerce' ), $image_id ); |
| 308 | ?> |
| 309 | <li |
| 310 | class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>" |
| 311 | data-attachment_id="<?php echo esc_attr( (string) $image_id ); ?>" |
| 312 | > |
| 313 | <button |
| 314 | type="button" |
| 315 | class="wc-variation-gallery-thumb__button" |
| 316 | aria-label="<?php echo esc_attr( $thumb_label ); ?>" |
| 317 | > |
| 318 | <?php if ( $is_broken ) : ?> |
| 319 | <span class="wc-variation-gallery-thumb__broken" aria-hidden="true"> |
| 320 | <span class="dashicons dashicons-format-image"></span> |
| 321 | </span> |
| 322 | <span class="screen-reader-text"> |
| 323 | <?php esc_html_e( 'Attachment file missing', 'woocommerce' ); ?> |
| 324 | </span> |
| 325 | <?php else : ?> |
| 326 | <?php echo wp_kses_post( $thumbnail ); ?> |
| 327 | <?php endif; ?> |
| 328 | </button> |
| 329 | <button |
| 330 | type="button" |
| 331 | class="wc-variation-gallery-thumb__remove" |
| 332 | aria-label="<?php esc_attr_e( 'Remove image', 'woocommerce' ); ?>" |
| 333 | > |
| 334 | <span class="dashicons dashicons-no-alt" aria-hidden="true"></span> |
| 335 | </button> |
| 336 | </li> |
| 337 | <?php |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Get the image count label shown beside the field title. |
| 342 | * |
| 343 | * @param int $count Number of images. |
| 344 | * @return string |
| 345 | */ |
| 346 | private function get_count_text( int $count ): string { |
| 347 | if ( 0 === $count ) { |
| 348 | return __( 'No images yet', 'woocommerce' ); |
| 349 | } |
| 350 | |
| 351 | return sprintf( |
| 352 | /* translators: %d number of variation gallery images */ |
| 353 | _n( '%d image', '%d images', $count, 'woocommerce' ), |
| 354 | $count |
| 355 | ); |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * Determine if the current screen is the classic product editor. |
| 360 | * |
| 361 | * @return bool |
| 362 | */ |
| 363 | private function is_product_edit_screen(): bool { |
| 364 | if ( ! function_exists( 'get_current_screen' ) ) { |
| 365 | return false; |
| 366 | } |
| 367 | |
| 368 | $screen = get_current_screen(); |
| 369 | |
| 370 | return $screen && 'product' === $screen->id; |
| 371 | } |
| 372 | } |
| 373 |