Skip to content
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions rspack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@
class="page"
dontUpdateWhenChangesLanguage
/>
<SystemIntegrations
:modelValue="['integrations'].includes($route.name)"
/>

<ExternalSystem
ref="system-flows"
Expand Down Expand Up @@ -160,6 +163,7 @@ import ModalRegistered from './views/register/ModalRegistered.vue';
import SystemIntelligences from './components/SystemIntelligences.vue';
import SystemCommerce from './components/SystemCommerce.vue';
import SystemInsights from './components/SystemInsights.vue';
import SystemIntegrations from './components/SystemIntegrations.vue';
import moment from 'moment-timezone';
import { waitFor } from './utils/waitFor.js';
import { PROJECT_COMMERCE } from '@/utils/constants';
Expand Down Expand Up @@ -192,6 +196,7 @@ export default {
PosRegister,
ModalRegistered,
SystemInsights,
SystemIntegrations,
},

data() {
Expand Down Expand Up @@ -425,7 +430,6 @@ export default {
return false;
}

this.$refs['system-integrations']?.reset();
this.$refs['system-flows']?.reset();
this.$refs['system-chats']?.reset();

Expand Down Expand Up @@ -782,8 +786,6 @@ export default {
this.$refs['system-api-nexus'].init(this.$route.params);
} else if (current === 'academy') {
this.$refs['system-academy'].init(this.$route.params);
} else if (current === 'integrations') {
this.$refs['system-integrations'].init(this.$route.params);
} else if (current === 'studio' || current === 'push') {
this.$refs['system-flows'].init(this.$route.params);
} else if (current === 'chats') {
Expand Down
11 changes: 9 additions & 2 deletions src/components/Sidebar/SidebarOption.vue
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,15 @@ function navigate(defaultNavigate) {
const isCurrentRoute = isActive(url);

if (isCurrentRoute) {
if (url?.includes('insights')) {
window.dispatchEvent(new CustomEvent('forceRemountInsights'));
const moduleToEventMap = {
insights: 'forceRemountInsights',
integrations: 'forceRemountIntegrations',
};

for (const [module, event] of Object.entries(moduleToEventMap)) {
if (url?.includes(module)) {
window.dispatchEvent(new CustomEvent(event));
}
}

if (url?.includes('agent-builder')) {
Expand Down
149 changes: 149 additions & 0 deletions src/components/SystemIntegrations.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<script setup>
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();
}
},
);
</script>

<template>
<LoadingModule
data-testid="integrations-loading"
:isModuleRoute="isIntegrationsRoute"
:hasModuleApp="!!integrationsApp"
/>
<template v-if="sharedStore.auth.token && sharedStore.current.project.uuid">
<section
v-if="!useIframe"
v-show="integrationsApp && isIntegrationsRoute"
id="integrations-app"
class="system-integrations__system"
data-testid="integrations-app"
/>
<ExternalSystem
v-else
v-show="isIntegrationsRoute"
ref="iframeIntegrations"
data-testid="integrations-iframe"
:routes="['integrations']"
class="system-integrations__iframe"
dontUpdateWhenChangesLanguage
name="integrations"
/>
</template>
</template>

<style scoped lang="scss">
.system-integrations__system {
height: 100%;
}
.system-integrations__loading {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.system-integrations__iframe {
flex: 1;
overflow: auto;
}
</style>
132 changes: 132 additions & 0 deletions src/components/__tests__/SystemIntegrations.spec.js
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
4 changes: 4 additions & 0 deletions src/composables/useModuleUpdateRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const moduleMockPaths = [
'insights/main',
'insights/dashboard-commerce',
'commerce/solution-card',
'integrations/main',
];

function generateModulesLocalesAliases() {
Expand Down
Loading