Bỏ qua để đến nội dung

Extension store — cắm UI liên module

Trang chi tiết contact (/contacts/:id/:tab) muốn hiển thị “Conversations” (Helpdesk) hoặc “Employee record” (HR) ngay trong tab của nó — nhưng Contacts không nên import trực tiếp component của Helpdesk/HR (sẽ tạo dependency ngược, và Contacts phải biết trước mọi module sẽ cắm vào nó, kể cả module chưa tồn tại). Extension store giải quyết bằng registry pattern: bên cung cấp UI đăng ký vào một pointId; bên host chỉ cần biết pointId đó, đọc list bằng getVisibleExtensions() mà không cần biết ai đã đăng ký.

Vị trí sống: @odp/core, không phải Contacts

Phần tiêu đề “Vị trí sống: @odp/core, không phải Contacts”
@odp/core/app/stores/use-extension-store.ts
export const useExtensionStore = defineStore('extensions', () => {
const registry = ref<Map<string, ExtensionRegistration[]>>(new Map())
function register(pointId: string, registration: ExtensionRegistration) {
const current = registry.value.get(pointId) || []
const idx = current.findIndex(e => e.id === registration.id)
if (idx >= 0) current[idx] = registration
else current.push(registration)
registry.value.set(pointId, current)
}
function getVisibleExtensions(pointId: string, context: Record<string, any>): ExtensionRegistration[] {
return getExtensions(pointId).filter(ext => !ext.visible || ext.visible(context))
}
return { register, getExtensions, getVisibleExtensions }
})

Pinia store id 'extensions'một instance duy nhất trong toàn app — mọi module extends @odp/core chia sẻ chung một registry, tự động auto-import, không cần cấu hình gì thêm để “kết nối” hai module.

Contacts tự bọc một lớp fallback an toàn khi chạy standalone (không có host store):

admin/packages/contacts/app/composables/use-extension-store.ts
export function useContactExtensionStore(): ExtensionStore {
try {
// @ts-expect-error — auto-imported from host app when available
return useExtensionStore() as ExtensionStore
} catch {
return NOOP_STORE
}
}
interface ExtensionRegistration {
id: string // duy nhất trong 1 pointId
label: string
icon?: string
sort?: number // mặc định 50 nếu bỏ trống
visible?: (context: Record<string, any>) => boolean // ẩn/hiện theo context
badge?: (context: Record<string, any>) => string | number | undefined
component?: Component // tab/widget extension
handler?: (context: Record<string, any>) => void | Promise<void> // action extension
color?: string
variant?: string
}

Cùng một interface phục vụ 2 kiểu mở rộng: tab/widget (cung cấp component, host render) và action (cung cấp handler, host gọi khi click) — điểm mở rộng nào dùng kiểu nào là do tài liệu của điểm đó quy định, không phải do interface ép buộc.

Ví dụ thật: Helpdesk cắm tab “Conversations”

Phần tiêu đề “Ví dụ thật: Helpdesk cắm tab “Conversations””
admin/packages/helpdesk/app/plugins/contacts-extension.ts (đầy đủ)
export default defineNuxtPlugin(() => {
const ext = useExtensionStore()
ext.register("contacts.detail.tabs", {
id: "helpdesk",
label: "Conversations",
icon: "i-ph-chat-circle-light",
component: defineAsyncComponent(
() => import("../components/contacts/helpdesk-contact-tab.vue"),
),
sort: 30,
visible: (ctx: { contact?: { type?: string } }) => ctx.contact?.type === "person",
})
})

Ba điểm đáng chú ý:

  • componentdefineAsyncComponent — Helpdesk chỉ tải component thật khi tab được render, không load sẵn lúc app boot.
  • visible nhận context — cùng một điểm mở rộng, Helpdesk chỉ hiện tab khi contact là person (organization không có “Conversations”).
  • Plugin này nằm trong Helpdesk, không phải Contacts — Contacts hoàn toàn không biết Helpdesk tồn tại.
admin/packages/contacts/app/pages/contacts/[id].vue
const extensionTabs = computed(() => {
if (!contact.value) return []
return extensionStore.getVisibleExtensions("contacts.detail.tabs", {
contact: contact.value,
})
})
const allTabs = computed(() => {
const extensions = extensionTabs.value.map(ext => ({
id: ext.id, label: ext.label, icon: ext.icon,
sort: ext.sort ?? 50,
badge: ext.badge ? ext.badge({ contact: contact.value }) : undefined,
}))
return [...coreTabs.value, ...extensions].sort((a, b) => (a.sort ?? 50) - (b.sort ?? 50))
})

Component thật được resolve ở trang con ([id]/[tab].vue), không phải nơi đăng ký:

admin/packages/contacts/app/pages/contacts/[id]/[tab].vue
// resolveComponent runs here in component context (not in the registering plugin),
// so a registered component *name* string resolves reliably.
const extComponent = computed(() => {
const comp = extReg.value?.component
if (!comp) return null
return typeof comp === "string" ? resolveComponent(comp) : comp
})

Một tab segment trên URL không khớp core tab lẫn extension đang visible (typo, link cũ, extension bị ẩn cho contact này) sẽ tự navigateTo redirect về info — tránh trang trắng.

useContactTypeRegistry — extension point khác, cùng ý tưởng

Phần tiêu đề “useContactTypeRegistry — extension point khác, cùng ý tưởng”

Ngoài tab, Contacts có một điểm mở rộng tương tự cho type (loại quan hệ): module ngoài có thể register() một type riêng (vd hr:employee) hiện trong danh sách type của Contacts mà không cần ghi vào shared_types backend:

admin/packages/contacts/app/composables/use-contact-type-registry.ts
export function useContactTypeRegistry() {
const registry = useState<ContactTypeDef[]>(REGISTRY_STATE_KEY, () => [])
function register(def: ContactTypeDef): void { /* upsert theo key */ }
return { registry, register, unregister, getRegisteredMeta }
}

useContactsSettings() hợp nhất backendTypes (từ shared_types) với contributedTypes (từ registry này) — backend thắng khi trùng key, key chỉ-contributed được nối thêm vào cuối danh sách hiển thị.