Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
c8918c6
chore: enhance extension points mechanism
ivan-kalachikov May 19, 2025
ffea839
Merge branch 'dev' into chore/VCST-3192-extension-points
ivan-kalachikov May 20, 2025
6f7bd77
chore: enhance extension points mechanism
ivan-kalachikov May 20, 2025
4984fbf
chore: enhance extension points mechanism
ivan-kalachikov May 20, 2025
33f7b6f
chore: enhance extension points mechanism
ivan-kalachikov May 20, 2025
b8a6ff8
chore: enhance extension points mechanism
ivan-kalachikov May 20, 2025
6a0a5d9
feat: extend useComponentsRegistry to fit useCustomProductComponents …
ivan-kalachikov May 20, 2025
0e42e5c
chore: refactor according new implementation
ivan-kalachikov May 20, 2025
9748e4f
chore: refactor according new implementation
ivan-kalachikov May 20, 2025
f770a0e
chore: refactor according new implementation
ivan-kalachikov May 20, 2025
90cc362
chore: refactor according new implementation
ivan-kalachikov May 21, 2025
5d2fe9f
fix: types
ivan-kalachikov May 21, 2025
cd97d41
fix: types
ivan-kalachikov May 21, 2025
473b1f8
fix: types
ivan-kalachikov May 22, 2025
135fe22
feat: add extension-point component and refactor
ivan-kalachikov May 22, 2025
1f4a2a3
feat: wrap EP to plugin
ivan-kalachikov May 22, 2025
a519d57
chore: rename composable
ivan-kalachikov May 22, 2025
92237ef
chore: rename types file
ivan-kalachikov May 22, 2025
2dacabc
chore: rename props and methods of composable
ivan-kalachikov May 22, 2025
2f03f37
chore: rename props and methods of composable
ivan-kalachikov May 22, 2025
c1b0448
chore: rename types
ivan-kalachikov May 22, 2025
fe49f04
chore: minor refactor
ivan-kalachikov May 22, 2025
0fb26ab
chore: refactor and improvements
ivan-kalachikov May 22, 2025
6f52a26
feat: update documentation
ivan-kalachikov May 22, 2025
2b17313
chore: minor refactor
ivan-kalachikov May 22, 2025
d2548d6
feat: add unit tests
ivan-kalachikov May 22, 2025
b4b883d
fix: cycle dependencies issue, update tests
ivan-kalachikov May 23, 2025
1eae537
fix: tests
ivan-kalachikov May 23, 2025
deeca22
Merge branch 'dev' into chore/VCST-3192-extension-points
ivan-kalachikov May 23, 2025
4d8dfa7
fix: sonar issues
ivan-kalachikov May 23, 2025
7e0dff7
chore: refactor to use extension-point component
ivan-kalachikov May 23, 2025
1f7ab36
fix: refactor and tests
ivan-kalachikov May 23, 2025
4fd8ed7
feat: implement multi-extension-points component
ivan-kalachikov May 26, 2025
8a1bcb2
Merge branch 'dev' into chore/VCST-3192-extension-points
ivan-kalachikov May 26, 2025
1600129
fix: minor refactor and update readme
ivan-kalachikov May 26, 2025
23b29e2
fix: update readme
ivan-kalachikov May 26, 2025
6d285a2
fix: refactor and update readme
ivan-kalachikov May 26, 2025
3116c74
fix: minor refactor
ivan-kalachikov May 26, 2025
8c6b923
fix: rename the component
ivan-kalachikov Jun 10, 2025
47ac7c9
fix: rename the component
ivan-kalachikov Jun 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion client-app/app-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
20 changes: 20 additions & 0 deletions client-app/core/plugins/extension-points.plugin.ts
Original file line number Diff line number Diff line change
@@ -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:
* <ExtensionPoint v-if="$canRenderExtensionPoint('productCard', 'card-button', product)" />
*/
app.config.globalProperties.$canRenderExtensionPoint = canRender;

app.component("ExtensionPoint", ExtensionPoint);
app.component("ExtensionPointList", ExtensionPointList);
},
};
1 change: 1 addition & 0 deletions client-app/core/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 1 addition & 6 deletions client-app/modules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
16 changes: 7 additions & 9 deletions client-app/modules/back-in-stock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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,
});
}
}
37 changes: 11 additions & 26 deletions client-app/modules/push-messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -66,21 +62,6 @@ const menuItems: DeepPartial<MenuType> = {
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) {
Expand All @@ -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",
Expand All @@ -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.
}

