implementation-plan-tabs.md 26 KB

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:

$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
// 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 includes 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:

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):

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
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:

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():

$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:

safeInit('Manipulation tabs', '.studiou-wcpcm-tabs', initManipulationTabs);
// 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:

.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.31.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. Securitystudiou_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.