|
@@ -0,0 +1,484 @@
|
|
|
|
|
+# Implementation Plan — Tabbed Category Manipulations Page (lazy partial loading)
|
|
|
|
|
+
|
|
|
|
|
+**Target version:** 1.6.0 (bump from current 1.5.3)
|
|
|
|
|
+**Scope:** Restructure the **Products → Category Manipulations** admin page so its 6 operation sections + the *Important Notes* block become **tabs**, where each tab's HTML (and its expensive data) is fetched via a separate AJAX "partial" request only when the tab is first opened.
|
|
|
|
|
+**Goal:** Eliminate the page-load timeout. Today `views/manipulations.php` runs three expensive data calls *before any HTML is sent*, so one slow page request must finish all of them. After this change the shell page renders instantly and each tab pays only for its own calculation, in its own request.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 1. Why the page times out today
|
|
|
|
|
+
|
|
|
|
|
+`views/manipulations.php` runs all of the following synchronously on every page load, before output:
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+$categories = $manipulator->get_categories_with_counts(); // N+1 queries
|
|
|
|
|
+$media_categories = $manipulator->get_media_categories_with_counts(); // N+1 queries
|
|
|
|
|
+$unassigned_media_count = $manipulator->get_unassigned_unused_media_count(); // heavy multi-table scan
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `get_categories_with_counts()` — loads every `product_cat` term, then runs **one count query per category** (`get_category_product_count()`). On a store with hundreds of categories this is hundreds of queries.
|
|
|
|
|
+- `get_media_categories_with_counts()` — same N+1 shape for the media-category taxonomy.
|
|
|
|
|
+- `get_unassigned_unused_media_count()` — calls `get_unassigned_unused_media_ids()`, which scans the whole `posts`/`postmeta` tables plus every `_product_image_gallery` row.
|
|
|
|
|
+
|
|
|
|
|
+All three must complete inside a single PHP request before the page renders. On a large store that single request exceeds `max_execution_time`. The fix is to (a) render the page shell with **zero** heavy calls, and (b) move each section's data fetch into its own on-demand AJAX request.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 2. Target behaviour
|
|
|
|
|
+
|
|
|
|
|
+The page **Products → Category Manipulations** becomes:
|
|
|
|
|
+
|
|
|
|
|
+- A title, the existing notice area, and a horizontal **tab bar** (WordPress core `.nav-tab-wrapper` markup).
|
|
|
|
|
+- An empty content container `#studiou-wcpcm-tab-content`.
|
|
|
|
|
+- On load, the controller activates a tab (from the URL hash, else the first tab) and fetches its partial via AJAX.
|
|
|
|
|
+- Clicking a tab fetches that tab's partial **once**, then caches the rendered HTML in the DOM. Re-clicking a cached tab just shows it — no new request.
|
|
|
|
|
+- The active tab is reflected in the URL hash (`#move`, `#clear-media`, …) so a browser refresh — and the `location.reload()` calls that several operations already perform on success — return to the same tab and re-fetch its (now up-to-date) partial.
|
|
|
|
|
+
|
|
|
|
|
+Seven tabs, in this order:
|
|
|
|
|
+
|
|
|
|
|
+| Slug | Tab label (reuses existing translated heading) | Heavy data it needs |
|
|
|
|
|
+| --- | --- | --- |
|
|
|
|
|
+| `move` | Move products between categories | `get_categories_with_counts()` |
|
|
|
|
|
+| `batch-description` | Batch description between categories | `get_categories_with_counts()` |
|
|
|
|
|
+| `delete-products` | Delete products in category | `get_categories_with_counts()` |
|
|
|
|
|
+| `clear-media` | Clear Media Categories | `get_media_categories_with_counts()` |
|
|
|
|
|
+| `remove-unassigned` | Remove Not Assigned Media | `get_unassigned_unused_media_count()` |
|
|
|
|
|
+| `upvp` | Update product variant prices | `get_categories_with_counts()` |
|
|
|
|
|
+| `notes` | Important Notes | none (static) |
|
|
|
|
|
+
|
|
|
|
|
+Each tab request computes only *its own* data. A user who only needs the *Move* tab never pays for the media scans.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 3. Files to create / modify
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 New files
|
|
|
|
|
+
|
|
|
|
|
+| File | Purpose |
|
|
|
|
|
+| --- | --- |
|
|
|
|
|
+| `views/manipulations/tab-move.php` | Partial — "Move products between categories" card body |
|
|
|
|
|
+| `views/manipulations/tab-batch-description.php` | Partial — "Batch description between categories" card body |
|
|
|
|
|
+| `views/manipulations/tab-delete-products.php` | Partial — "Delete products in category" card body |
|
|
|
|
|
+| `views/manipulations/tab-clear-media.php` | Partial — "Clear Media Categories" card body |
|
|
|
|
|
+| `views/manipulations/tab-remove-unassigned.php` | Partial — "Remove Not Assigned Media" card body |
|
|
|
|
|
+| `views/manipulations/tab-upvp.php` | Partial — "Update product variant prices" card body |
|
|
|
|
|
+| `views/manipulations/tab-notes.php` | Partial — "Important Notes" card body |
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 Modified files
|
|
|
|
|
+
|
|
|
|
|
+| File | Change |
|
|
|
|
|
+| --- | --- |
|
|
|
|
|
+| `studiou-wc-product-cat-manage.php` | Bump `Version:` header and `STUDIOU_WCPCM_VERSION` to `1.6.0`. Add 3 new `i18n` keys (`tabLoading`, `tabLoadError`, see §7). |
|
|
|
|
|
+| `includes/class-studiou-wc-product-cat-manage-manipulator.php` | Add `handle_load_tab_ajax()` + register the `studiou_wcpcm_load_tab` action. Add cached wrappers `get_categories_with_counts()` / `get_media_categories_with_counts()` + cache-invalidation hooks (see §5). |
|
|
|
|
|
+| `views/manipulations.php` | Replace the whole body with the lightweight shell (tab bar + empty container). **No heavy data calls.** |
|
|
|
|
|
+| `assets/css/admin.css` | Add styles for the tab content container loading state (tab bar itself uses WP core styles). |
|
|
|
|
|
+| `assets/js/admin.js` | Add the tab controller; convert per-tab `init*Form()` functions to be invoked *after* the partial is injected, via a slug→init registry. |
|
|
|
|
|
+| `languages/studiou-wc-product-cat-manage.pot` | Add new translation strings. |
|
|
|
|
|
+| `languages/studiou-wc-product-cat-manage-cs_CZ.po` | Add Czech translations. |
|
|
|
|
|
+| `languages/studiou-wc-product-cat-manage-cs_CZ.mo` | Regenerate (`msgfmt`). |
|
|
|
|
|
+| `readme.md` | Bump version, note the tabbed UI under *Features*, add **Version 1.6.0** changelog entry. |
|
|
|
|
|
+| `CLAUDE.md` | Bump *Current Version* to 1.6.0, document the tab partials, the new AJAX endpoint, the `views/manipulations/` folder. |
|
|
|
|
|
+| `directory-structure.txt` | Add the new `views/manipulations/` folder and its 7 partial files. |
|
|
|
|
|
+
|
|
|
|
|
+No existing AJAX handler, no existing operation logic, and no DB schema changes.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 4. Shell page — `views/manipulations.php`
|
|
|
|
|
+
|
|
|
|
|
+Replace the entire current contents with a shell that does **no** `$manipulator` calls:
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+<?php
|
|
|
|
|
+// If this file is called directly, abort.
|
|
|
|
|
+if (!defined('WPINC')) { die; }
|
|
|
|
|
+
|
|
|
|
|
+$tabs = array(
|
|
|
|
|
+ 'move' => __('Move products between categories', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+ 'batch-description' => __('Batch description between categories', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+ 'delete-products' => __('Delete products in category', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+ 'clear-media' => __('Clear Media Categories', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+ 'remove-unassigned' => __('Remove Not Assigned Media', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+ 'upvp' => __('Update product variant prices', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+ 'notes' => __('Important Notes', 'studiou-wc-product-cat-manage'),
|
|
|
|
|
+);
|
|
|
|
|
+?>
|
|
|
|
|
+<div class="wrap studiou-wcpcm-wrap">
|
|
|
|
|
+ <h1><?php echo esc_html__('Category Manipulations', 'studiou-wc-product-cat-manage'); ?></h1>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="studiou-wcpcm-notice-area"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <h2 class="nav-tab-wrapper studiou-wcpcm-tabs">
|
|
|
|
|
+ <?php foreach ($tabs as $slug => $label): ?>
|
|
|
|
|
+ <a href="#<?php echo esc_attr($slug); ?>"
|
|
|
|
|
+ class="nav-tab"
|
|
|
|
|
+ data-tab="<?php echo esc_attr($slug); ?>">
|
|
|
|
|
+ <?php echo esc_html($label); ?>
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ </h2>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="studiou-wcpcm-tab-content" class="studiou-wcpcm-tab-content">
|
|
|
|
|
+ <div class="studiou-wcpcm-tab-loading">
|
|
|
|
|
+ <?php echo esc_html__('Loading...', 'studiou-wc-product-cat-manage'); ?>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`render_manipulations_page()` in the main plugin file is unchanged — it still just `include`s this file. The expensive `$manipulator` calls are deleted from here entirely.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 5. Backend — `class-studiou-wc-product-cat-manage-manipulator.php`
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 Register the new AJAX action
|
|
|
|
|
+
|
|
|
|
|
+In `__construct()`, alongside the existing `add_action('wp_ajax_studiou_wcpcm_*', ...)` lines:
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+add_action('wp_ajax_studiou_wcpcm_load_tab', array($this, 'handle_load_tab_ajax'));
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 New handler `handle_load_tab_ajax()`
|
|
|
|
|
+
|
|
|
|
|
+Follows the established handler pattern in this file (`ob` cleaning, `error_reporting(0)` save/restore, nonce, capability):
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+public function handle_load_tab_ajax() {
|
|
|
|
|
+ while (ob_get_level()) { ob_end_clean(); }
|
|
|
|
|
+
|
|
|
|
|
+ if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
|
|
|
|
|
+ wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!current_user_can('manage_woocommerce')) {
|
|
|
|
|
+ wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $tab = isset($_POST['tab']) ? sanitize_key($_POST['tab']) : '';
|
|
|
|
|
+ $allowed = array('move', 'batch-description', 'delete-products', 'clear-media', 'remove-unassigned', 'upvp', 'notes');
|
|
|
|
|
+
|
|
|
|
|
+ if (!in_array($tab, $allowed, true)) {
|
|
|
|
|
+ wp_send_json_error(array('message' => __('Unknown tab', 'studiou-wc-product-cat-manage')));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $partial = STUDIOU_WCPCM_PLUGIN_DIR . 'views/manipulations/tab-' . $tab . '.php';
|
|
|
|
|
+ if (!file_exists($partial)) {
|
|
|
|
|
+ wp_send_json_error(array('message' => __('Tab content not found', 'studiou-wc-product-cat-manage')));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // $manipulator is available to the partial; each partial fetches only the data it needs.
|
|
|
|
|
+ $manipulator = $this;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ ob_start();
|
|
|
|
|
+ include $partial;
|
|
|
|
|
+ $html = ob_get_clean();
|
|
|
|
|
+ wp_send_json_success(array('tab' => $tab, 'html' => $html));
|
|
|
|
|
+ } catch (Exception $e) {
|
|
|
|
|
+ if (ob_get_level()) { ob_end_clean(); }
|
|
|
|
|
+ error_log('STUDIOU WC TABS: tab "' . $tab . '" render failed: ' . $e->getMessage());
|
|
|
|
|
+ wp_send_json_error(array('message' => $e->getMessage()));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+The whitelist (`$allowed`) plus `sanitize_key()` plus the explicit `tab-<slug>.php` filename pattern means the `include` path can never be influenced into a directory traversal.
|
|
|
|
|
+
|
|
|
|
|
+### 5.3 Each partial is self-contained
|
|
|
|
|
+
|
|
|
|
|
+Each partial receives `$manipulator` (set to `$this` by the handler) and fetches only what it needs at the top, e.g. `views/manipulations/tab-move.php`:
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+<?php
|
|
|
|
|
+if (!defined('WPINC')) { die; }
|
|
|
|
|
+/** @var Studiou_WC_Product_Cat_Manage_Manipulator $manipulator */
|
|
|
|
|
+$categories = $manipulator->get_categories_with_counts();
|
|
|
|
|
+?>
|
|
|
|
|
+<div class="studiou-wcpcm-card">
|
|
|
|
|
+ <h2><?php echo esc_html__('Move products between categories', 'studiou-wc-product-cat-manage'); ?></h2>
|
|
|
|
|
+ ... existing "Move" card markup, copied verbatim from current manipulations.php lines 30-81 ...
|
|
|
|
|
+</div>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Mapping of which existing markup moves into which partial (line numbers refer to the current `views/manipulations.php`):
|
|
|
|
|
+
|
|
|
|
|
+- `tab-move.php` — current lines 29–82 (Move card). Needs `$categories`.
|
|
|
|
|
+- `tab-batch-description.php` — lines 85–141. Needs `$categories`.
|
|
|
|
|
+- `tab-delete-products.php` — lines 144–203. Needs `$categories`.
|
|
|
|
|
+- `tab-clear-media.php` — lines 206–269 (keeps the `empty($media_categories)` fallback branch). Needs `$media_categories = $manipulator->get_media_categories_with_counts();`.
|
|
|
|
|
+- `tab-remove-unassigned.php` — lines 272–302. Needs `$unassigned_media_count = $manipulator->get_unassigned_unused_media_count();`.
|
|
|
|
|
+- `tab-upvp.php` — lines 305–390. Needs `$categories`.
|
|
|
|
|
+- `tab-notes.php` — lines 393–406 (static `<ul>`; no data call).
|
|
|
|
|
+
|
|
|
|
|
+The `.studiou-wcpcm-card` wrapper stays inside each partial so the existing CSS keeps applying unchanged.
|
|
|
|
|
+
|
|
|
|
|
+### 5.4 Cache the N+1 category counts (addresses the "calculations" cost)
|
|
|
|
|
+
|
|
|
|
|
+Four of the seven tabs (`move`, `batch-description`, `delete-products`, `upvp`) each call `get_categories_with_counts()`. With lazy loading that is now four *separate* requests, each re-running the full N+1. To stop repeated recomputation, wrap the two N+1 methods in a transient cache.
|
|
|
|
|
+
|
|
|
|
|
+Rename the current bodies to private `*_uncached()` methods and add caching public wrappers:
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+public function get_categories_with_counts() {
|
|
|
|
|
+ $key = 'studiou_wcpcm_cat_counts';
|
|
|
|
|
+ $data = get_transient($key);
|
|
|
|
|
+ if (false === $data) {
|
|
|
|
|
+ $data = $this->get_categories_with_counts_uncached();
|
|
|
|
|
+ set_transient($key, $data, 5 * MINUTE_IN_SECONDS);
|
|
|
|
|
+ }
|
|
|
|
|
+ return $data;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+public function get_media_categories_with_counts() {
|
|
|
|
|
+ $key = 'studiou_wcpcm_media_cat_counts';
|
|
|
|
|
+ $data = get_transient($key);
|
|
|
|
|
+ if (false === $data) {
|
|
|
|
|
+ $data = $this->get_media_categories_with_counts_uncached();
|
|
|
|
|
+ set_transient($key, $data, 5 * MINUTE_IN_SECONDS);
|
|
|
|
|
+ }
|
|
|
|
|
+ return $data;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Invalidate both transients whenever categories or product/category relationships change. Register in `__construct()`:
|
|
|
|
|
+
|
|
|
|
|
+```php
|
|
|
|
|
+$bust = function() {
|
|
|
|
|
+ delete_transient('studiou_wcpcm_cat_counts');
|
|
|
|
|
+ delete_transient('studiou_wcpcm_media_cat_counts');
|
|
|
|
|
+};
|
|
|
|
|
+add_action('created_term', $bust);
|
|
|
|
|
+add_action('edited_term', $bust);
|
|
|
|
|
+add_action('delete_term', $bust);
|
|
|
|
|
+add_action('set_object_terms', $bust); // product <-> category assignment changes
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Also call the buster directly at the end of `move_products`, `delete_products`/`delete_products_batch`, `clear_media`/`clear_media_batch` so the counts shown after an operation's `location.reload()` are fresh even within the 5-minute window.
|
|
|
|
|
+
|
|
|
|
|
+The 5-minute TTL is a safety net only; the explicit invalidation keeps the numbers correct. The `get_unassigned_unused_media_count()` scan is left uncached — it is only triggered by its own single tab and there is no cheap, reliable invalidation signal for "media used anywhere".
|
|
|
|
|
+
|
|
|
|
|
+> Note: §5.4 caching is the secondary optimisation. The primary timeout fix is §4 + §6 — splitting one giant request into seven small on-demand ones. Caching can be implemented in the same release or deferred without affecting the timeout fix.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 6. Frontend — `assets/js/admin.js`
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 The problem with the current init model
|
|
|
|
|
+
|
|
|
|
|
+Today every form is initialised in `$(document).ready()` via `safeInit(name, selector, initFn)`. With lazy tabs, the manipulation forms do **not** exist in the DOM at `ready` time — they arrive later inside an AJAX partial. So their `init*Form()` functions must run *after* the partial is injected, not on `ready`.
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 Tab controller
|
|
|
|
|
+
|
|
|
|
|
+Add a controller that owns tab switching, lazy fetch, caching, and post-inject init. Register it on `ready` only when the tab bar is present:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+safeInit('Manipulation tabs', '.studiou-wcpcm-tabs', initManipulationTabs);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+// Maps a tab slug to the init function that wires its partial's handlers.
|
|
|
|
|
+var TAB_INIT = {
|
|
|
|
|
+ 'move': initMoveForm,
|
|
|
|
|
+ 'batch-description': initBatchDescriptionForm,
|
|
|
|
|
+ 'delete-products': initDeleteProductsForm,
|
|
|
|
|
+ 'clear-media': initClearMediaForm,
|
|
|
|
|
+ 'remove-unassigned': initRemoveUnassignedMediaForm,
|
|
|
|
|
+ 'upvp': initUpdateVariantPricesForm,
|
|
|
|
|
+ 'notes': null // static, nothing to wire
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function initManipulationTabs() {
|
|
|
|
|
+ var $tabs = $('.studiou-wcpcm-tabs .nav-tab');
|
|
|
|
|
+ var $content = $('#studiou-wcpcm-tab-content');
|
|
|
|
|
+ var cache = {}; // slug -> rendered HTML, populated on first load
|
|
|
|
|
+ var allowed = $tabs.map(function() { return $(this).data('tab'); }).get();
|
|
|
|
|
+
|
|
|
|
|
+ function activate(slug) {
|
|
|
|
|
+ if (allowed.indexOf(slug) === -1) { slug = allowed[0]; }
|
|
|
|
|
+
|
|
|
|
|
+ $tabs.removeClass('nav-tab-active')
|
|
|
|
|
+ .filter('[data-tab="' + slug + '"]').addClass('nav-tab-active');
|
|
|
|
|
+
|
|
|
|
|
+ if (window.history && history.replaceState) {
|
|
|
|
|
+ history.replaceState(null, '', '#' + slug);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ window.location.hash = slug;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (cache[slug] !== undefined) {
|
|
|
|
|
+ $content.html(cache[slug]);
|
|
|
|
|
+ return; // already loaded + already initialised; just show it
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $content.html('<div class="studiou-wcpcm-tab-loading">' +
|
|
|
|
|
+ (studiouWcpcm.i18n.tabLoading || 'Loading...') + '</div>');
|
|
|
|
|
+
|
|
|
|
|
+ $.ajax({
|
|
|
|
|
+ url: studiouWcpcm.ajaxUrl,
|
|
|
|
|
+ type: 'POST',
|
|
|
|
|
+ data: { action: 'studiou_wcpcm_load_tab', nonce: studiouWcpcm.nonce, tab: slug },
|
|
|
|
|
+ success: function(response) {
|
|
|
|
|
+ if (response.success && response.data && response.data.html) {
|
|
|
|
|
+ cache[slug] = response.data.html;
|
|
|
|
|
+ $content.html(response.data.html);
|
|
|
|
|
+ var initFn = TAB_INIT[slug];
|
|
|
|
|
+ if (typeof initFn === 'function') {
|
|
|
|
|
+ try { initFn(); }
|
|
|
|
|
+ catch (e) { console.error('STUDIOU WC: tab init "' + slug + '" failed', e); }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $content.html('<div class="studiou-wcpcm-tab-error">' +
|
|
|
|
|
+ ((response.data && response.data.message) ||
|
|
|
|
|
+ studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + '</div>');
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ error: function(xhr, status, error) {
|
|
|
|
|
+ $content.html('<div class="studiou-wcpcm-tab-error">' +
|
|
|
|
|
+ (studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + ': ' + error + '</div>');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $tabs.on('click', function(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ activate($(this).data('tab'));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Initial tab: URL hash if valid, else first tab.
|
|
|
|
|
+ var initial = (window.location.hash || '').replace(/^#/, '');
|
|
|
|
|
+ activate(allowed.indexOf(initial) !== -1 ? initial : allowed[0]);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 Changes to the existing init functions
|
|
|
|
|
+
|
|
|
|
|
+- **Remove** these lines from the `$(document).ready()` block (their forms no longer exist at `ready`):
|
|
|
|
|
+ `safeInit('Move form', ...)`, `safeInit('Batch description form', ...)`, `safeInit('Delete products form', ...)`, `safeInit('Clear media form', ...)`, `safeInit('Remove unassigned media form', ...)`, `safeInit('Update variant prices form', ...)`.
|
|
|
|
|
+- **Keep** the `safeInit` calls for `Import form`, `Export form`, `Sample download`, `Product import form`, `Product export form` — those live on other admin pages and still load normally.
|
|
|
|
|
+- The six `init*Form()` function bodies themselves are **unchanged** — they already query by ID/selector and bind handlers; they just get invoked from `TAB_INIT` after injection instead of on `ready`.
|
|
|
|
|
+- Each tab partial is fetched at most once and cached, so each `init*Form()` runs exactly once → no double-bound handlers. (If a tab were ever re-fetched, re-binding would duplicate handlers; the cache guarantees it is not. A defensive `.off()` before `.on()` inside each init is an optional hardening step.)
|
|
|
|
|
+- `initUpdateVariantPricesForm()` already calls `loadAttributes()` at the end of its body — that AJAX now fires when the UPVP tab is opened, which is the desired lazy behaviour.
|
|
|
|
|
+- Operations that currently call `location.reload()` on success (move, delete, clear-media, remove-unassigned) keep doing so. After reload the shell re-reads the URL hash and re-fetches that tab's partial, which now shows refreshed counts. No JS change needed for that path beyond the hash being set by `activate()`.
|
|
|
|
|
+- The `$(window).on('load', ...)` debug block that logs `Move form exists`, `UPVP form exists`, etc. will now log `false` for tab forms at window-load time. Update it to drop the stale per-form checks (or leave it — it is debug-only and harmless). Recommended: trim it to just the tab-bar presence check.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 7. Translations / i18n
|
|
|
|
|
+
|
|
|
|
|
+New strings to add to the `i18n` map in `wp_localize_script('studiou-wcpcm-admin', 'studiouWcpcm', ...)` in `studiou-wc-product-cat-manage.php`:
|
|
|
|
|
+
|
|
|
|
|
+| Key | English | Czech |
|
|
|
|
|
+| --- | --- | --- |
|
|
|
|
|
+| `tabLoading` | Loading... | Načítání... |
|
|
|
|
|
+| `tabLoadError` | Failed to load tab | Nepodařilo se načíst záložku |
|
|
|
|
|
+
|
|
|
|
|
+New translatable strings introduced in PHP (shell + handler), to add to `.pot` / `cs_CZ.po`:
|
|
|
|
|
+
|
|
|
|
|
+| English | Czech |
|
|
|
|
|
+| --- | --- |
|
|
|
|
|
+| Loading... | Načítání... |
|
|
|
|
|
+| Unknown tab | Neznámá záložka |
|
|
|
|
|
+| Tab content not found | Obsah záložky nenalezen |
|
|
|
|
|
+
|
|
|
|
|
+All seven tab labels reuse strings that are **already** translated (they are the current section `<h2>` headings), so no new label translations are required. Recompile `cs_CZ.mo` with `msgfmt` after editing the `.po`.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 8. CSS — `assets/css/admin.css`
|
|
|
|
|
+
|
|
|
|
|
+The tab bar uses WordPress core classes (`.nav-tab-wrapper`, `.nav-tab`, `.nav-tab-active`) which are already styled by `wp-admin`. Only add styling for the content container states:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+.studiou-wcpcm-tab-content {
|
|
|
|
|
+ margin-top: 20px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.studiou-wcpcm-tab-loading,
|
|
|
|
|
+.studiou-wcpcm-tab-error {
|
|
|
|
|
+ padding: 40px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border: 1px solid #ddd;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.studiou-wcpcm-tab-error {
|
|
|
|
|
+ color: #a00;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+The existing `.studiou-wcpcm-card` rules are reused untouched — each partial still wraps its body in a `.studiou-wcpcm-card`.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 9. Documentation updates
|
|
|
|
|
+
|
|
|
|
|
+### 9.1 `CLAUDE.md`
|
|
|
|
|
+- Bump *Current Version* `1.5.3` → `1.6.0`.
|
|
|
|
|
+- Under *Views (views/)*, change the `manipulations.php` bullet to note it is now a lightweight shell, and add a `views/manipulations/` sub-folder bullet listing the 7 tab partials.
|
|
|
|
|
+- Under *AJAX Handlers → Category Operations*, add `studiou_wcpcm_load_tab` — renders a manipulations tab partial on demand (NEW in v1.6.0).
|
|
|
|
|
+- Under the manipulator class bullets, note the new transient-cached `get_categories_with_counts()` / `get_media_categories_with_counts()` wrappers.
|
|
|
|
|
+
|
|
|
|
|
+### 9.2 `readme.md`
|
|
|
|
|
+- Bump version to `1.6.0`.
|
|
|
|
|
+- Under *Features*, note the Category Manipulations page is now tabbed with lazy-loaded sections.
|
|
|
|
|
+- Add changelog entry:
|
|
|
|
|
+ ```
|
|
|
|
|
+ ### Version 1.6.0
|
|
|
|
|
+ - Category Manipulations page restructured into tabs
|
|
|
|
|
+ - Each tab's content + data is loaded on demand via a separate AJAX request
|
|
|
|
|
+ - Fixes page-load timeout on stores with many categories / media files
|
|
|
|
|
+ - Active tab is preserved in the URL hash across refreshes
|
|
|
|
|
+ - New AJAX handler: studiou_wcpcm_load_tab
|
|
|
|
|
+ - Category/media count queries are now transient-cached (5 min, invalidated on term changes)
|
|
|
|
|
+ ```
|
|
|
|
|
+
|
|
|
|
|
+### 9.3 `directory-structure.txt`
|
|
|
|
|
+- Add the `views/manipulations/` folder and its 7 `tab-*.php` files.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 10. Step-by-step execution order
|
|
|
|
|
+
|
|
|
|
|
+1. Bump version constant + plugin header to `1.6.0`.
|
|
|
|
|
+2. Create the `views/manipulations/` folder and the 7 `tab-*.php` partials by moving each section's existing markup out of `views/manipulations.php` (see §5.3 line mapping). Add the `$manipulator` data-fetch line at the top of each non-static partial.
|
|
|
|
|
+3. Replace `views/manipulations.php` with the shell from §4.
|
|
|
|
|
+4. Add `handle_load_tab_ajax()` + register `studiou_wcpcm_load_tab` in the manipulator constructor.
|
|
|
|
|
+5. Add the transient-cached count wrappers + invalidation hooks (§5.4).
|
|
|
|
|
+6. Add `initManipulationTabs()` + the `TAB_INIT` registry to `admin.js`; remove the six obsolete `safeInit` calls from `$(document).ready()`; trim the window-load debug block.
|
|
|
|
|
+7. Extend `wp_localize_script` `i18n` with `tabLoading` / `tabLoadError`.
|
|
|
|
|
+8. Add the tab-content CSS rules.
|
|
|
|
|
+9. Update `.pot`, `cs_CZ.po`; regenerate `cs_CZ.mo`.
|
|
|
|
|
+10. Update `readme.md`, `CLAUDE.md`, `directory-structure.txt`.
|
|
|
|
|
+11. Manual smoke test — see §11.
|
|
|
|
|
+12. Hard-refresh in the browser (studiou.cz strips `?ver=` query strings, so the asset version bump alone will not bust the cache — instruct the user to hard-refresh).
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 11. Test plan
|
|
|
|
|
+
|
|
|
|
|
+1. **Shell loads instantly** — open Category Manipulations; the page renders immediately with the tab bar and a "Loading..." placeholder; no timeout even on a store with hundreds of categories.
|
|
|
|
|
+2. **First tab auto-loads** — with no URL hash, the *Move* tab fetches and renders; its category dropdowns populate.
|
|
|
|
|
+3. **Lazy loading** — open the *Clear Media Categories* tab; confirm the media-category scan runs only now (not on initial page load) — check via query monitor / debug log.
|
|
|
|
|
+4. **Tab caching** — switch away from a tab and back; no second AJAX request fires; the form state is preserved.
|
|
|
|
|
+5. **Each operation still works** — Move, Batch description, Delete products, Clear media, Remove unassigned media, UPVP — run each from inside its tab; handlers, spinners, progress bars, and confirmation dialogs behave as before.
|
|
|
|
|
+6. **Post-operation reload** — after a Move/Delete/Clear that calls `location.reload()`, the page returns to the same tab (via hash) and shows refreshed counts.
|
|
|
|
|
+7. **URL hash** — manually load `…&page=studiou-category-manipulations#upvp`; the UPVP tab opens directly. An invalid hash falls back to the first tab.
|
|
|
|
|
+8. **Count caching** — open *Move*, then *Delete products*, then *UPVP* in succession; the N+1 category-count query runs once, subsequent tabs read the transient. After editing a category, the next tab load recomputes.
|
|
|
|
|
+9. **Media fallback** — on a store with no media-category taxonomy, the *Clear Media Categories* tab still shows the graceful "install a media category plugin" message.
|
|
|
|
|
+10. **Security** — `studiou_wcpcm_load_tab` with a bad nonce → "Security check failed"; an unknown `tab` value → "Unknown tab"; a user without `manage_woocommerce` → permission error.
|
|
|
|
|
+11. **Path safety** — confirm a crafted `tab` param (e.g. `../../wp-config`) is rejected by the whitelist and never reaches `include`.
|
|
|
|
|
+12. **Translations** — switch the site to `cs_CZ`; tab labels and the "Loading..." / error strings render in Czech.
|
|
|
|
|
+13. **Other pages unaffected** — Import/Export Categories and Product Import/Export pages still work (their `safeInit` calls are untouched).
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 12. Rollback
|
|
|
|
|
+
|
|
|
|
|
+The change is structural but non-destructive — no DB schema changes, no data writes, all existing operation handlers and their logic are untouched. To roll back: restore the previous monolithic `views/manipulations.php`, delete the `views/manipulations/` folder, revert `admin.js` / `admin.css` / the manipulator class / the main plugin file to 1.5.3. The two new transients (`studiou_wcpcm_cat_counts`, `studiou_wcpcm_media_cat_counts`) are self-expiring and need no cleanup.
|