diff --git a/client-app/app-runner.ts b/client-app/app-runner.ts index 03b3fa1121..890f3b3bb1 100644 --- a/client-app/app-runner.ts +++ b/client-app/app-runner.ts @@ -7,7 +7,14 @@ import { useHotjar } from "@/core/composables/useHotjar"; import { useLanguages } from "@/core/composables/useLanguages"; import { FALLBACK_LOCALE, IS_DEVELOPMENT } from "@/core/constants"; import { setGlobals } from "@/core/globals"; -import { applicationInsightsPlugin, authPlugin, configPlugin, contextPlugin, permissionsPlugin } from "@/core/plugins"; +import { + applicationInsightsPlugin, + authPlugin, + configPlugin, + contextPlugin, + extensionPointsPlugin, + permissionsPlugin, +} from "@/core/plugins"; import { extractHostname, getBaseUrl, Logger } from "@/core/utilities"; import { createI18n } from "@/i18n"; import { init as initModuleBackInStock } from "@/modules/back-in-stock"; @@ -147,6 +154,7 @@ export default async () => { app.use(i18n); app.use(router); app.use(permissionsPlugin); + app.use(extensionPointsPlugin); app.use(contextPlugin, themeContext.value); app.use(configPlugin, themeContext.value); diff --git a/client-app/core/plugins/extension-points.plugin.ts b/client-app/core/plugins/extension-points.plugin.ts new file mode 100644 index 0000000000..7c36380ade --- /dev/null +++ b/client-app/core/plugins/extension-points.plugin.ts @@ -0,0 +1,20 @@ +import { useExtensionRegistry } from "@/shared/common/composables/extensionRegistry/useExtensionRegistry"; +import type { App, Plugin } from "vue"; +import ExtensionPointList from "@/shared/common/components/extension-point-list.vue"; +import ExtensionPoint from "@/shared/common/components/extension-point.vue"; + +export const extensionPointsPlugin: Plugin = { + install: (app: App) => { + const { canRender } = useExtensionRegistry(); + + /** + * Checking if component should be rendered + * @example: + * + */ + app.config.globalProperties.$canRenderExtensionPoint = canRender; + + app.component("ExtensionPoint", ExtensionPoint); + app.component("ExtensionPointList", ExtensionPointList); + }, +}; diff --git a/client-app/core/plugins/index.ts b/client-app/core/plugins/index.ts index de89268e08..1ef58eed65 100644 --- a/client-app/core/plugins/index.ts +++ b/client-app/core/plugins/index.ts @@ -2,4 +2,5 @@ export * from "./applicationInsights.plugin"; export * from "./auth.plugin"; export * from "./config.plugin"; export * from "./context.plugin"; +export * from "./extension-points.plugin"; export * from "./permissions.plugin"; diff --git a/client-app/modules/README.md b/client-app/modules/README.md index 94d709db24..cddeefa201 100644 --- a/client-app/modules/README.md +++ b/client-app/modules/README.md @@ -19,12 +19,7 @@ A **Module** is an additional functionality developed with minimal impact on the ### Extension points **Extension points** are belong to the **Core**. Also called **Holes** or **Sockets** -### Existing Extension points: -- `client-app/shared/common/composables/useCustomProductComponents.ts` -- `client-app/shared/layout/composables/useCustomAccountLinkComponents.ts` -- `client-app/shared/layout/composables/useCustomHeaderLinkComponents.ts` -- `client-app/shared/layout/composables/useCustomMobileHeaderComponents.ts` -- `client-app/shared/layout/composables/useCustomMobileMenuLinkComponents.ts` +You can read more about them in the [Extension points](../shared/common/composables/extensionRegistry/README.md) documentation. ### Module Management System The **Module Management System** is the decision-making point and business logic handler. It is represented as a [settings_data.json](../config/settings_data.json) as a bundle level and an array of `modules` in the `getStore` request as a store level settings. Could be considered as a "Feature Flags". diff --git a/client-app/modules/back-in-stock/index.ts b/client-app/modules/back-in-stock/index.ts index 2018f183a8..ae00b94bf7 100644 --- a/client-app/modules/back-in-stock/index.ts +++ b/client-app/modules/back-in-stock/index.ts @@ -2,8 +2,8 @@ import { defineAsyncComponent } from "vue"; import { useNavigations } from "@/core/composables"; import { useModuleSettings } from "@/core/composables/useModuleSettings"; import { useUser } from "@/shared/account/composables"; -import { useCustomProductComponents } from "@/shared/common/composables"; -import { CUSTOM_PRODUCT_COMPONENT_IDS } from "@/shared/common/constants"; +import { useExtensionRegistry } from "@/shared/common/composables/extensionRegistry/useExtensionRegistry"; +import { EXTENSION_NAMES } from "@/shared/common/constants"; import { loadModuleLocale } from "../utils"; import { MODULE_ID, ENABLED_KEY } from "./constants"; import type { MenuType } from "@/core/types"; @@ -17,7 +17,7 @@ const BackInStockButton = defineAsyncComponent(() => import("./components/back-i const { isEnabled } = useModuleSettings(MODULE_ID); const { mergeMenuSchema } = useNavigations(); -const { registerComponent } = useCustomProductComponents(); +const { register } = useExtensionRegistry(); const route: RouteRecordRaw = { path: "back-in-stock", @@ -64,16 +64,14 @@ export function init(router: Router, i18n: I18n) { } if (isAuthenticated.value && isEnabled(ENABLED_KEY)) { mergeMenuSchema(menuItems); - registerComponent({ - id: CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON, + register("productCard", EXTENSION_NAMES.productCard.cardButton, { component: BackInStockButton, - shouldRender: (product) => !product.availabilityData.isInStock, + condition: (product) => !product.availabilityData.isInStock, props: { isTextShown: true }, }); - registerComponent({ - id: CUSTOM_PRODUCT_COMPONENT_IDS.PAGE_SIDEBAR_BUTTON, + register("productPage", EXTENSION_NAMES.productPage.sidebarButton, { component: BackInStockButton, - shouldRender: (product) => !product.availabilityData.isInStock, + condition: (product) => !product.availabilityData.isInStock, }); } } diff --git a/client-app/modules/push-messages/index.ts b/client-app/modules/push-messages/index.ts index 6663239e0f..bd578d6e81 100644 --- a/client-app/modules/push-messages/index.ts +++ b/client-app/modules/push-messages/index.ts @@ -6,15 +6,11 @@ import { useThemeContext } from "@/core/composables/useThemeContext"; import { MODULE_ID_PUSH_MESSAGES } from "@/core/constants/modules"; import { loadModuleLocale } from "@/modules/utils"; import { useUser } from "@/shared/account/composables/useUser"; -import { useCustomHeaderLinkComponents } from "@/shared/layout/composables/useCustomHeaderLinkComponents"; -import { useCustomMobileHeaderComponents } from "@/shared/layout/composables/useCustomMobileHeaderComponents"; -import { useCustomMobileMenuLinkComponents } from "@/shared/layout/composables/useCustomMobileMenuLinkComponents"; +import { useExtensionRegistry } from "@/shared/common/composables/extensionRegistry/useExtensionRegistry"; import { pushMessagesTypePolices } from "./api/graphql/typePolices"; import { PUSH_MESSAGES_MODULE_ENABLED_KEY, PUSH_MESSAGES_MODULE_FCM_ENABLED_KEY } from "./constants"; import type { MenuType } from "@/core/types"; import type { I18n } from "@/i18n"; -import type { ElementType } from "@/shared/layout/composables/useCustomHeaderLinkComponents"; -import type { ElementType as HeaderElementType } from "@/shared/layout/composables/useCustomMobileHeaderComponents"; import type { DeepPartial } from "utility-types"; import type { Router, RouteRecordRaw } from "vue-router"; @@ -66,21 +62,6 @@ const menuItems: DeepPartial = { const Notifications = () => import("@/modules/push-messages/pages/notifications.vue"); const PushMessage = () => import("@/modules/push-messages/pages/push-message.vue"); -const menuLinkCustomElement: ElementType = { - id: "push-messages", - component: defineAsyncComponent(() => import("./components/link-push-messages.vue")), -}; - -const menuLinkCustomElementMobile: ElementType = { - id: "push-messages", - component: defineAsyncComponent(() => import("./components/link-push-messages-mobile.vue")), -}; - -const headerWidgetCustomElementMobile: HeaderElementType = { - id: "push-messages", - component: defineAsyncComponent(() => import("./components/push-messages-mobile.vue")), -}; - async function unregisterFCM() { const serviceWorkerRegistration = await navigator.serviceWorker.getRegistration(REGISTRATION_SCOPE); if (serviceWorkerRegistration) { @@ -102,9 +83,7 @@ export async function init(router: Router, i18n: I18n) { if (isModuleEnabled) { const { mergeMenuSchema } = useNavigations(); - const { registerCustomLinkComponent } = useCustomHeaderLinkComponents(); - const { registerCustomLinkComponent: registerCustomMobileLinkComponent } = useCustomMobileMenuLinkComponents(); - const { registerCustomComponent: registerCustomMobileHeaderComponent } = useCustomMobileHeaderComponents(); + const { register } = useExtensionRegistry(); const route: RouteRecordRaw = { path: "notifications", name: "Notifications", @@ -121,9 +100,15 @@ export async function init(router: Router, i18n: I18n) { cache.policies.addTypePolicies(pushMessagesTypePolices); mergeMenuSchema(menuItems); void loadModuleLocale(i18n, "push-messages"); - registerCustomLinkComponent(menuLinkCustomElement); - registerCustomMobileLinkComponent(menuLinkCustomElementMobile); - registerCustomMobileHeaderComponent(headerWidgetCustomElementMobile); + register("headerMenu", "push-messages", { + component: defineAsyncComponent(() => import("./components/link-push-messages.vue")), + }); + register("mobileMenu", "push-messages", { + component: defineAsyncComponent(() => import("./components/link-push-messages-mobile.vue")), + }); + register("mobileHeader", "push-messages", { + component: defineAsyncComponent(() => import("./components/push-messages-mobile.vue")), + }); router.addRoute("Account", route); // NOTE: This route must be added before any asynchronous calls. Delaying it can cause a 404 error if accessed prematurely. } diff --git a/client-app/shared/account/components/account-navigation.vue b/client-app/shared/account/components/account-navigation.vue index db2456f8da..b745b9ae60 100644 --- a/client-app/shared/account/components/account-navigation.vue +++ b/client-app/shared/account/components/account-navigation.vue @@ -1,21 +1,27 @@ @@ -24,19 +30,12 @@ import { computed } from "vue"; import { useNavigations } from "@/core/composables"; import { useUser } from "@/shared/account/composables/useUser"; -import { useCustomAccountLinkComponents } from "@/shared/layout/composables"; import LinkDefault from "./account-navigation-link-components/link-default.vue"; -import LinkLists from "./account-navigation-link-components/link-lists.vue"; -import LinkOrders from "./account-navigation-link-components/link-orders.vue"; import type { ExtendedMenuLinkType } from "@/core/types"; const { isCorporateMember } = useUser(); const { desktopAccountMenuItems, desktopCorporateMenuItems } = useNavigations(); -const { customLinkComponents, registerCustomLinkComponent } = useCustomAccountLinkComponents(); - -registerCustomLinkComponent({ id: "orders", component: LinkOrders }); -registerCustomLinkComponent({ id: "lists", component: LinkLists }); function canShowItem(item: ExtendedMenuLinkType) { return !(item.id === "addresses" && isCorporateMember.value); diff --git a/client-app/shared/catalog/components/product-card-grid.vue b/client-app/shared/catalog/components/product-card-grid.vue index 5b47f72d09..30dc2af742 100644 --- a/client-app/shared/catalog/components/product-card-grid.vue +++ b/client-app/shared/catalog/components/product-card-grid.vue @@ -164,14 +164,11 @@ - (); const props = withDefaults(defineProps(), { @@ -256,8 +253,6 @@ const properties = computed(() => ); const price = computed(() => (props.product.hasVariations ? props.product.minVariationPrice : props.product.price)); -const { isComponentRegistered, getComponent, shouldRenderComponent, getComponentProps } = useCustomProductComponents(); - function slideChanged(swiper: SwiperInstance) { const activeIndex: number = swiper.activeIndex; const lastIndex: number = props.product.images?.length ? props.product.images.length - 1 : 0; diff --git a/client-app/shared/catalog/components/product-card-list.vue b/client-app/shared/catalog/components/product-card-list.vue index f7241890a9..9a22074f4b 100644 --- a/client-app/shared/catalog/components/product-card-list.vue +++ b/client-app/shared/catalog/components/product-card-list.vue @@ -97,14 +97,11 @@
- getProductRoute(props.product.id, props.product.slug)); const isDigital = computed(() => props.product.productType === ProductType.Digital); const properties = computed(() => diff --git a/client-app/shared/catalog/components/product-card.vue b/client-app/shared/catalog/components/product-card.vue index a33da78157..956dfbbc9a 100644 --- a/client-app/shared/catalog/components/product-card.vue +++ b/client-app/shared/catalog/components/product-card.vue @@ -51,14 +51,11 @@ :single-line="viewMode === 'grid'" /> - (); const props = defineProps(); -const { isComponentRegistered, getComponent, shouldRenderComponent, getComponentProps } = useCustomProductComponents(); - const { isEnabled } = useModuleSettings(CUSTOMER_REVIEWS_MODULE_ID); const productReviewsEnabled = isEnabled(CUSTOMER_REVIEWS_ENABLED_KEY); diff --git a/client-app/shared/catalog/components/product-sidebar.vue b/client-app/shared/catalog/components/product-sidebar.vue index 14321c187a..55c69e43b7 100644 --- a/client-app/shared/catalog/components/product-sidebar.vue +++ b/client-app/shared/catalog/components/product-sidebar.vue @@ -30,15 +30,13 @@
- + (() => props.product.productType === ProductType.Digital); diff --git a/client-app/shared/common/components/extension-point-list.vue b/client-app/shared/common/components/extension-point-list.vue new file mode 100644 index 0000000000..1d0ca6c1ff --- /dev/null +++ b/client-app/shared/common/components/extension-point-list.vue @@ -0,0 +1,28 @@ + + + diff --git a/client-app/shared/common/components/extension-point.vue b/client-app/shared/common/components/extension-point.vue new file mode 100644 index 0000000000..96b941cb38 --- /dev/null +++ b/client-app/shared/common/components/extension-point.vue @@ -0,0 +1,26 @@ + + + diff --git a/client-app/shared/common/composables/extensionRegistry/README.md b/client-app/shared/common/composables/extensionRegistry/README.md new file mode 100644 index 0000000000..699b61f96b --- /dev/null +++ b/client-app/shared/common/composables/extensionRegistry/README.md @@ -0,0 +1,84 @@ +# ExtensionPoint system — quick reference + +### Purpose +1. Plug extra Vue components into fixed UI slots without touching core code. +2. Add new extension points anywhere in the app quickly. + +--- + +## Core pieces + +| Item | Description | +|------|-------------| +| **`useExtensionRegistry()`** | Global store + API | +| **`ExtensionPoint`** | Placeholder component to render registered extension; renders default slot for each unregistered entry | +| **`ExtensionPointList`** | Placeholder component to render multiple registered extensions; accepts optional `names` array; renders default slot for each unregistered entry | +| **`$canRenderExtensionPoint`** | Global helper that evaluates `condition` | + +--- + +## Main Scenarios + +### 1. Defining extension points in the core app + +1. Extend the category map: + - In `client-app/shared/common/types/extensionRegistryMap.ts`, add your new category key to `ExtensionCategoryMapType` with the appropriate `ExtensionEntryType`. +2. Initialize the registry placeholder: + - In `client-app/shared/common/constants/initialExtensionRegistry.ts`, add an empty object for the new category. +3. Update `EXTENSION_NAMES` (for static extension identifiers): + - In `client-app/shared/common/constants/extensionPointsNames.ts`, add entries under your category for each extension name: + ```ts + export const EXTENSION_NAMES = merge({}, INITIAL_EXTENSION_NAMES, { + myCategory: { + myExtension: 'my-extension', + }, + }); + ``` +4. Declare extension points in templates: + - Insert `` or `` in your Vue components, specifying `category` and `name` (`names` for multi). +5. (Optional) Provide fallback slot content for unregistered names: + ```vue + +
Fallback content when no extension is registered
+
+ ``` + +### 2. Enriching the app from modules + +1. Import and register your extension: + ```ts + import { useExtensionRegistry } from '@/shared/common/composables/useExtensionRegistry'; + import { EXTENSION_NAMES } from '@/shared/common/constants/extensionPointsNames.ts'; + const { register } = useExtensionRegistry(); + register( + 'productCard', + EXTENSION_NAMES.productCard.cardButton, + { component: MyComponent } + ); + ``` +2. (Optional) Unregister on cleanup: + ```ts + import { onUnmounted } from 'vue'; + const { unregister } = useExtensionRegistry(); + onUnmounted(() => { + unregister('myCategory', 'myExtension'); + }); + ``` +3. Your registered components will then be automatically rendered at the corresponding extension points in the core app. + +> **Recommendation** +> +> For consistent extension identifiers and to avoid typos, import the `EXTENSION_NAMES` constant from `@/shared/common/constants/extensionPointsNames.ts` and use its properties: +> ```ts +> import { EXTENSION_NAMES } from '@/shared/common/constants/extensionPointsNames.ts'; +> const { register } = useExtensionRegistry(); +> register( +> 'productCard', +> EXTENSION_NAMES.productCard.cardButton, +> { component: MyComponent } +> ); +> ``` + +> [!TIP] +> +> **Dev tip:** In dev mode the registry is available as `window.VCExtensionRegistry`. \ No newline at end of file diff --git a/client-app/shared/common/composables/extensionRegistry/useExtensionRegistry.test.ts b/client-app/shared/common/composables/extensionRegistry/useExtensionRegistry.test.ts new file mode 100644 index 0000000000..2d0b13ea05 --- /dev/null +++ b/client-app/shared/common/composables/extensionRegistry/useExtensionRegistry.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent } from "vue"; +import { useExtensionRegistry } from "@/shared/common/composables/extensionRegistry/useExtensionRegistry"; +import type { Product } from "@/core/api/graphql/types"; +import type { ExtensionCategoryType } from "@/shared/common/types/extensionRegistry"; + +// Mock Logger and initial registry to avoid flakiness +const mockLogger = vi.hoisted(() => ({ warn: vi.fn(), error: vi.fn() })); +vi.mock("@/core/utilities", () => ({ Logger: mockLogger })); +vi.mock("@/shared/common/constants/initialExtensionRegistry", () => ({ + initialExtensionRegistry: { + headerMenu: {}, + mobileMenu: {}, + accountMenu: {}, + mobileHeader: {}, + productCard: {}, + }, +})); + +describe("useExtensionRegistry", () => { + const DummyComponent = defineComponent({ name: "DummyComponent" }); + let registry: ReturnType; + const dummyProduct = {} as unknown as Product; + + beforeEach(() => { + vi.resetModules(); + registry = useExtensionRegistry(); + vi.resetAllMocks(); + }); + + describe("initial state", () => { + it("should start with no entries in productCard", () => { + const entries = registry.getEntries("productCard"); + expect(Object.keys(entries)).toHaveLength(0); + }); + }); + + describe("registration", () => { + it("should register and retrieve entries", () => { + registry.register("productCard", "a", { component: DummyComponent }); + const entries = registry.getEntries("productCard"); + expect(entries).toEqual({ a: { component: DummyComponent } }); + expect(registry.getComponent("productCard", "a")).toBe(DummyComponent); + expect(registry.isRegistered("productCard", "a")).toBe(true); + expect(registry.canRender("productCard", "a", dummyProduct)).toBe(true); + }); + + it("should not override existing entries", () => { + const A = defineComponent({ name: "ComponentA" }); + const B = defineComponent({ name: "ComponentB" }); + registry.register("productCard", "dup", { component: A }); + registry.register("productCard", "dup", { component: B }); + expect(registry.getComponent("productCard", "dup")).toBe(A); + }); + + it("should unregister entries", () => { + registry.register("productCard", "toRemove", { component: DummyComponent }); + expect(registry.isRegistered("productCard", "toRemove")).toBe(true); + registry.unregister("productCard", "toRemove"); + expect(registry.getEntries("productCard")).not.toContain("toRemove"); + expect(registry.isRegistered("productCard", "toRemove")).toBe(false); + expect(registry.getComponent("productCard", "toRemove")).toBeNull(); + }); + + it("should not throw when unregistering non-existent entry", () => { + expect(() => registry.unregister("productCard", "nonexistent")).not.toThrow(); + }); + + it("should share state across instances", () => { + const reg1 = useExtensionRegistry(); + const reg2 = useExtensionRegistry(); + reg1.register("productCard", "globalTest", { component: DummyComponent }); + expect(reg2.getEntries("productCard")).toEqual( + expect.objectContaining({ globalTest: { component: DummyComponent } }), + ); + }); + + it("should warn when registering duplicate entries", () => { + registry.register("productCard", "warnTest", { component: DummyComponent }); + registry.register("productCard", "warnTest", { component: DummyComponent }); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'useExtensionRegistry: Component "productCard/warnTest" already registered', + ); + }); + }); + + describe("props management", () => { + it("should return undefined props when not set", () => { + registry.register("productCard", "noProps", { component: DummyComponent }); + expect(registry.getProps("productCard", "noProps")).toBeUndefined(); + }); + + it("should set and retrieve props", () => { + const props = { product: dummyProduct }; + registry.register("productCard", "withProps", { component: DummyComponent, props }); + expect(registry.getProps("productCard", "withProps")).toEqual(props); + }); + }); + + describe("condition evaluation", () => { + it("should handle condition function correctly", () => { + const otherProduct = {} as unknown as Product; + const condition = (product: Product) => product === dummyProduct; + registry.register("productCard", "cond", { component: DummyComponent, condition }); + expect(registry.canRender("productCard", "cond", dummyProduct)).toBe(true); + expect(registry.canRender("productCard", "cond", otherProduct)).toBe(false); + }); + }); + + describe("entries snapshot", () => { + it("should maintain immutability of entries snapshot", () => { + registry.register("productCard", "readonly", { component: DummyComponent }); + const entries = registry.getEntries("productCard"); + (entries as Record)["mutated"] = 123; + expect(registry.getEntries("productCard")).not.toContain("mutated"); + }); + + it("should reflect entries keys with getEntries", () => { + const uniqueCategory = "testCategoryForEntriesSnapshot" as ExtensionCategoryType; + + registry.register(uniqueCategory, "entryA", { component: DummyComponent }); + registry.register(uniqueCategory, "entryB", { component: DummyComponent }); + + const entries = registry.getEntries(uniqueCategory); + expect(Object.keys(entries)).toEqual(["entryA", "entryB"]); + }); + }); + + describe("error handling", () => { + it("should return false for unregistered entries", () => { + expect(registry.getComponent("productCard", "unknown")).toBeNull(); + expect(registry.isRegistered("productCard", "unknown")).toBe(false); + expect(registry.canRender("productCard", "unknown", dummyProduct)).toBe(false); + }); + + it("should return false and log an error when condition throws", () => { + const errorCondition = () => { + throw new Error("Test condition error"); + }; + registry.register("productCard", "errTest", { component: DummyComponent, condition: errorCondition }); + const result = registry.canRender("productCard", "errTest", dummyProduct); + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'useExtensionRegistry: Error in condition for component "productCard/errTest"', + expect.any(Error), + ); + }); + }); +}); diff --git a/client-app/shared/common/composables/extensionRegistry/useExtensionRegistry.ts b/client-app/shared/common/composables/extensionRegistry/useExtensionRegistry.ts new file mode 100644 index 0000000000..2ac35d21b5 --- /dev/null +++ b/client-app/shared/common/composables/extensionRegistry/useExtensionRegistry.ts @@ -0,0 +1,114 @@ +import { createGlobalState } from "@vueuse/core"; +import pick from "lodash/pick"; +import { shallowReadonly, shallowRef } from "vue"; +import { IS_DEVELOPMENT } from "@/core/constants"; +import { Logger } from "@/core/utilities"; +import { initialExtensionRegistry } from "@/shared/common/constants/initialExtensionRegistry"; +import type { ExtensionCategoryType, ExtensionRegistryStateType } from "@/shared/common/types/extensionRegistry"; + +function _useExtensionRegistry() { + const entries = shallowRef(initialExtensionRegistry); + + function register( + category: C, + name: N, + item: ExtensionRegistryStateType[C][N], + ) { + if (!entries.value[category]) { + entries.value[category] = {}; + } + if (!entries.value[category][name]) { + entries.value[category][name] = item; + } else { + Logger.warn(`useExtensionRegistry: Component "${category}/${name}" already registered`); + } + } + + function unregister(category: C, name: string) { + delete entries.value[category]?.[name]; + } + + function getEntries(category: C, names?: string[]) { + if (names) { + return shallowReadonly(pick(entries.value[category], names)); + } + return shallowReadonly(entries.value[category] ?? {}); + } + + function getComponent( + category: C, + name: N, + ) { + return entries.value[category]?.[name]?.component ?? null; + } + + function isRegistered( + category: C, + name: N, + ) { + return entries.value[category]?.[name]?.component !== undefined; + } + + function canRender( + category: C, + name: N, + parameter: Parameters>[0], + ): boolean { + if (!isRegistered(category, name)) { + return false; + } + + const condition = entries.value[category]?.[name]?.condition as ExtensionRegistryStateType[C][N]["condition"]; + + if (condition && typeof condition === "function") { + try { + return condition(parameter); + } catch (error) { + Logger.error(`useExtensionRegistry: Error in condition for component "${category}/${name}"`, error); + return false; + } + } + + return true; + } + + function getProps( + category: C, + name: N, + ): ExtensionRegistryStateType[C][N]["props"] { + return entries.value[category]?.[name]?.props; + } + + // To debug in development mode + if (IS_DEVELOPMENT) { + window.VCExtensionRegistry = { + entries, + + register, + unregister, + + getComponent, + getEntries, + getProps, + + isRegistered, + canRender, + }; + } + + return { + entries, + + register, + unregister, + + getComponent, + getEntries, + getProps, + + isRegistered, + canRender, + }; +} + +export const useExtensionRegistry = createGlobalState(_useExtensionRegistry); diff --git a/client-app/shared/common/composables/index.ts b/client-app/shared/common/composables/index.ts index 0b44c8ae44..77d41b307b 100644 --- a/client-app/shared/common/composables/index.ts +++ b/client-app/shared/common/composables/index.ts @@ -1,2 +1,2 @@ -export * from "./useCustomProductComponents"; +export * from "./extensionRegistry/useExtensionRegistry"; export * from "./useSlugInfo"; diff --git a/client-app/shared/common/composables/useCustomProductComponents.ts b/client-app/shared/common/composables/useCustomProductComponents.ts deleted file mode 100644 index b0221898cf..0000000000 --- a/client-app/shared/common/composables/useCustomProductComponents.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createGlobalState } from "@vueuse/core"; -import { shallowRef } from "vue"; -import { Logger } from "@/core/utilities"; -import type { Product } from "@/core/api/graphql/types"; -import type { DefineComponent } from "vue"; - -export type ElementType = { - id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: DefineComponent<{ product: Product }, Record, any>; - shouldRender?: (product: Product) => boolean; - props?: Record; -}; - -function _useCustomProductComponents() { - const customProductComponents = shallowRef<{ [key: string]: ElementType }>({}); - - function registerComponent(element: ElementType) { - if (!customProductComponents.value[element.id]) { - customProductComponents.value[element.id] = element; - } else { - Logger.warn(`useCustomProductComponents: Custom product component with id ${element.id} already registered`); - } - } - - function getComponent(id: string) { - return customProductComponents.value[id]?.component; - } - - function isComponentRegistered(id: string) { - return customProductComponents.value[id] !== undefined; - } - - function shouldRenderComponent(id: string, product: Product) { - return typeof customProductComponents.value[id]?.shouldRender === "function" - ? customProductComponents.value[id]?.shouldRender(product) - : true; - } - - function getComponentProps(id: string) { - return customProductComponents.value[id]?.props; - } - - return { - registerComponent, - getComponent, - isComponentRegistered, - shouldRenderComponent, - getComponentProps, - }; -} - -export const useCustomProductComponents = createGlobalState(_useCustomProductComponents); diff --git a/client-app/shared/common/constants/customProductComponents.ts b/client-app/shared/common/constants/customProductComponents.ts deleted file mode 100644 index 59b42d1c96..0000000000 --- a/client-app/shared/common/constants/customProductComponents.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const CUSTOM_PRODUCT_COMPONENT_IDS = { - CARD_BUTTON: "card-button", - PAGE_SIDEBAR_BUTTON: "page-sidebar-button", -}; diff --git a/client-app/shared/common/constants/extensionPointsNames.ts b/client-app/shared/common/constants/extensionPointsNames.ts new file mode 100644 index 0000000000..e934ce8aa2 --- /dev/null +++ b/client-app/shared/common/constants/extensionPointsNames.ts @@ -0,0 +1,18 @@ +import merge from "lodash/merge"; +import { initialExtensionRegistry } from "./initialExtensionRegistry"; + +const INITIAL_EXTENSION_NAMES = Object.fromEntries( + Object.entries(initialExtensionRegistry).map(([key, entries]) => [ + key, + Object.fromEntries(Object.keys(entries).map((name) => [name, name])), + ]), +); + +export const EXTENSION_NAMES = merge({}, INITIAL_EXTENSION_NAMES, { + productCard: { + cardButton: "card-button", + }, + productPage: { + sidebarButton: "sidebar-button", + }, +}); diff --git a/client-app/shared/common/constants/index.ts b/client-app/shared/common/constants/index.ts index 45805303e9..897490a8fe 100644 --- a/client-app/shared/common/constants/index.ts +++ b/client-app/shared/common/constants/index.ts @@ -1 +1,2 @@ -export * from "./customProductComponents"; +export * from "./extensionPointsNames"; +export * from "./initialExtensionRegistry"; diff --git a/client-app/shared/common/constants/initialExtensionRegistry.ts b/client-app/shared/common/constants/initialExtensionRegistry.ts new file mode 100644 index 0000000000..7d641d7b4f --- /dev/null +++ b/client-app/shared/common/constants/initialExtensionRegistry.ts @@ -0,0 +1,44 @@ +import { defineAsyncComponent } from "vue"; +import type { ExtensionRegistryStateType } from "@/shared/common/types/extensionRegistry"; + +export const initialExtensionRegistry: ExtensionRegistryStateType = { + headerMenu: { + compare: { + component: defineAsyncComponent( + () => import("@/shared/layout/components/header/_internal/link-components/link-compare.vue"), + ), + }, + cart: { + component: defineAsyncComponent( + () => import("@/shared/layout/components/header/_internal/link-components/link-cart.vue"), + ), + }, + }, + mobileMenu: { + cart: { + component: defineAsyncComponent( + () => import("@/shared/layout/components/header/_internal/mobile-menu/link-components/link-cart.vue"), + ), + }, + compare: { + component: defineAsyncComponent( + () => import("@/shared/layout/components/header/_internal/mobile-menu/link-components/link-compare.vue"), + ), + }, + }, + accountMenu: { + orders: { + component: defineAsyncComponent( + () => import("@/shared/account/components/account-navigation-link-components/link-orders.vue"), + ), + }, + lists: { + component: defineAsyncComponent( + () => import("@/shared/account/components/account-navigation-link-components/link-lists.vue"), + ), + }, + }, + mobileHeader: {}, + productCard: {}, + productPage: {}, +}; diff --git a/client-app/shared/common/types/extensionRegistry.ts b/client-app/shared/common/types/extensionRegistry.ts new file mode 100644 index 0000000000..b58828d55e --- /dev/null +++ b/client-app/shared/common/types/extensionRegistry.ts @@ -0,0 +1,7 @@ +import type { ExtensionCategoryMapType } from "@/shared/common/types/extensionRegistryMap"; + +export type ExtensionRegistryStateType = { + [K in keyof ExtensionCategoryMapType]: Record; +}; + +export type ExtensionCategoryType = keyof ExtensionCategoryMapType; diff --git a/client-app/shared/common/types/extensionRegistryMap.ts b/client-app/shared/common/types/extensionRegistryMap.ts new file mode 100644 index 0000000000..f27f08dc0c --- /dev/null +++ b/client-app/shared/common/types/extensionRegistryMap.ts @@ -0,0 +1,29 @@ +import type { Product } from "@/core/api/graphql/types"; +import type { ExtendedMenuLinkType } from "@/core/types"; +import type { Component } from "vue"; + +type ExtensionEntryType< + Props = never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Condition extends (parameter: any) => boolean = never, +> = { + component: Component; + condition?: Condition; + props?: Props; +}; + +/** + * Here we define the extension categories and the extension entries for each category. + * ExtensionEntryType is a type that defines the extension entry for a given category. + */ +export type ExtensionCategoryMapType = { + headerMenu: ExtensionEntryType<{ item: ExtendedMenuLinkType }>; + mobileMenu: ExtensionEntryType<{ item: ExtendedMenuLinkType }>; + accountMenu: ExtensionEntryType<{ item: ExtendedMenuLinkType }>; + mobileHeader: ExtensionEntryType; + productCard: ExtensionEntryType< + { product?: Product; isTextShown?: boolean; lazy?: boolean }, + (product: Product) => boolean + >; + productPage: ExtensionEntryType<{ product?: Product }, (product: Product) => boolean>; +}; diff --git a/client-app/shared/common/types/types.d.ts b/client-app/shared/common/types/types.d.ts new file mode 100644 index 0000000000..1d892bc053 --- /dev/null +++ b/client-app/shared/common/types/types.d.ts @@ -0,0 +1,13 @@ +import type ExtensionPointList from "@/shared/common/components/extension-point-list.vue"; +import type ExtensionPoint from "@/shared/common/components/extension-point.vue"; + +declare module "vue" { + // Global components is already declared interface which we want to augment + // eslint-disable-next-line @typescript-eslint/naming-convention + export interface GlobalComponents { + ExtensionPoint: typeof ExtensionPoint; + ExtensionPointList: typeof ExtensionPointList; + } +} + +export {}; diff --git a/client-app/shared/common/types/window.d.ts b/client-app/shared/common/types/window.d.ts new file mode 100644 index 0000000000..56de2fda33 --- /dev/null +++ b/client-app/shared/common/types/window.d.ts @@ -0,0 +1,9 @@ +import type { useExtensionRegistry } from "@/shared/common/composables/extensionRegistry/useExtensionRegistry"; + +export {}; + +declare global { + interface Window { + VCExtensionRegistry: Partial>; + } +} diff --git a/client-app/shared/layout/components/header/_internal/bottom-header.vue b/client-app/shared/layout/components/header/_internal/bottom-header.vue index dfd1930a44..656324e260 100644 --- a/client-app/shared/layout/components/header/_internal/bottom-header.vue +++ b/client-app/shared/layout/components/header/_internal/bottom-header.vue @@ -41,7 +41,9 @@
  • - + + +
@@ -78,7 +80,6 @@ import { useRoute, useRouter } from "vue-router"; import { useNavigations, useWhiteLabeling } from "@/core/composables"; import { useUser } from "@/shared/account/composables/useUser"; import { SearchBar } from "@/shared/layout"; -import { useCustomHeaderLinkComponents } from "@/shared/layout/composables/useCustomHeaderLinkComponents"; import CatalogMenu from "./catalog-menu.vue"; import type { StyleValue } from "vue"; import LinkDefault from "@/shared/layout/components/header/_internal/link-components/link-default.vue"; @@ -93,7 +94,6 @@ const router = useRouter(); const { organization } = useUser(); const { logoUrl } = useWhiteLabeling(); const { catalogMenuItems, desktopMainMenuItems } = useNavigations(); -const { customLinkComponents } = useCustomHeaderLinkComponents(); const bottomHeader = ref(null); const catalogMenuElement = shallowRef(null); diff --git a/client-app/shared/layout/components/header/_internal/mobile-header.vue b/client-app/shared/layout/components/header/_internal/mobile-header.vue index 233404800e..76b1230595 100644 --- a/client-app/shared/layout/components/header/_internal/mobile-header.vue +++ b/client-app/shared/layout/components/header/_internal/mobile-header.vue @@ -53,7 +53,7 @@ - + @@ -141,7 +141,6 @@ import { QueryParamName } from "@/core/enums"; import { ROUTES } from "@/router/routes/constants"; import { useShortCart } from "@/shared/cart"; import { useNestedMobileHeader } from "@/shared/layout"; -import { useCustomMobileHeaderComponents } from "@/shared/layout/composables/useCustomMobileHeaderComponents"; import { useSearchBar } from "@/shared/layout/composables/useSearchBar"; import { ShipToSelector } from "@/shared/ship-to-location"; import MobileMenu from "./mobile-menu/mobile-menu.vue"; @@ -150,7 +149,6 @@ import type { RouteLocationRaw } from "vue-router"; import BarcodeScanner from "@/shared/layout/components/search-bar/barcode-scanner.vue"; const router = useRouter(); -const { customComponents } = useCustomMobileHeaderComponents(); const searchPhrase = ref(""); const searchPhraseInUrl = useRouteQueryParam(QueryParamName.SearchPhrase); const mobileMenuVisible = ref(false); diff --git a/client-app/shared/layout/components/header/_internal/mobile-menu/menus/default-menu.vue b/client-app/shared/layout/components/header/_internal/mobile-menu/menus/default-menu.vue index b0ca1d3347..d9d6757efe 100644 --- a/client-app/shared/layout/components/header/_internal/mobile-menu/menus/default-menu.vue +++ b/client-app/shared/layout/components/header/_internal/mobile-menu/menus/default-menu.vue @@ -1,33 +1,31 @@