Expand Down
25 changes: 12 additions & 13 deletions client-app/shared/account/components/account-navigation.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
<template>
<div class="space-y-6">
<VcWidget :title="$t(`shared.account.navigation.main_title`)" size="sm">
<component
:is="(link.id && customLinkComponents[link.id]) || LinkDefault"
<ExtensionPoint
v-for="link in filteredDesktopAccountMenuItems"
:key="link.id"
:item="link"
/>
category="accountMenu"
:name="link.id"
>
<LinkDefault :item="link" />
</ExtensionPoint>
</VcWidget>

<VcWidget v-if="isCorporateMember" :title="$t(`shared.account.navigation.corporate_title`)" size="sm">
<component
:is="(link.id && customLinkComponents[link.id]) || LinkDefault"
<ExtensionPoint
v-for="link in desktopCorporateMenuItems?.children"
:key="link.id"
:item="link"
/>
category="accountMenu"
:name="link.id"
>
<LinkDefault :item="link" />
</ExtensionPoint>
</VcWidget>
</div>
</template>
Expand All @@ -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);
Expand Down
17 changes: 6 additions & 11 deletions client-app/shared/catalog/components/product-card-grid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,11 @@
<VcItemPriceCatalog :with-from-label="product.hasVariations || product.isConfigurable" :value="price" />
</div>

<component
:is="getComponent(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON)"
v-if="
isComponentRegistered(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON) &&
shouldRenderComponent(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON, product)
"
<ExtensionPoint
v-if="$canRenderExtensionPoint('productCard', EXTENSION_NAMES.productCard.cardButton, product)"
:name="EXTENSION_NAMES.productCard.cardButton"
category="productCard"
:product="product"
v-bind="getComponentProps(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON)"
/>

<VcProductButton
Expand Down Expand Up @@ -218,8 +215,7 @@ import { computed, ref } from "vue";
import { PropertyType } from "@/core/api/graphql/types";
import { ProductType } from "@/core/enums";
import { getProductRoute, getPropertiesGroupedByName } from "@/core/utilities";
import { useCustomProductComponents } from "@/shared/common/composables";
import { CUSTOM_PRODUCT_COMPONENT_IDS } from "@/shared/common/constants";
import { EXTENSION_NAMES } from "@/shared/common/constants";
import { AddToCompareCatalog } from "@/shared/compare";
import { AddToList } from "@/shared/wishlists";
import CountInCart from "./count-in-cart.vue";
Expand All @@ -230,6 +226,7 @@ import type { Product } from "@/core/api/graphql/types";
import type { BrowserTargetType } from "@/core/types";
import type { Swiper as SwiperInstance } from "swiper/types";
import ProductRating from "@/modules/customer-reviews/components/product-rating.vue";

defineEmits<{ (eventName: "linkClick", globalEvent: MouseEvent): void }>();

