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() {