diff --git a/Dockerfile b/Dockerfile
index 97eb56f9..9653988b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -34,6 +34,7 @@ ARG GROWTHBOOK_CLIENT_KEY
ARG GROWTHBOOK_API_HOST
ARG MODULE_FEDERATION_COMMERCE_URL
ARG MODULE_FEDERATION_INSIGHTS_URL
+ARG MODULE_FEDERATION_INTEGRATIONS_URL
ARG PUBLIC_PATH_URL
ARG APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_TRIAL
ARG APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_NON_TRIAL
@@ -73,6 +74,7 @@ ARG GROWTHBOOK_CLIENT_KEY
ARG GROWTHBOOK_API_HOST
ARG MODULE_FEDERATION_COMMERCE_URL
ARG MODULE_FEDERATION_INSIGHTS_URL
+ARG MODULE_FEDERATION_INTEGRATIONS_URL
ARG PUBLIC_PATH_URL
ARG APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_TRIAL
ARG APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_NON_TRIAL
@@ -107,6 +109,7 @@ ENV GROWTHBOOK_CLIENT_KEY=${GROWTHBOOK_CLIENT_KEY}
ENV GROWTHBOOK_API_HOST=${GROWTHBOOK_API_HOST}
ENV MODULE_FEDERATION_COMMERCE_URL=${MODULE_FEDERATION_COMMERCE_URL}
ENV MODULE_FEDERATION_INSIGHTS_URL=${MODULE_FEDERATION_INSIGHTS_URL}
+ENV MODULE_FEDERATION_INTEGRATIONS_URL=${MODULE_FEDERATION_INTEGRATIONS_URL}
ENV PUBLIC_PATH_URL=${PUBLIC_PATH_URL}
ENV APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_TRIAL=${APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_TRIAL}
ENV APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_NON_TRIAL=${APPOINTMENT_LINK_FOR_AGENT_BUILDER_2_0_NON_TRIAL}
diff --git a/rspack.config.js b/rspack.config.js
index 5597e428..d1edddda 100644
--- a/rspack.config.js
+++ b/rspack.config.js
@@ -96,6 +96,7 @@ module.exports = defineConfig({
remotes: {
commerce: `commerce@${process.env.MODULE_FEDERATION_COMMERCE_URL}/remoteEntry.js`,
insights: `insights@${process.env.MODULE_FEDERATION_INSIGHTS_URL}/remoteEntry.js`,
+ integrations: `integrations@${process.env.MODULE_FEDERATION_INTEGRATIONS_URL}/remoteEntry.js`,
},
exposes: {
'./sharedStore': './src/store/Shared.js',
diff --git a/src/app.vue b/src/app.vue
index 8024fb78..3aa65be7 100644
--- a/src/app.vue
+++ b/src/app.vue
@@ -89,6 +89,9 @@
class="page"
dontUpdateWhenChangesLanguage
/>
+
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { tryImportWithRetries } from '../utils/moduleFederation';
+import { useSharedStore } from '@/store/Shared';
+import ExternalSystem from './ExternalSystem.vue';
+import { useModuleUpdateRoute } from '@/composables/useModuleUpdateRoute';
+import LoadingModule from '@/components/modules/LoadingModule.vue';
+
+const integrationsApp = ref(null);
+const integrationsRouter = ref(null);
+const useIframe = ref(false);
+const iframeIntegrations = ref(null);
+
+const isIntegrationsRoute = computed(() =>
+ ['integrations'].includes(route.name),
+);
+
+const props = defineProps({
+ modelValue: {
+ type: Boolean,
+ default: false,
+ },
+});
+
+const route = useRoute();
+const sharedStore = useSharedStore();
+
+const { getInitialModuleRoute } = useModuleUpdateRoute('integrations');
+
+async function mount({ force = false } = {}) {
+ console.log('[integrations] mount', force, props.modelValue);
+ if (!force && !props.modelValue) return;
+
+ const mountIntegrationsApp = await tryImportWithRetries(
+ () => import('integrations/main'),
+ 'integrations/main',
+ );
+
+ if (!mountIntegrationsApp) {
+ fallbackToIframe();
+ return;
+ }
+
+ const initialRoute = getInitialModuleRoute();
+
+ const { app, router } = await mountIntegrationsApp({
+ containerId: 'integrations-app',
+ initialRoute,
+ });
+
+ integrationsApp.value = app;
+ integrationsRouter.value = router;
+}
+
+function fallbackToIframe() {
+ console.log('[integrations] fallbackToIframe');
+ useIframe.value = true;
+ nextTick(() => {
+ iframeIntegrations.value?.init(route.params);
+ });
+}
+
+function unmount() {
+ integrationsApp.value?.unmount();
+ integrationsApp.value = null;
+}
+
+async function remount() {
+ await integrationsRouter.value.replace({ name: 'Discovery' });
+ unmount();
+ await nextTick();
+ mount({ force: true });
+}
+
+onMounted(() => {
+ window.addEventListener('forceRemountIntegrations', remount);
+});
+
+onUnmounted(() => {
+ unmount();
+ window.removeEventListener('forceRemountIntegrations', remount);
+});
+
+watch(
+ () => props.modelValue,
+ () => {
+ if (!integrationsApp.value) {
+ console.log('[integrations] shared store on mount', sharedStore);
+ mount();
+ }
+ },
+ { immediate: true },
+);
+
+watch(
+ () => sharedStore.current.project.uuid,
+ (newProjectUuid, oldProjectUuid) => {
+ if (newProjectUuid !== oldProjectUuid) {
+ useIframe.value ? iframeIntegrations.value?.reset() : unmount();
+ }
+ },
+);
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/__tests__/SystemIntegrations.spec.js b/src/components/__tests__/SystemIntegrations.spec.js
new file mode 100644
index 00000000..f56be523
--- /dev/null
+++ b/src/components/__tests__/SystemIntegrations.spec.js
@@ -0,0 +1,132 @@
+import { shallowMount } from '@vue/test-utils';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import SystemIntegrations from '../SystemIntegrations.vue';
+import { createRouter, createWebHistory } from 'vue-router';
+import { createTestingPinia } from '@pinia/testing';
+import { useSharedStore } from '@/store/Shared';
+
+const mockMountIntegrationsApp = vi.hoisted(() =>
+ vi.fn().mockReturnValue({
+ unmount: vi.fn(),
+ }),
+);
+
+vi.mock('@/utils/moduleFederation', () => ({
+ tryImportWithRetries: vi.fn().mockResolvedValue(mockMountIntegrationsApp),
+}));
+
+const sharedStoreMock = vi.hoisted(() => {
+ const { reactive } = require('vue');
+ return reactive({
+ current: {
+ project: { uuid: 'test-uuid' },
+ },
+ auth: {
+ token: 'mock-token',
+ },
+ });
+});
+
+vi.mock('@/store/Shared', () => ({
+ useSharedStore: vi.fn().mockReturnValue(sharedStoreMock),
+}));
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ {
+ path: '/:projectUuid/integrations',
+ name: 'integrations',
+ },
+ ],
+});
+
+describe('SystemIntegrations', () => {
+ let wrapper;
+ let sharedStore;
+
+ beforeEach(async () => {
+ wrapper = shallowMount(SystemIntegrations, {
+ props: {
+ modelValue: true,
+ },
+ global: {
+ plugins: [createTestingPinia(), router],
+ },
+ });
+
+ await router.push({
+ name: 'integrations',
+ params: { projectUuid: 'test-uuid' },
+ });
+
+ sharedStore = useSharedStore();
+ sharedStore.auth.token = 'mock-token';
+ sharedStore.current.project.uuid = 'test-uuid';
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ });
+
+ it('renders loading state when integrations app is not mounted', async () => {
+ wrapper.vm.integrationsApp = null;
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ wrapper.findComponent('[data-testid="integrations-loading"]').exists(),
+ ).toBe(true);
+ });
+
+ it('mounts integrations app when modelValue is true', async () => {
+ wrapper.vm.integrationsApp = null;
+
+ await wrapper.vm.mount();
+ await wrapper.vm.$nextTick();
+
+ expect(mockMountIntegrationsApp).toHaveBeenCalled();
+ expect(wrapper.find('[data-testid="integrations-app"]').exists()).toBe(true);
+ });
+
+ it('falls back to iframe when feature flag is disabled', async () => {
+ wrapper.vm.integrationsApp = null;
+ wrapper.vm.useIframe = true;
+
+ await wrapper.vm.$nextTick();
+ expect(
+ wrapper.findComponent('[data-testid="integrations-iframe"]').exists(),
+ ).toBe(true);
+ });
+
+ it('sets integrations app to null when component is unmounted', async () => {
+ await wrapper.vm.mount();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.integrationsApp).not.toBe(null);
+ await wrapper.vm.unmount();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.integrationsApp).toBe(null);
+ });
+
+ it('does not render when token is missing', async () => {
+ sharedStore.auth.token = null;
+
+ await wrapper.vm.mount();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('[data-testid="integrations-app"]').exists()).toBe(
+ false,
+ );
+ });
+
+ it('does not render when current project is missing', async () => {
+ sharedStore.current.project.uuid = null;
+
+ await wrapper.vm.mount();
+
+ expect(wrapper.find('[data-testid="integrations-app"]').exists()).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/composables/useModuleUpdateRoute.js b/src/composables/useModuleUpdateRoute.js
index d6b9c57b..0306fcc3 100644
--- a/src/composables/useModuleUpdateRoute.js
+++ b/src/composables/useModuleUpdateRoute.js
@@ -11,6 +11,10 @@ export function useModuleUpdateRoute(routeName) {
const route = useRoute();
const handleUpdateRoute = (event) => {
+ if (!route.path.includes(routeName)) {
+ return;
+ }
+
const path = event.detail.path
.split('/')
.slice(1)
diff --git a/vite.config.js b/vite.config.js
index 3dfa1407..4cec72d3 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -14,6 +14,7 @@ const moduleMockPaths = [
'insights/main',
'insights/dashboard-commerce',
'commerce/solution-card',
+ 'integrations/main',
];
function generateModulesLocalesAliases() {