
Product tabs integration
A MODX plugin adds your own tab to the product edit page: it registers a Vue component or an ExtJS panel in window.MS3ProductTabsRegistry.
Similar API
For order tabs use MS3OrderTabsRegistry.
Architecture
The product edit page is a hybrid: Vue tabs run inside an ExtJS TabPanel.
ExtJS TabPanel (top level)
├── Document (MODX system tab)
├── Product (Vue ProductTabs)
│ ├── Product Properties (Vue)
│ ├── Gallery (Vue)
│ ├── Categories (Vue ProductCategoriesTab)
│ ├── Links (Vue ProductLinksTab)
│ ├── Product options (Vue)
│ └── [Your tabs]
├── Template Variables (MODX system tab)
├── Settings (MODX system tab)
└── Resource Groups (MODX system tab)Not every built-in tab is always there:
| Tab | Key | Position | Shown when |
|---|---|---|---|
| Product Properties | properties | 0 | Always |
| Gallery | gallery | 1 | Unless show_gallery is off |
| Categories | categories | 2 | Unless show_categories is off |
| Links | links | 3 | Unless show_links is off |
| Product options | options | 4 | Unless show_options is off, and the product has options |
Tab registration
The window.MS3ProductTabsRegistry.register(tabConfig) method accepts:
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Unique tab identifier |
title | string | Yes | Tab title |
type | string | No | 'vue' (default) or 'extjs' |
component | object | For Vue | The component object — an imported SFC, for one. A string name only works if the component is already registered on the app via app.component(); the core does not look names up in window |
xtype | string | For ExtJS | ExtJS component xtype |
extConfig | object | No | Additional ExtJS configuration |
props | object | No | Additional props for the Vue component |
position | number | No | Tab position among the others (default 100) |
Returns: boolean — whether registration succeeded.
Keys of the built-in tabs (properties, gallery, categories, links, options) must not be taken: the core checks for duplicates among plugin tabs only and does not catch such a clash.
What the component receives
The tab receives this data automatically:
| Parameter | Type | Description | Where available |
|---|---|---|---|
productId | number | Product ID | props of the Vue component |
record | object | Product data | props of the Vue component, config.record in the ExtJS panel |
For ExtJS tabs the core injects record into extConfig itself — you do not need to pass it.
Load order
product-tabs.min.js is included as an ES module, and modules run after the page has been parsed. A plain plugin script therefore always runs earlier, and at that moment window.MS3ProductTabsRegistry does not exist yet.
Do not create a stub
The order page uses a stub-with-a-queue approach. It does not work here: the registry is created as window.MS3ProductTabsRegistry || new ProductTabsRegistry(), so your stub object stays where the real registry should be. Tab initialisation then calls _onMounted() on it, which the stub does not have, and not a single queued tab appears.
Wait for the registry instead — see the function below.
Waiting for the registry
This function defers the registration; it works for both Vue and ExtJS tabs:
function whenRegistryReady(callback, attemptsLeft) {
if (window.MS3ProductTabsRegistry) {
callback(window.MS3ProductTabsRegistry)
return
}
if (attemptsLeft === undefined) attemptsLeft = 50 // ~5 seconds
if (attemptsLeft <= 0) return
setTimeout(function() { whenRegistryReady(callback, attemptsLeft - 1) }, 100)
}The attempt limit is not optional: without it the polling never stops where no registry exists — on the product create page, for one.
The examples below call it by name.
Vue component integration
1. Create the component
<!-- my-component/src/components/VariantsTab.vue -->
<template>
<div class="variants-tab">
<h3>Product #{{ productId }} variants</h3>
<!-- Your content -->
</div>
</template>
<script setup>
defineProps({
productId: {
type: Number,
required: true
},
record: {
type: Object,
required: true
}
})
</script>2. Create the entry point
// my-component/src/entries/variants-tab.js
import VariantsTab from '../components/VariantsTab.vue'
whenRegistryReady(registry => {
registry.register({
key: 'variants',
title: 'Variants',
type: 'vue',
component: VariantsTab, // the component object, not a string
position: 5, // after "Product options"
})
})3. Load the script from a MODX plugin
<?php
// MODX plugin — event: msOnManagerCustomCssJs
$page = $scriptProperties['page'] ?? '';
if ($page !== 'product_update') {
return;
}
$controller = $scriptProperties['controller'];
$assetsUrl = MODX_ASSETS_URL . 'components/mycomponent/';
$controller->addHtml(
'<script type="module" src="' . $assetsUrl . 'js/variants-tab.min.js"></script>'
);There are no tabs on the create page
The event fires on the create page as well, with page => 'product_create', but product-tabs.min.js is not included there, the Product tab is absent and no registry ever appears. So checking for product_create buys you nothing: the script runs for nothing, and waiting for the registry would poll the page until it is closed. Custom tabs are only available for a saved product.
A Vue component inside ProductTabs inherits the styles of the .vueApp container.
ExtJS component integration
1. Create an ExtJS panel
// assets/components/mycomponent/js/mgr/widgets/custom.panel.js
MyComponent.panel.CustomTab = function(config) {
config = config || {};
Ext.apply(config, {
border: false,
layout: 'anchor',
items: [{
xtype: 'modx-grid',
// ... grid config
}]
});
MyComponent.panel.CustomTab.superclass.constructor.call(this, config);
};
Ext.extend(MyComponent.panel.CustomTab, MODx.Panel);
Ext.reg('mycomponent-custom-tab', MyComponent.panel.CustomTab);2. Register the tab
// assets/components/mycomponent/js/mgr/product-integration.js
whenRegistryReady(function(registry) {
registry.register({
key: 'custom',
title: 'Custom tab',
type: 'extjs',
xtype: 'mycomponent-custom-tab',
extConfig: {
// the core injects record itself, put only your own params here
myParam: 'value'
},
position: 6
});
});Ext.onReady() will not do for registration: it fires before the ES module with the registry runs. Wait for the registry with the function from the section above.
3. Load scripts
<?php
// MODX plugin — event: msOnManagerCustomCssJs
$page = $scriptProperties['page'] ?? '';
if ($page !== 'product_update' && $page !== 'product_create') {
return;
}
$controller = $scriptProperties['controller'];
$assetsUrl = MODX_ASSETS_URL . 'components/mycomponent/';
// The panel first, the registration second
$controller->addLastJavascript($assetsUrl . 'js/mgr/widgets/custom.panel.js');
$controller->addLastJavascript($assetsUrl . 'js/mgr/product-integration.js');An ExtJS panel is mounted only when the tab is opened and is destroyed together with the page.
Tab positioning
Built-in tabs occupy positions 0–4, and a tab without position gets 100. To land right after the built-in ones, set a value between 5 and 99.
position: 0 never reaches the tab: the core reads the value as position || 100 and substitutes 100. To sit ahead of Product Properties you need a negative number.
If the tab does not appear
The core logs the reason, and the prefix depends on when the tab was rejected:
| Message | When |
|---|---|
[ProductTabsRegistry] Tab must have key and title | key or title missing |
[ProductTabsRegistry] Tab "…" already registered | Duplicate key in the queue, before it reaches Vue |
[ProductTabs] Tab with key "…" already registered | Duplicate key registered on an already open page |
TypeError: … _onMounted is not a function | You created a stub — see the warning in Load order |
Filtering the console by [ProductTabs] is not enough: some messages arrive with the [ProductTabsRegistry] prefix.
What to look at in the browser console:
// Tabs still waiting to reach Vue
console.log(window.MS3ProductTabsRegistry.pendingTabs);A queue that is not empty after the page has loaded means the tabs never reached Vue.
