From c4bc4a16c91025d547a4215897575851f5947d0b Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 22 Mar 2026 14:08:47 +0600 Subject: [PATCH] feat(vueManager): add MS3OrderTabsRegistry for custom order tabs - window.MS3OrderTabsRegistry with register / _onMounted / _onUnmounted - OrderView: plugin tabs, Vue + ExtJS, shared props, lazy Ext mount - orderPluginTab.js: validation, normalization, queue snapshot Refs #166 --- vueManager/src/components/OrderView.vue | 217 ++++++++++++++++++++++-- vueManager/src/entries/order.js | 119 ++++++++++++- vueManager/src/utils/orderPluginTab.js | 92 ++++++++++ 3 files changed, 411 insertions(+), 17 deletions(-) create mode 100644 vueManager/src/utils/orderPluginTab.js diff --git a/vueManager/src/components/OrderView.vue b/vueManager/src/components/OrderView.vue index cc2246d2..d180dd28 100644 --- a/vueManager/src/components/OrderView.vue +++ b/vueManager/src/components/OrderView.vue @@ -23,9 +23,18 @@ import Textarea from 'primevue/textarea' import Toast from 'primevue/toast' import { useConfirm } from 'primevue/useconfirm' import { useToast } from 'primevue/usetoast' -import { computed, onMounted, ref } from 'vue' +import { + computed, + defineExpose, + nextTick, + onBeforeUnmount, + onMounted, + ref, + watch, +} from 'vue' import request from '../request.js' +import { normalizeOrderPluginTab } from '../utils/orderPluginTab.js' const toast = useToast() const confirm = useConfirm() @@ -96,8 +105,11 @@ const customerSuggestions = ref([]) const searchingCustomers = ref(false) const createCustomerFromData = ref(false) -// Order tabs +// Order tabs: built-ins (info/products/address/history) + MS3OrderTabsRegistry plugin tabs const orderActiveTab = ref('info') +const pluginTabs = ref([]) +/** ExtJS plugin panels mounted lazily per tab key; destroyed in onBeforeUnmount */ +const mountedExtPluginComponents = ref({}) // Duplicate customer dialog state const showDuplicateDialog = ref(false) @@ -125,6 +137,27 @@ const isCreateMode = computed(() => { return orderId.value === 'new' || orderId.value === 0 }) +const managerConfig = computed(() => { + return typeof ms3 !== 'undefined' ? ms3.config : {} +}) + +/** Fixed positions 0–3; must stay in sync with RESERVED_ORDER_TAB_KEYS in orderPluginTab.js */ +const builtInOrderTabs = computed(() => [ + { key: 'info', title: _('order_info'), position: 0, hideOnCreate: false, kind: 'builtin' }, + { key: 'products', title: _('order_products'), position: 1, hideOnCreate: true, kind: 'builtin' }, + { key: 'address', title: _('order_address'), position: 2, hideOnCreate: false, kind: 'builtin' }, + { key: 'history', title: _('order_history'), position: 3, hideOnCreate: true, kind: 'builtin' }, +]) + +/** Built-in + plugin tabs, sorted by `position`; respects hideOnCreate per tab */ +const orderTabsConfig = computed(() => { + const builtIn = builtInOrderTabs.value.filter(t => !(t.hideOnCreate && isCreateMode.value)) + const plugins = pluginTabs.value + .filter(t => !(t.hideOnCreate && isCreateMode.value)) + .map(t => ({ ...t, kind: 'plugin' })) + return [...builtIn, ...plugins].sort((a, b) => (a.position ?? 100) - (b.position ?? 100)) +}) + // Draft status ID (typically 1) const draftStatusId = computed(() => { const ms3Config = typeof ms3 !== 'undefined' ? ms3.config : null @@ -1597,6 +1630,142 @@ function goBack() { window.location.href = '?a=mgr/orders&namespace=minishop3' } +/** + * Registers a plugin tab (called by window.MS3OrderTabsRegistry or tests). + * Validation lives in normalizeOrderPluginTab(). + */ +function registerPluginTab(tabData) { + const normalized = normalizeOrderPluginTab(tabData) + if (!normalized.ok) { + console.warn(`[OrderView] ${normalized.reason}`, tabData) + return false + } + const exists = pluginTabs.value.some(t => t.key === normalized.tab.key) + if (exists) { + console.warn(`[OrderView] Tab with key "${normalized.tab.key}" already registered`) + return false + } + pluginTabs.value.push(normalized.tab) + return true +} + +defineExpose({ registerPluginTab }) + +/** + * Props passed to Vue plugin tab components (same contract as ExtJS tabs below). + * User `tab.props` is spread first; core fields override name collisions intentionally. + */ +function pluginVueProps(tab) { + return { + ...(tab.props || {}), + orderId: orderId.value, + order: order.value, + config: managerConfig.value, + isCreateMode: isCreateMode.value, + } +} + +/** Polls for TabPanel DOM (PrimeVue may render the panel slightly after tab switch). */ +function waitForOrderTabElement(id, callback, maxAttempts = 100) { + let attempts = 0 + const check = () => { + const element = document.getElementById(id) + if (element) { + callback(element) + } else if (attempts < maxAttempts) { + attempts++ + setTimeout(check, 50) + } else { + console.warn(`[OrderView] Element #${id} not found after ${maxAttempts} attempts`) + } + } + check() +} + +/** + * Lazy-mounts an ExtJS panel into the plugin tab container. + * Merge order: xtype/renderTo/width, then extConfig, then core fields (order, orderId, config, isCreateMode). + * + * The Ext instance is created once per tab key when the user first selects the tab. Later changes to + * Vue’s `order` (after API load, save, etc.) are not pushed into Ext — plugin panels must implement + * their own listeners, polling, or `load` hooks if they need live data. + */ +function mountExtJSOrderPlugin(tab) { + if (mountedExtPluginComponents.value[tab.key]) { + return + } + const containerId = `ms3-order-tab-${tab.key}` + waitForOrderTabElement(containerId, container => { + try { + if (typeof Ext === 'undefined') { + console.error('[OrderView] Ext is not defined') + return + } + const extComponent = Ext.create({ + xtype: tab.xtype, + renderTo: container, + width: '100%', + ...tab.extConfig, + order: order.value, + orderId: orderId.value, + config: managerConfig.value, + isCreateMode: isCreateMode.value, + }) + mountedExtPluginComponents.value[tab.key] = extComponent + } catch (error) { + console.error(`[OrderView] Failed to mount ExtJS order tab ${tab.key}:`, error) + } + }) +} + +function destroyPluginExtComponents() { + Object.keys(mountedExtPluginComponents.value).forEach(key => { + const component = mountedExtPluginComponents.value[key] + if (component && typeof component.destroy === 'function') { + try { + component.destroy() + } catch (e) { + console.warn(`[OrderView] Error destroying plugin ExtJS component ${key}:`, e) + } + } + }) + mountedExtPluginComponents.value = {} +} + +watch( + () => orderTabsConfig.value.map(t => t.key), + keys => { + if (keys.length === 0) return + if (!keys.includes(orderActiveTab.value)) { + orderActiveTab.value = keys[0] + } + }, + { immediate: true } +) + +watch(orderActiveTab, () => { + nextTick(() => { + const tab = orderTabsConfig.value.find(t => t.key === orderActiveTab.value) + if ( + tab && + tab.kind === 'plugin' && + tab.type === 'extjs' && + tab.xtype && + !mountedExtPluginComponents.value[tab.key] + ) { + mountExtJSOrderPlugin(tab) + } + }) +}) + +onBeforeUnmount(() => { + destroyPluginExtComponents() + // Clears registry root so late register() calls queue again if the app remounts in the same page + if (window.MS3OrderTabsRegistry) { + window.MS3OrderTabsRegistry._onUnmounted() + } +}) + /** * Format date */ @@ -2187,13 +2356,11 @@ onMounted(async () => { - - + - - + + + + + @@ -2775,6 +2948,18 @@ onMounted(async () => { padding: 1.25rem; } +.order-extjs-tab-container { + min-height: 18.75rem; + width: 100%; +} + +.order-extjs-tab-container :deep(.x-panel) { + width: 100% !important; +} + +.order-extjs-tab-container :deep(.x-panel-body) { + padding: 0.625rem; +} .order-header { display: flex; align-items: center; diff --git a/vueManager/src/entries/order.js b/vueManager/src/entries/order.js index a892a3fc..fd1881e1 100644 --- a/vueManager/src/entries/order.js +++ b/vueManager/src/entries/order.js @@ -17,6 +17,111 @@ import { createApp } from 'vue' import OrderView from '../components/OrderView.vue' import { injectFormStylesOverride } from '../utils/formStyles.js' +import { normalizeOrderPluginTab, snapshotOrderTabConfigForQueue } from '../utils/orderPluginTab.js' + +/** + * Plugin registry for third-party order manager tabs (Vue / ExtJS). See GitHub #166. + * Same lifecycle as ProductTabsRegistry: call `register()` before or after Vue mount; + * pre-mount entries are queued (snapshotted) and flushed in `_onMounted(instance)`. + * + * Tab config fields: + * - `key` (string, required) — unique id; must not be info|products|address|history + * - `title` (string, required) — header label + * - `type` — `'vue'` (default) or `'extjs'` + * - `component` — Vue: options object (imported SFC) or registered component name string + * - `xtype` — ExtJS: component xtype + * - `extConfig` — extra ExtJS config; merged before core props (see OrderView mountExtJSOrderPlugin) + * - `props` — extra Vue props (merged before orderId, order, config, isCreateMode) + * - `position` (number, default 100) — lower sorts earlier + * - `hideOnCreate` — hide tab while creating a new order (draft flow) + * + * Vue and ExtJS tabs receive: `orderId`, `order`, `config` (mgr ms3.config), `isCreateMode`. + * + * **Vue tabs** get reactive updates: props change when `order` loads or is edited in the manager. + * + * **ExtJS tabs** are created once when the user first opens the tab; `order` / `orderId` / `isCreateMode` + * are snapshots at creation time. The core does not push later Vue state into the Ext instance — implement + * `listeners`, a custom `initComponent`, or reload logic inside your xtype if you need live data. + * + * @example Vue tab (prefer a component definition from your bundle; string names need app.component()) + * window.MS3OrderTabsRegistry.register({ + * key: 'tracking', + * title: 'Tracking', + * type: 'vue', + * component: MyTrackingTab, + * position: 10, + * }) + * + * @example ExtJS tab — extConfig merges first; order, orderId, config, isCreateMode override extConfig keys + * window.MS3OrderTabsRegistry.register({ + * key: 'delivery', + * title: 'Delivery', + * type: 'extjs', + * xtype: 'my-delivery-panel', + * extConfig: { foo: 1 }, + * }) + */ +class OrderTabsRegistry { + constructor() { + /** @type {object[]} snapshotted tab configs (see `snapshotOrderTabConfigForQueue`) queued before mount */ + this.pendingTabs = [] + /** @type {import('vue').ComponentPublicInstance | null} */ + this._instance = null + this._mounted = false + } + + /** + * Before mount, valid configs are queued as a shallow snapshot (see `snapshotOrderTabConfigForQueue`) + * so later mutations of the caller’s object do not change the queued registration. + * + * @param {object} tabConfig + * @returns {boolean} false if validation failed or duplicate key + */ + register(tabConfig) { + if (this._mounted && this._instance) { + return this._instance.registerPluginTab(tabConfig) + } + + const normalized = normalizeOrderPluginTab(tabConfig) + if (!normalized.ok) { + console.warn(`[OrderTabsRegistry] ${normalized.reason}`, tabConfig) + return false + } + + const existsInPending = this.pendingTabs.some(t => t.key === normalized.tab.key) + if (existsInPending) { + console.warn(`[OrderTabsRegistry] Tab "${normalized.tab.key}" already registered`) + return false + } + + this.pendingTabs.push(snapshotOrderTabConfigForQueue(tabConfig)) + return true + } + + /** + * Called from `init()` after app.mount(). Flushes queued configs through `registerPluginTab` (single normalize). + * @param {import('vue').ComponentPublicInstance} instance + */ + _onMounted(instance) { + this._instance = instance + this._mounted = true + + this.pendingTabs.forEach(tab => { + if (instance.registerPluginTab) { + instance.registerPluginTab(tab) + } + }) + this.pendingTabs = [] + } + + /** Called from OrderView onBeforeUnmount; clears root so new registrations can queue again */ + _onUnmounted() { + this._instance = null + this._mounted = false + } +} + +window.MS3OrderTabsRegistry = window.MS3OrderTabsRegistry || new OrderTabsRegistry() /** * Creates and configures Vue application @@ -44,6 +149,9 @@ function createVueApp() { /** * Widget initialization + * + * @returns {import('vue').App | null} Vue application (historical contract). + * Mounted root instance is available as non-enumerable `__ms3OrderRootInstance` for integrations. */ export function init(selector = '#ms3-order-vue-wrapper') { const $el = document.querySelector(selector) @@ -57,10 +165,19 @@ export function init(selector = '#ms3-order-vue-wrapper') { } const app = createVueApp() - app.mount(selector) + const instance = app.mount(selector) injectFormStylesOverride() $el.dataset.vApp = 'true' + // Links registry to OrderView (defineExpose registerPluginTab) for queued + late plugin tabs + window.MS3OrderTabsRegistry._onMounted(instance) + + Object.defineProperty(app, '__ms3OrderRootInstance', { + value: instance, + enumerable: false, + configurable: true, + }) + return app } diff --git a/vueManager/src/utils/orderPluginTab.js b/vueManager/src/utils/orderPluginTab.js new file mode 100644 index 00000000..59421bcc --- /dev/null +++ b/vueManager/src/utils/orderPluginTab.js @@ -0,0 +1,92 @@ +/** + * Validation and normalization for `window.MS3OrderTabsRegistry` / OrderView plugin tabs. + * Keeps rules in one place for both the pre-mount queue (order.js) and direct registration. + */ + +/** Keys used by built-in tabs; plugin tabs must use different keys */ +export const RESERVED_ORDER_TAB_KEYS = new Set(['info', 'products', 'address', 'history']) + +/** + * Shallow snapshot for the pre-mount queue: top-level fields and nested `extConfig` / `props` are + * copied so plugins cannot invalidate an already accepted registration by mutating the same object + * before Vue mounts. The Vue `component` reference is kept as-is (definitions are not cloned). + * + * @param {object} tabConfig + * @returns {object} + */ +export function snapshotOrderTabConfigForQueue(tabConfig) { + if (!tabConfig || typeof tabConfig !== 'object') { + return tabConfig + } + const ext = + tabConfig.extConfig != null && typeof tabConfig.extConfig === 'object' + ? { ...tabConfig.extConfig } + : {} + const props = + tabConfig.props != null && typeof tabConfig.props === 'object' ? { ...tabConfig.props } : {} + return { + ...tabConfig, + extConfig: ext, + props, + } +} + +/** + * @param {object} tabConfig + * @returns {{ ok: true, type: string } | { ok: false, reason: string }} + */ +export function validateOrderPluginTabConfig(tabConfig) { + if (!tabConfig?.key || !tabConfig?.title) { + return { ok: false, reason: 'Tab must have key and title' } + } + if (RESERVED_ORDER_TAB_KEYS.has(tabConfig.key)) { + return { ok: false, reason: `Tab key "${tabConfig.key}" is reserved` } + } + const type = (tabConfig.type || 'vue').toLowerCase() + if (type !== 'vue' && type !== 'extjs') { + return { ok: false, reason: `Unsupported tab type "${tabConfig.type}" (use vue or extjs)` } + } + if (type === 'vue' && !tabConfig.component) { + return { ok: false, reason: 'Vue tab requires component (component name or definition)' } + } + if (type === 'extjs' && !tabConfig.xtype) { + return { ok: false, reason: 'ExtJS tab requires xtype' } + } + return { ok: true, type } +} + +/** + * Validates and returns the tab object stored in OrderView (single place for rules + shape). + * + * **Vue (`type: 'vue'`)** — `component` must be a component options object (e.g. imported SFC), + * or a string name already registered on the app (`app.component(...)`). The core does not + * register arbitrary component names from plugins. + * + * **ExtJS (`type: 'extjs'`)** — OrderView passes `order`, `orderId`, `config`, `isCreateMode` into + * `Ext.create` after `extConfig`; values are fixed at first mount of that tab. For data that arrives + * after load (e.g. async order fetch), panels must subscribe to events or refresh themselves — the + * core does not update the Ext component when Vue’s `order` ref changes. + * + * @param {object} tabConfig + * @returns {{ ok: true, tab: object } | { ok: false, reason: string }} + */ +export function normalizeOrderPluginTab(tabConfig) { + const validation = validateOrderPluginTabConfig(tabConfig) + if (!validation.ok) { + return { ok: false, reason: validation.reason } + } + return { + ok: true, + tab: { + key: tabConfig.key, + title: tabConfig.title, + type: validation.type, + component: tabConfig.component, + xtype: tabConfig.xtype, + extConfig: tabConfig.extConfig || {}, + props: tabConfig.props || {}, + position: tabConfig.position ?? 100, + hideOnCreate: !!tabConfig.hideOnCreate, + }, + } +}