const props = withDefaults(defineProps<IProps>(), {
Expand All @@ -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;
Expand Down
16 changes: 5 additions & 11 deletions client-app/shared/catalog/components/product-card-list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,11 @@
</div>

<div class="vc-product-card-list__add-to-cart mt-3 flex w-full flex-col sm:mt-0">
<component
:is="getComponent(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON)"
v-if="
isComponentRegistered(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON) &&
shouldRenderComponent(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON, product)
"
<ExtensionPoint
v-if="$canRenderExtensionPoint('productCard', EXTENSION_NAMES.productCard.cardButton, product)"
:name="EXTENSION_NAMES.productCard.cardButton"
category="productCard"
:product="product"
v-bind="getComponentProps(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON)"
/>

<VcProductButton
Expand Down Expand Up @@ -150,8 +147,7 @@ import { computed } from "vue";
import { PropertyType } from "@/core/api/graphql/types";
import { ProductType } from "@/core/enums";
import { getProductRoute, getPropertiesGroupedByName } from "@/core/utilities";
import { useCustomProductComponents } from "@/shared/common/composables";
import { CUSTOM_PRODUCT_COMPONENT_IDS } from "@/shared/common/constants";
import { EXTENSION_NAMES } from "@/shared/common/constants";
import { AddToCompareCatalog } from "@/shared/compare";
import { AddToList } from "@/shared/wishlists";
import CountInCart from "./count-in-cart.vue";
Expand All @@ -177,8 +173,6 @@ interface IProps {

console.warn("ProductCardList is deprecated. Use VcProductCard or ProductCard instead.");

const { isComponentRegistered, getComponent, shouldRenderComponent, getComponentProps } = useCustomProductComponents();

const link = computed(() => getProductRoute(props.product.id, props.product.slug));
const isDigital = computed(() => props.product.productType === ProductType.Digital);
const properties = computed(() =>
Expand Down
16 changes: 5 additions & 11 deletions client-app/shared/catalog/components/product-card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,11 @@
:single-line="viewMode === 'grid'"
/>

<component
:is="getComponent(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON)"
v-if="
isComponentRegistered(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON) &&
shouldRenderComponent(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON, product)
"
<ExtensionPoint
v-if="$canRenderExtensionPoint('productCard', EXTENSION_NAMES.productCard.cardButton, product)"
:name="EXTENSION_NAMES.productCard.cardButton"
category="productCard"
:product="product"
v-bind="getComponentProps(CUSTOM_PRODUCT_COMPONENT_IDS.CARD_BUTTON)"
/>

<VcProductButton
Expand Down Expand Up @@ -105,8 +102,7 @@ import {
ENABLED_KEY as CUSTOMER_REVIEWS_ENABLED_KEY,
} from "@/modules/customer-reviews/constants";
import { AddToCart } from "@/shared/cart";
import { useCustomProductComponents } from "@/shared/common/composables";
import { CUSTOM_PRODUCT_COMPONENT_IDS } from "@/shared/common/constants";
import { EXTENSION_NAMES } from "@/shared/common/constants";
import { AddToCompareCatalog } from "@/shared/compare";
import { AddToList } from "@/shared/wishlists";
import BadgesWrapper from "./badges-wrapper.vue";
Expand Down Expand Up @@ -134,8 +130,6 @@ defineEmits<IEmits>();

const props = defineProps<IProps>();

const { isComponentRegistered, getComponent, shouldRenderComponent, getComponentProps } = useCustomProductComponents();

const { isEnabled } = useModuleSettings(CUSTOMER_REVIEWS_MODULE_ID);
const productReviewsEnabled = isEnabled(CUSTOMER_REVIEWS_ENABLED_KEY);

Expand Down
16 changes: 6 additions & 10 deletions client-app/shared/catalog/components/product-sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,13 @@
</div>

<div class="mt-4 print:hidden">
<component
:is="getComponent(CUSTOM_PRODUCT_COMPONENT_IDS.PAGE_SIDEBAR_BUTTON)"
v-if="
isComponentRegistered(CUSTOM_PRODUCT_COMPONENT_IDS.PAGE_SIDEBAR_BUTTON) &&
shouldRenderComponent(CUSTOM_PRODUCT_COMPONENT_IDS.PAGE_SIDEBAR_BUTTON, product)
"
<ExtensionPoint
v-if="$canRenderExtensionPoint('productPage', EXTENSION_NAMES.productPage.sidebarButton, product)"
:name="EXTENSION_NAMES.productPage.sidebarButton"
category="productCard"
:product="product"
v-bind="getComponentProps(CUSTOM_PRODUCT_COMPONENT_IDS.PAGE_SIDEBAR_BUTTON)"
/>

<AddToCart v-else :product="product">
<InStock
:is-in-stock="product.availabilityData?.isInStock"
Expand All @@ -60,8 +58,7 @@ import { useCurrency } from "@/core/composables";
import { ProductType } from "@/core/enums";
import { AddToCart, useShortCart } from "@/shared/cart";
import { useConfigurableProduct } from "@/shared/catalog/composables";
import { useCustomProductComponents } from "@/shared/common/composables";
import { CUSTOM_PRODUCT_COMPONENT_IDS } from "@/shared/common/constants";
import { EXTENSION_NAMES } from "@/shared/common/constants";
import CountInCart from "./count-in-cart.vue";
import InStock from "./in-stock.vue";
import ProductPriceBlock from "./product-price-block.vue";
Expand All @@ -79,7 +76,6 @@ const product = toRef(props, "product");
const { currentCurrency } = useCurrency();
const { getItemsTotal } = useShortCart();
const { configuredLineItem, loading: configuredLineItemLoading } = useConfigurableProduct(product.value.id);
const { getComponent, isComponentRegistered, shouldRenderComponent, getComponentProps } = useCustomProductComponents();

const isDigital = computed<boolean>(() => props.product.productType === ProductType.Digital);

Expand Down
Loading
Loading