feat(editor): Implement new app layout (#10548)

This commit is contained in:
Milorad FIlipović 2024-08-28 14:01:05 +02:00 committed by GitHub
parent d7241cfc3a
commit 95a9cd2c73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 316 additions and 153 deletions

View file

@ -7,7 +7,7 @@ import {
WorkflowSharingModal, WorkflowSharingModal,
WorkflowsPage, WorkflowsPage,
} from '../pages'; } from '../pages';
import { getVisibleDropdown, getVisibleSelect } from '../utils'; import { getVisibleDropdown, getVisiblePopper, getVisibleSelect } from '../utils';
import * as projects from '../composables/projects'; import * as projects from '../composables/projects';
/** /**
@ -180,7 +180,8 @@ describe('Sharing', { disableAutoLogin: true }, () => {
).should('be.visible'); ).should('be.visible');
credentialsModal.getters.usersSelect().click(); credentialsModal.getters.usersSelect().click();
cy.getByTestId('project-sharing-info') getVisiblePopper()
.find('[data-test-id="project-sharing-info"]')
.filter(':visible') .filter(':visible')
.should('have.length', 3) .should('have.length', 3)
.contains(INSTANCE_ADMIN.email) .contains(INSTANCE_ADMIN.email)

View file

@ -34,7 +34,7 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatInputWrapper().should('not.exist'); aiAssistant.getters.chatInputWrapper().should('not.exist');
aiAssistant.getters.closeChatButton().should('be.visible'); aiAssistant.getters.closeChatButton().should('be.visible');
aiAssistant.getters.closeChatButton().click(); aiAssistant.getters.closeChatButton().click();
aiAssistant.getters.askAssistantChat().should('not.exist'); aiAssistant.getters.askAssistantChat().should('not.be.visible');
}); });
it('should resize assistant chat up', () => { it('should resize assistant chat up', () => {
@ -162,13 +162,13 @@ describe('AI Assistant::enabled', () => {
cy.createFixtureWorkflow('aiAssistant/test_workflow.json'); cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Edit Fields'); wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click(); ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click(); aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
cy.wait('@chatRequest'); cy.wait('@chatRequest');
aiAssistant.getters.closeChatButton().click(); aiAssistant.getters.closeChatButton().click();
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
wf.actions.openNode('Stop and Error'); wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click(); ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click(); aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
// Since we already have an active session, a warning should be shown // Since we already have an active session, a warning should be shown
aiAssistant.getters.newAssistantSessionModal().should('be.visible'); aiAssistant.getters.newAssistantSessionModal().should('be.visible');
aiAssistant.getters aiAssistant.getters

View file

@ -199,7 +199,7 @@ describe('NDV', () => {
.contains(key) .contains(key)
.should('be.visible'); .should('be.visible');
}); });
getObjectValueItem().find('label').click(); getObjectValueItem().find('label').click({ force: true });
expandedObjectProps.forEach((key) => { expandedObjectProps.forEach((key) => {
ndv.getters ndv.getters
.outputPanel() .outputPanel()

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import LoadingView from '@/views/LoadingView.vue'; import LoadingView from '@/views/LoadingView.vue';
import BannerStack from '@/components/banners/BannerStack.vue'; import BannerStack from '@/components/banners/BannerStack.vue';
@ -9,7 +9,7 @@ import Telemetry from '@/components/Telemetry.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/AskAssistantFloatingButton.vue'; import AskAssistantFloatingButton from '@/components/AskAssistant/AskAssistantFloatingButton.vue';
import { loadLanguage } from '@/plugins/i18n'; import { loadLanguage } from '@/plugins/i18n';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { HIRING_BANNER, VIEWS } from '@/constants'; import { APP_MODALS_ELEMENT_ID, HIRING_BANNER, VIEWS } from '@/constants';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import { useAssistantStore } from './stores/assistant.store'; import { useAssistantStore } from './stores/assistant.store';
import { useUIStore } from './stores/ui.store'; import { useUIStore } from './stores/ui.store';
@ -32,34 +32,56 @@ const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO); const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtons); const showAssistantButton = computed(() => assistantStore.canShowAssistantButtons);
const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
watch(defaultLocale, (newLocale) => { watch(defaultLocale, (newLocale) => {
void loadLanguage(newLocale); void loadLanguage(newLocale);
}); });
onMounted(async () => {
logHiringBanner();
void useExternalHooks().run('app.mount');
loading.value = false;
window.addEventListener('resize', updateGridWidth);
await updateGridWidth();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateGridWidth);
});
// As assistant sidebar width changes, recalculate the total width regularly
watch(assistantSidebarWidth, async () => {
await updateGridWidth();
});
const logHiringBanner = () => { const logHiringBanner = () => {
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) { if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
console.log(HIRING_BANNER); console.log(HIRING_BANNER);
} }
}; };
onMounted(async () => { const updateGridWidth = async () => {
logHiringBanner(); await nextTick();
void useExternalHooks().run('app.mount'); if (appGrid.value) {
loading.value = false; uiStore.appGridWidth = appGrid.value.clientWidth;
}); }
};
</script> </script>
<template> <template>
<div :class="[$style.app, 'root-container']">
<LoadingView v-if="loading" /> <LoadingView v-if="loading" />
<div <div
v-else v-else
id="app" id="n8n-app"
:class="{ :class="{
[$style.container]: true, [$style.container]: true,
[$style.sidebarCollapsed]: uiStore.sidebarMenuCollapsed, [$style.sidebarCollapsed]: uiStore.sidebarMenuCollapsed,
}" }"
> >
<div id="app-grid" ref="appGrid" :class="$style['app-grid']">
<div id="banners" :class="$style.banners"> <div id="banners" :class="$style.banners">
<BannerStack v-if="!isDemoMode" /> <BannerStack v-if="!isDemoMode" />
</div> </div>
@ -77,29 +99,37 @@ onMounted(async () => {
<component :is="Component" v-else /> <component :is="Component" v-else />
</router-view> </router-view>
</div> </div>
<AskAssistantChat /> <div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
<Modals /> <Modals />
</div>
<Telemetry /> <Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" /> <AskAssistantFloatingButton v-if="showAssistantButton" />
</div> </div>
<AskAssistantChat />
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.app { // On the root level, whole app is a flex container
// with app grid and assistant sidebar as children
.container {
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
display: flex;
} }
.container { // App grid is the main app layout including modals and other absolute positioned elements
.app-grid {
position: relative;
display: grid; display: grid;
grid-template-areas:
'banners banners rightsidebar'
'sidebar header rightsidebar'
'sidebar content rightsidebar';
grid-auto-columns: minmax(0, max-content) minmax(100px, auto) minmax(0, max-content);
grid-template-rows: auto fit-content($header-height) 1fr;
height: 100vh; height: 100vh;
flex-basis: 100%;
grid-template-areas:
'banners banners'
'sidebar header'
'sidebar content';
grid-auto-columns: minmax(0, max-content) 1fr;
grid-template-rows: auto fit-content($header-height) 1fr;
} }
.banners { .banners {
@ -132,4 +162,8 @@ onMounted(async () => {
height: 100%; height: 100%;
z-index: 999; z-index: 999;
} }
.modals {
width: 100%;
}
</style> </style>

View file

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import type { ISettingsState } from '@/Interface'; import type { ISettingsState } from '@/Interface';
import { UserManagementAuthenticationMethod } from '@/Interface'; import { UserManagementAuthenticationMethod } from '@/Interface';
import { defaultSettings } from './defaults'; import { defaultSettings } from './defaults';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
/** /**
* Retries the given assertion until it passes or the timeout is reached * Retries the given assertion until it passes or the timeout is reached
@ -90,3 +91,20 @@ export const getSelectedDropdownValue = async (items: NodeListOf<Element>) => {
expect(selectedItem).toBeInTheDocument(); expect(selectedItem).toBeInTheDocument();
return selectedItem?.querySelector('p')?.textContent?.trim(); return selectedItem?.querySelector('p')?.textContent?.trim();
}; };
/**
* Create a container for teleported modals
*
* More info: https://test-utils.vuejs.org/guide/advanced/teleport#Mounting-the-Component
* @returns {HTMLElement} appModals
*/
export const createAppModals = () => {
const appModals = document.createElement('div');
appModals.id = APP_MODALS_ELEMENT_ID;
document.body.appendChild(appModals);
return appModals;
};
export const cleanupAppModals = () => {
document.body.innerHTML = '';
};

View file

@ -67,7 +67,7 @@ function onClose() {
<template> <template>
<SlideTransition> <SlideTransition>
<n8n-resize-wrapper <n8n-resize-wrapper
v-if="assistantStore.isAssistantOpen" v-show="assistantStore.isAssistantOpen"
:supported-directions="['left']" :supported-directions="['left']"
:width="assistantStore.chatWidth" :width="assistantStore.chatWidth"
:class="$style.container" :class="$style.container"
@ -95,9 +95,9 @@ function onClose() {
<style module> <style module>
.container { .container {
grid-area: rightsidebar;
height: 100%; height: 100%;
z-index: 99999; /* Needs to be high enough so it doesn't get covered by element-ui dialogs */ flex-basis: content;
z-index: 300;
} }
.wrapper { .wrapper {

View file

@ -46,9 +46,10 @@ const startNewSession = async () => {
<Modal <Modal
width="460px" width="460px"
height="250px" height="250px"
data-test-id="new-assistant-session-modal"
:name="NEW_ASSISTANT_SESSION_MODAL" :name="NEW_ASSISTANT_SESSION_MODAL"
:center="true" :center="true"
data-test-id="new-assistant-session-modal" :append-to-body="true"
> >
<template #header> <template #header>
{{ i18n.baseText('aiAssistant.newSessionModal.title.part1') }} {{ i18n.baseText('aiAssistant.newSessionModal.title.part1') }}

View file

@ -5,6 +5,7 @@ import Assignment from '../Assignment.vue';
import { defaultSettings } from '@/__tests__/defaults'; import { defaultSettings } from '@/__tests__/defaults';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const DEFAULT_SETUP = { const DEFAULT_SETUP = {
pinia: createTestingPinia({ pinia: createTestingPinia({
@ -24,7 +25,12 @@ const DEFAULT_SETUP = {
const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP); const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP);
describe('Assignment.vue', () => { describe('Assignment.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => { afterEach(() => {
cleanupAppModals();
vi.clearAllMocks(); vi.clearAllMocks();
}); });

View file

@ -6,7 +6,7 @@ import { fireEvent, within } from '@testing-library/vue';
import * as workflowHelpers from '@/composables/useWorkflowHelpers'; import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import AssignmentCollection from '../AssignmentCollection.vue'; import AssignmentCollection from '../AssignmentCollection.vue';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
const DEFAULT_SETUP = { const DEFAULT_SETUP = {
pinia: createTestingPinia({ pinia: createTestingPinia({
@ -64,8 +64,13 @@ async function dropAssignment({
} }
describe('AssignmentCollection.vue', () => { describe('AssignmentCollection.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
cleanupAppModals();
}); });
it('renders empty state properly', async () => { it('renders empty state properly', async () => {

View file

@ -20,6 +20,8 @@ import { useDebounce } from '@/composables/useDebounce';
import DraggableTarget from './DraggableTarget.vue'; import DraggableTarget from './DraggableTarget.vue';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop'; import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
type Props = { type Props = {
parameter: INodeProperties; parameter: INodeProperties;
path: string; path: string;
@ -122,8 +124,8 @@ async function onDrop(expression: string, event: MouseEvent) {
<template> <template>
<el-dialog <el-dialog
width="calc(100vw - var(--spacing-3xl))" width="calc(100% - var(--spacing-3xl))"
append-to-body :append-to="`#${APP_MODALS_ELEMENT_ID}`"
:class="$style.modal" :class="$style.modal"
:model-value="dialogVisible" :model-value="dialogVisible"
:before-close="closeDialog" :before-close="closeDialog"

View file

@ -1,5 +1,5 @@
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue'; import FilterConditions from '@/components/FilterConditions/FilterConditions.vue';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
@ -35,8 +35,13 @@ const DEFAULT_SETUP = {
const renderComponent = createComponentRenderer(FilterConditions, DEFAULT_SETUP); const renderComponent = createComponentRenderer(FilterConditions, DEFAULT_SETUP);
describe('FilterConditions.vue', () => { describe('FilterConditions.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
cleanupAppModals();
}); });
it('renders default state properly', async () => { it('renders default state properly', async () => {

View file

@ -6,6 +6,7 @@ import { mapStores } from 'pinia';
import type { EventBus } from 'n8n-design-system'; import type { EventBus } from 'n8n-design-system';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import type { ModalKey } from '@/Interface'; import type { ModalKey } from '@/Interface';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
export default defineComponent({ export default defineComponent({
name: 'Modal', name: 'Modal',
@ -13,15 +14,19 @@ export default defineComponent({
...ElDialog.props, ...ElDialog.props,
name: { name: {
type: String as PropType<ModalKey>, type: String as PropType<ModalKey>,
required: true,
}, },
title: { title: {
type: String, type: String,
default: '',
}, },
subtitle: { subtitle: {
type: String, type: String,
default: '',
}, },
eventBus: { eventBus: {
type: Object as PropType<EventBus>, type: Object as PropType<EventBus>,
default: null,
}, },
showClose: { showClose: {
type: Boolean, type: Boolean,
@ -35,31 +40,39 @@ export default defineComponent({
}, },
beforeClose: { beforeClose: {
type: Function, type: Function,
default: null,
}, },
customClass: { customClass: {
type: String, type: String,
default: '',
}, },
center: { center: {
type: Boolean, type: Boolean,
default: true,
}, },
width: { width: {
type: String, type: String,
default: '50%', default: '50%',
}, },
minWidth: { minWidth: {
type: String, type: [String, null] as PropType<string | null>,
default: null,
}, },
maxWidth: { maxWidth: {
type: String, type: [String, null] as PropType<string | null>,
default: null,
}, },
height: { height: {
type: String, type: [String, null] as PropType<string | null>,
default: null,
}, },
minHeight: { minHeight: {
type: String, type: [String, null] as PropType<string | null>,
default: null,
}, },
maxHeight: { maxHeight: {
type: String, type: [String, null] as PropType<string | null>,
default: null,
}, },
scrollable: { scrollable: {
type: Boolean, type: Boolean,
@ -79,23 +92,10 @@ export default defineComponent({
}, },
appendToBody: { appendToBody: {
type: Boolean, type: Boolean,
default: true, default: false,
}, },
}, },
mounted() { emits: { enter: null },
window.addEventListener('keydown', this.onWindowKeydown);
this.eventBus?.on('close', this.closeDialog);
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
},
beforeUnmount() {
this.eventBus?.off('close', this.closeDialog);
window.removeEventListener('keydown', this.onWindowKeydown);
},
computed: { computed: {
...mapStores(useUIStore), ...mapStores(useUIStore),
styles() { styles() {
@ -117,6 +117,23 @@ export default defineComponent({
} }
return styles; return styles;
}, },
appModalsId() {
return `#${APP_MODALS_ELEMENT_ID}`;
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
this.eventBus?.on('close', this.closeDialog);
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
},
beforeUnmount() {
this.eventBus?.off('close', this.closeDialog);
window.removeEventListener('keydown', this.onWindowKeydown);
}, },
methods: { methods: {
onWindowKeydown(event: KeyboardEvent) { onWindowKeydown(event: KeyboardEvent) {
@ -175,9 +192,11 @@ export default defineComponent({
:close-on-click-modal="closeOnClickModal" :close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape" :close-on-press-escape="closeOnPressEscape"
:style="styles" :style="styles"
:append-to="appendToBody ? undefined : appModalsId"
:append-to-body="appendToBody" :append-to-body="appendToBody"
:data-test-id="`${name}-modal`" :data-test-id="`${name}-modal`"
:modal-class="center ? $style.center : ''" :modal-class="center ? $style.center : ''"
z-index="2000"
> >
<template v-if="$slots.header" #header> <template v-if="$slots.header" #header>
<slot v-if="!loading" name="header" /> <slot v-if="!loading" name="header" />

View file

@ -11,11 +11,12 @@ import NDVFloatingNodes from '@/components/NDVFloatingNodes.vue';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import type { XYPosition } from '@/Interface'; import type { XYPosition } from '@/Interface';
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'; import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
import { useUIStore } from '@/stores/ui.store';
const SIDE_MARGIN = 24; const SIDE_MARGIN = 24;
const SIDE_PANELS_MARGIN = 80; const SIDE_PANELS_MARGIN = 80;
const MIN_PANEL_WIDTH = 280; const MIN_PANEL_WIDTH = 310;
const PANEL_WIDTH = 320; const PANEL_WIDTH = 350;
const PANEL_WIDTH_LARGE = 420; const PANEL_WIDTH_LARGE = 420;
const MIN_WINDOW_WIDTH = 2 * (SIDE_MARGIN + SIDE_PANELS_MARGIN) + MIN_PANEL_WIDTH; const MIN_WINDOW_WIDTH = 2 * (SIDE_MARGIN + SIDE_PANELS_MARGIN) + MIN_PANEL_WIDTH;
@ -35,10 +36,10 @@ interface Props {
const { callDebounced } = useDebounce(); const { callDebounced } = useDebounce();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const uiStore = useUIStore();
const props = defineProps<Props>(); const props = defineProps<Props>();
const windowWidth = ref<number>(1);
const isDragging = ref<boolean>(false); const isDragging = ref<boolean>(false);
const initialized = ref<boolean>(false); const initialized = ref<boolean>(false);
@ -57,8 +58,6 @@ const slots = defineSlots<{
}>(); }>();
onMounted(() => { onMounted(() => {
setTotalWidth();
/* /*
Only set(or restore) initial position if `mainPanelDimensions` Only set(or restore) initial position if `mainPanelDimensions`
is at the default state({relativeLeft:1, relativeRight: 1, relativeWidth: 1}) to make sure we use store values if they are set is at the default state({relativeLeft:1, relativeRight: 1, relativeWidth: 1}) to make sure we use store values if they are set
@ -72,7 +71,6 @@ onMounted(() => {
restorePositionData(); restorePositionData();
} }
window.addEventListener('resize', setTotalWidth);
emit('init', { position: mainPanelDimensions.value.relativeLeft }); emit('init', { position: mainPanelDimensions.value.relativeLeft });
setTimeout(() => { setTimeout(() => {
initialized.value = true; initialized.value = true;
@ -82,11 +80,12 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', setTotalWidth);
ndvEventBus.off('setPositionByName', setPositionByName); ndvEventBus.off('setPositionByName', setPositionByName);
}); });
watch(windowWidth, (width) => { const containerWidth = computed(() => uiStore.appGridWidth);
watch(containerWidth, (width) => {
const minRelativeWidth = pxToRelativeWidth(MIN_PANEL_WIDTH); const minRelativeWidth = pxToRelativeWidth(MIN_PANEL_WIDTH);
const isBelowMinWidthMainPanel = mainPanelDimensions.value.relativeWidth < minRelativeWidth; const isBelowMinWidthMainPanel = mainPanelDimensions.value.relativeWidth < minRelativeWidth;
@ -161,14 +160,14 @@ const hasInputSlot = computed((): boolean => {
const inputPanelMargin = computed(() => pxToRelativeWidth(SIDE_PANELS_MARGIN)); const inputPanelMargin = computed(() => pxToRelativeWidth(SIDE_PANELS_MARGIN));
const minimumLeftPosition = computed((): number => { const minimumLeftPosition = computed((): number => {
if (windowWidth.value < MIN_WINDOW_WIDTH) return pxToRelativeWidth(1); if (containerWidth.value < MIN_WINDOW_WIDTH) return pxToRelativeWidth(1);
if (!hasInputSlot.value) return pxToRelativeWidth(SIDE_MARGIN); if (!hasInputSlot.value) return pxToRelativeWidth(SIDE_MARGIN);
return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value; return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value;
}); });
const maximumRightPosition = computed((): number => { const maximumRightPosition = computed((): number => {
if (windowWidth.value < MIN_WINDOW_WIDTH) return pxToRelativeWidth(1); if (containerWidth.value < MIN_WINDOW_WIDTH) return pxToRelativeWidth(1);
return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value; return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value;
}); });
@ -208,7 +207,7 @@ const hasDoubleWidth = computed((): boolean => {
const fixedPanelWidth = computed((): number => { const fixedPanelWidth = computed((): number => {
const multiplier = hasDoubleWidth.value ? 2 : 1; const multiplier = hasDoubleWidth.value ? 2 : 1;
if (windowWidth.value > 1700) { if (containerWidth.value > 1700) {
return PANEL_WIDTH_LARGE * multiplier; return PANEL_WIDTH_LARGE * multiplier;
} }
@ -288,15 +287,11 @@ function setPositionByName(position: 'minLeft' | 'maxRight' | 'initial') {
} }
function pxToRelativeWidth(px: number): number { function pxToRelativeWidth(px: number): number {
return px / windowWidth.value; return px / containerWidth.value;
} }
function relativeWidthToPx(relativeWidth: number) { function relativeWidthToPx(relativeWidth: number) {
return relativeWidth * windowWidth.value; return relativeWidth * containerWidth.value;
}
function onResizeStart() {
setTotalWidth();
} }
function onResizeEnd() { function onResizeEnd() {
@ -357,16 +352,12 @@ function onDragEnd() {
setTimeout(() => { setTimeout(() => {
isDragging.value = false; isDragging.value = false;
emit('dragend', { emit('dragend', {
windowWidth: windowWidth.value, windowWidth: containerWidth.value,
position: mainPanelDimensions.value.relativeLeft, position: mainPanelDimensions.value.relativeLeft,
}); });
}, 0); }, 0);
storePositionData(); storePositionData();
} }
function setTotalWidth() {
windowWidth.value = window.innerWidth;
}
</script> </script>
<template> <template>
@ -390,7 +381,6 @@ function setTotalWidth() {
:grid-size="20" :grid-size="20"
:supported-directions="supportedResizeDirections" :supported-directions="supportedResizeDirections"
@resize="onResizeDebounced" @resize="onResizeDebounced"
@resizestart="onResizeStart"
@resizeend="onResizeEnd" @resizeend="onResizeEnd"
> >
<div :class="$style.dragButtonContainer"> <div :class="$style.dragButtonContainer">

View file

@ -12,6 +12,7 @@ import OutputPanel from './OutputPanel.vue';
import InputPanel from './InputPanel.vue'; import InputPanel from './InputPanel.vue';
import TriggerPanel from './TriggerPanel.vue'; import TriggerPanel from './TriggerPanel.vue';
import { import {
APP_MODALS_ELEMENT_ID,
BASE_NODE_SURVEY_URL, BASE_NODE_SURVEY_URL,
EnterpriseEditionFeature, EnterpriseEditionFeature,
EXECUTABLE_TRIGGER_NODE_TYPES, EXECUTABLE_TRIGGER_NODE_TYPES,
@ -664,8 +665,9 @@ onBeforeUnmount(() => {
class="data-display-wrapper ndv-wrapper" class="data-display-wrapper ndv-wrapper"
overlay-class="data-display-overlay" overlay-class="data-display-overlay"
width="auto" width="auto"
append-to-body :append-to="`#${APP_MODALS_ELEMENT_ID}`"
data-test-id="ndv" data-test-id="ndv"
z-index="1800"
:data-has-output-connection="hasOutputConnection" :data-has-output-connection="hasOutputConnection"
> >
<n8n-tooltip <n8n-tooltip

View file

@ -40,6 +40,7 @@ import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils'
import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isResourceLocatorValue } from '@/utils/typeGuards';
import { import {
APP_MODALS_ELEMENT_ID,
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_KEY,
HTML_NODE_TYPE, HTML_NODE_TYPE,
@ -1037,7 +1038,7 @@ onUpdated(async () => {
> >
<el-dialog <el-dialog
:model-value="codeEditDialogVisible" :model-value="codeEditDialogVisible"
append-to-body :append-to="`#${APP_MODALS_ELEMENT_ID}`"
width="80%" width="80%"
:title="`${i18n.baseText('codeEdit.edit')} ${$locale :title="`${i18n.baseText('codeEdit.edit')} ${$locale
.nodeText() .nodeText()

View file

@ -2,9 +2,16 @@ import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import ParameterInputWrapper from './ParameterInputWrapper.vue'; import ParameterInputWrapper from './ParameterInputWrapper.vue';
import { STORES } from '@/constants'; import { STORES } from '@/constants';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
describe('ParameterInputWrapper.vue', () => { describe('ParameterInputWrapper.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
test('should resolve expression', async () => { test('should resolve expression', async () => {
const { getByTestId } = renderComponent(ParameterInputWrapper, { const { getByTestId } = renderComponent(ParameterInputWrapper, {
pinia: createTestingPinia({ pinia: createTestingPinia({

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, nextTick } from 'vue'; import { ref, watch, onMounted, nextTick } from 'vue';
import type { INodeProperties } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
const props = defineProps<{ const props = defineProps<{
dialogVisible: boolean; dialogVisible: boolean;
@ -60,7 +61,7 @@ const closeDialog = () => {
<div v-if="dialogVisible"> <div v-if="dialogVisible">
<el-dialog <el-dialog
:model-value="dialogVisible" :model-value="dialogVisible"
append-to-body :append-to="`#${APP_MODALS_ELEMENT_ID}`"
width="80%" width="80%"
:title="`${$locale.baseText('textEdit.edit')} ${$locale :title="`${$locale.baseText('textEdit.edit')} ${$locale
.nodeText() .nodeText()

View file

@ -11,7 +11,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, getDropdownItems } from '@/__tests__/utils';
import { EnterpriseEditionFeature, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; import { EnterpriseEditionFeature, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
@ -36,6 +36,8 @@ describe('WorkflowSettingsVue', () => {
pinia = createPinia(); pinia = createPinia();
setActivePinia(pinia); setActivePinia(pinia);
createAppModals();
workflowsStore = useWorkflowsStore(); workflowsStore = useWorkflowsStore();
settingsStore = useSettingsStore(); settingsStore = useSettingsStore();
uiStore = useUIStore(); uiStore = useUIStore();
@ -65,6 +67,10 @@ describe('WorkflowSettingsVue', () => {
}; };
}); });
afterEach(() => {
cleanupAppModals();
});
afterAll(() => { afterAll(() => {
server.shutdown(); server.shutdown();
}); });

View file

@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing';
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'; import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
import type { createPinia } from 'pinia'; import type { createPinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const renderComponent = createComponentRenderer(ChangePasswordModal); const renderComponent = createComponentRenderer(ChangePasswordModal);
@ -9,9 +10,14 @@ describe('ChangePasswordModal', () => {
let pinia: ReturnType<typeof createPinia>; let pinia: ReturnType<typeof createPinia>;
beforeEach(() => { beforeEach(() => {
createAppModals();
pinia = createTestingPinia({}); pinia = createTestingPinia({});
}); });
afterEach(() => {
cleanupAppModals();
});
it('should render correctly', () => { it('should render correctly', () => {
const wrapper = renderComponent({ pinia }); const wrapper = renderComponent({ pinia });

View file

@ -3,6 +3,7 @@ import { createTestingPinia } from '@pinia/testing';
import { CHAT_EMBED_MODAL_KEY, STORES, WEBHOOK_NODE_TYPE } from '@/constants'; import { CHAT_EMBED_MODAL_KEY, STORES, WEBHOOK_NODE_TYPE } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const renderComponent = createComponentRenderer(ChatEmbedModal, { const renderComponent = createComponentRenderer(ChatEmbedModal, {
props: { props: {
@ -26,16 +27,22 @@ const renderComponent = createComponentRenderer(ChatEmbedModal, {
}); });
describe('ChatEmbedModal', () => { describe('ChatEmbedModal', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
it('should render correctly', async () => { it('should render correctly', async () => {
const wrapper = renderComponent(); const { getByTestId } = renderComponent();
await waitFor(() => await waitFor(() => expect(getByTestId('chatEmbed-modal')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
const tabs = wrapper.container.querySelectorAll('.n8n-tabs .tab'); const modalContainer = getByTestId('chatEmbed-modal');
const activeTab = wrapper.container.querySelector('.n8n-tabs .tab.activeTab'); const tabs = modalContainer.querySelectorAll('.n8n-tabs .tab');
const editor = wrapper.container.querySelector('.cm-editor'); const activeTab = modalContainer.querySelector('.n8n-tabs .tab.activeTab');
const editor = modalContainer.querySelector('.cm-editor');
expect(tabs).toHaveLength(4); expect(tabs).toHaveLength(4);
expect(activeTab).toBeVisible(); expect(activeTab).toBeVisible();

View file

@ -3,7 +3,7 @@ import CommunityPackageInstallModal from '../CommunityPackageInstallModal.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, STORES } from '@/constants'; import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, STORES } from '@/constants';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { retry } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
const renderComponent = createComponentRenderer(CommunityPackageInstallModal, { const renderComponent = createComponentRenderer(CommunityPackageInstallModal, {
props: { props: {
@ -33,22 +33,27 @@ const renderComponent = createComponentRenderer(CommunityPackageInstallModal, {
}); });
describe('CommunityPackageInstallModal', () => { describe('CommunityPackageInstallModal', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
it('should disable install button until user agrees', async () => { it('should disable install button until user agrees', async () => {
const wrapper = renderComponent(); const { getByTestId } = renderComponent();
await retry(() => await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
const installButton = wrapper.getByTestId('install-community-package-button'); const installButton = getByTestId('install-community-package-button');
expect(installButton).toBeDisabled(); expect(installButton).toBeDisabled();
await userEvent.click(wrapper.getByTestId('user-agreement-checkbox')); await userEvent.click(getByTestId('user-agreement-checkbox'));
expect(installButton).toBeEnabled(); expect(installButton).toBeEnabled();
await userEvent.click(wrapper.getByTestId('user-agreement-checkbox')); await userEvent.click(getByTestId('user-agreement-checkbox'));
expect(installButton).toBeDisabled(); expect(installButton).toBeDisabled();
}); });

View file

@ -14,6 +14,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server'; import { setupServer } from '@/__tests__/server';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks'; import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
async function createPiniaWithActiveNode() { async function createPiniaWithActiveNode() {
const node = mockNodes[0]; const node = mockNodes[0];
@ -47,7 +48,12 @@ describe('NodeDetailsView', () => {
server = setupServer(); server = setupServer();
}); });
beforeEach(() => {
createAppModals();
});
afterEach(() => { afterEach(() => {
cleanupAppModals();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -73,12 +79,10 @@ describe('NodeDetailsView', () => {
}, },
}); });
const wrapper = renderComponent({ const { getByTestId } = renderComponent({
pinia, pinia,
}); });
await waitFor(() => await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
expect(wrapper.container.querySelector('.ndv-wrapper')).toBeInTheDocument(),
);
}); });
}); });

View file

@ -7,6 +7,7 @@ import { faker } from '@faker-js/faker';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import type { useNodeTypesStore } from '../../stores/nodeTypes.store'; import type { useNodeTypesStore } from '../../stores/nodeTypes.store';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>; let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>; let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
@ -57,6 +58,11 @@ describe('ParameterInput.vue', () => {
mockNodeTypesState = { mockNodeTypesState = {
allNodeTypes: [], allNodeTypes: [],
}; };
createAppModals();
});
afterEach(() => {
cleanupAppModals();
}); });
test('should render an options parameter (select)', async () => { test('should render an options parameter (select)', async () => {

View file

@ -2,7 +2,7 @@ import PersonalizationModal from '@/components/PersonalizationModal.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { PERSONALIZATION_MODAL_KEY, ROLE, STORES, VIEWS } from '@/constants'; import { PERSONALIZATION_MODAL_KEY, ROLE, STORES, VIEWS } from '@/constants';
import { retry } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { fireEvent } from '@testing-library/vue'; import { fireEvent } from '@testing-library/vue';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
@ -55,26 +55,32 @@ const renderComponent = createComponentRenderer(PersonalizationModal, {
}); });
describe('PersonalizationModal.vue', () => { describe('PersonalizationModal.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
it('should render correctly', async () => { it('should render correctly', async () => {
const wrapper = renderComponent(); const { getByTestId } = renderComponent();
await retry(() => await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
expect(wrapper.container.querySelectorAll('.n8n-select').length).toEqual(5); const modalContent = getByTestId('personalization-form');
expect(modalContent.querySelectorAll('.n8n-select').length).toEqual(5);
}); });
it('should display new option when role is "Devops", "Engineering", "IT", or "Sales and marketing"', async () => { it('should display new option when role is "Devops", "Engineering", "IT", or "Sales and marketing"', async () => {
const wrapper = renderComponent(); const { getByTestId } = renderComponent();
await retry(() => await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
for (const index of [3, 4, 5, 6]) { for (const index of [3, 4, 5, 6]) {
const modalContent = getByTestId('personalization-form');
const expectFn = expect; // So we don't break @typescript-eslint/no-loop-func const expectFn = expect; // So we don't break @typescript-eslint/no-loop-func
const select = wrapper.container.querySelectorAll('.n8n-select')[1]; const select = modalContent.querySelectorAll('.n8n-select')[1];
await fireEvent.click(select); await fireEvent.click(select);
@ -83,27 +89,27 @@ describe('PersonalizationModal.vue', () => {
await fireEvent.click(item); await fireEvent.click(item);
await retry(() => { await retry(() => {
expectFn(wrapper.container.querySelectorAll('.n8n-select').length).toEqual(6); expectFn(modalContent.querySelectorAll('.n8n-select').length).toEqual(6);
expectFn(wrapper.container.querySelector('[name^="automationGoal"]')).toBeInTheDocument(); expectFn(modalContent.querySelector('[name^="automationGoal"]')).toBeInTheDocument();
}); });
} }
}); });
it('should display self serve trial option when company size is larger than 500', async () => { it('should display self serve trial option when company size is larger than 500', async () => {
const wrapper = renderComponent(); const { getByTestId } = renderComponent();
await retry(() => await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
const select = wrapper.container.querySelectorAll('.n8n-select')[3]; const modalContent = getByTestId('personalization-form');
const select = modalContent.querySelectorAll('.n8n-select')[3];
await fireEvent.click(select); await fireEvent.click(select);
const item = select.querySelectorAll('.el-select-dropdown__item')[3]; const item = select.querySelectorAll('.el-select-dropdown__item')[3];
await fireEvent.click(item); await fireEvent.click(item);
await retry(() => { await retry(() => {
expect(wrapper.container.querySelector('.card')).not.toBeNull(); expect(modalContent.querySelector('.card')).not.toBeNull();
}); });
}); });
@ -114,23 +120,23 @@ describe('PersonalizationModal.vue', () => {
const usageStore = useUsageStore(pinia); const usageStore = useUsageStore(pinia);
const spyLicenseTrial = vi.spyOn(usageStore, 'requestEnterpriseLicenseTrial'); const spyLicenseTrial = vi.spyOn(usageStore, 'requestEnterpriseLicenseTrial');
const wrapper = renderComponent(); const { getByTestId, getByRole } = renderComponent();
await retry(() => await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
const select = wrapper.container.querySelectorAll('.n8n-select')[3]; const modalContent = getByTestId('personalization-form');
const select = modalContent.querySelectorAll('.n8n-select')[3];
await fireEvent.click(select); await fireEvent.click(select);
const item = select.querySelectorAll('.el-select-dropdown__item')[3]; const item = select.querySelectorAll('.el-select-dropdown__item')[3];
await fireEvent.click(item); await fireEvent.click(item);
const agreeCheckbox = wrapper.container.querySelector('.n8n-checkbox'); const agreeCheckbox = modalContent.querySelector('.n8n-checkbox');
assert(agreeCheckbox); assert(agreeCheckbox);
await fireEvent.click(agreeCheckbox); await fireEvent.click(agreeCheckbox);
const submitButton = wrapper.getByRole('button'); const submitButton = getByRole('button');
await userEvent.click(submitButton); await userEvent.click(submitButton);
await retry(() => expect(spyLicenseTrial).toHaveBeenCalled()); await retry(() => expect(spyLicenseTrial).toHaveBeenCalled());

View file

@ -4,7 +4,7 @@ import {
UPDATED_SCHEMA, UPDATED_SCHEMA,
} from './utils/ResourceMapper.utils'; } from './utils/ResourceMapper.utils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { waitAllPromises } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, waitAllPromises } from '@/__tests__/utils';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
@ -23,8 +23,13 @@ describe('ResourceMapper.vue', () => {
.mockResolvedValue(MAPPING_COLUMNS_RESPONSE); .mockResolvedValue(MAPPING_COLUMNS_RESPONSE);
}); });
beforeEach(() => {
createAppModals();
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
cleanupAppModals();
}); });
it('renders default configuration properly', async () => { it('renders default configuration properly', async () => {

View file

@ -16,6 +16,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server'; import { setupServer } from '@/__tests__/server';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks'; import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const connections: IConnections = { const connections: IConnections = {
'Chat Trigger': { 'Chat Trigger': {
@ -74,7 +75,12 @@ describe('WorkflowLMChatModal', () => {
server = setupServer(); server = setupServer();
}); });
beforeEach(() => {
createAppModals();
});
afterEach(() => { afterEach(() => {
cleanupAppModals();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -83,31 +89,27 @@ describe('WorkflowLMChatModal', () => {
}); });
it('should render correctly', async () => { it('should render correctly', async () => {
const wrapper = renderComponent({ const { getByTestId } = renderComponent({
pinia: await createPiniaWithAINodes(), pinia: await createPiniaWithAINodes(),
}); });
await waitFor(() => await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
expect(wrapper.getByTestId('workflow-lm-chat-dialog')).toBeInTheDocument(); expect(getByTestId('workflow-lm-chat-dialog')).toBeInTheDocument();
}); });
it('should send and display chat message', async () => { it('should send and display chat message', async () => {
const wrapper = renderComponent({ const { getByTestId } = renderComponent({
pinia: await createPiniaWithAINodes({ pinia: await createPiniaWithAINodes({
withConnections: true, withConnections: true,
withAgentNode: true, withAgentNode: true,
}), }),
}); });
await waitFor(() => await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
const chatDialog = wrapper.getByTestId('workflow-lm-chat-dialog'); const chatDialog = getByTestId('workflow-lm-chat-dialog');
const chatInputsContainer = wrapper.getByTestId('lm-chat-inputs'); const chatInputsContainer = getByTestId('lm-chat-inputs');
const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button'); const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button');
const chatInput = chatInputsContainer.querySelector('textarea'); const chatInput = chatInputsContainer.querySelector('textarea');

View file

@ -20,7 +20,7 @@ export interface NotificationErrorWithNodeAndDescription extends ApplicationErro
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = { const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
dangerouslyUseHTMLString: false, dangerouslyUseHTMLString: false,
position: 'bottom-right', position: 'bottom-right',
zIndex: 3000, // above NDV and chat window zIndex: 1900, // above NDV and below the modals
offset: 64, offset: 64,
appendTo: '#node-view-root', appendTo: '#node-view-root',
customClass: 'content-toast', customClass: 'content-toast',

View file

@ -575,7 +575,7 @@ export const EnterpriseEditionFeature: Record<
AdvancedPermissions: 'advancedPermissions', AdvancedPermissions: 'advancedPermissions',
}; };
export const MAIN_NODE_PANEL_WIDTH = 360; export const MAIN_NODE_PANEL_WIDTH = 390;
export const enum MAIN_HEADER_TABS { export const enum MAIN_HEADER_TABS {
WORKFLOW = 'workflow', WORKFLOW = 'workflow',
@ -867,3 +867,5 @@ export const CanvasNodeHandleKey =
/** Auth */ /** Auth */
export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId'; export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
export const APP_MODALS_ELEMENT_ID = 'app-modals';

View file

@ -22,6 +22,10 @@
background-color: var(--color-dialog-overlay-background-dark); background-color: var(--color-dialog-overlay-background-dark);
} }
#app-modals .el-overlay {
position: absolute;
}
.el-dialog { .el-dialog {
border: var(--border-base); border: var(--border-base);
box-shadow: 0px 6px 16px rgb(68 28 23 / 6%); box-shadow: 0px 6px 16px rgb(68 28 23 / 6%);

View file

@ -27,6 +27,7 @@ import { usePostHog } from './posthog.store';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useUIStore } from './ui.store';
export const MAX_CHAT_WIDTH = 425; export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 250; export const MIN_CHAT_WIDTH = 250;
@ -42,6 +43,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const chatMessages = ref<ChatUI.AssistantMessage[]>([]); const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false); const chatWindowOpen = ref<boolean>(false);
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const route = useRoute(); const route = useRoute();
const streaming = ref<boolean>(); const streaming = ref<boolean>();
@ -117,13 +119,20 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
suggestions.value = {}; suggestions.value = {};
} }
function closeChat() { // As assistant sidebar opens and closes, use window width to calculate the container width
chatWindowOpen.value = false; // This will prevent animation race conditions from making ndv twitchy
}
function openChat() { function openChat() {
chatWindowOpen.value = true; chatWindowOpen.value = true;
chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true })); chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true }));
uiStore.appGridWidth = window.innerWidth - chatWidth.value;
}
function closeChat() {
chatWindowOpen.value = false;
// Looks smoother if we wait for slide animation to finish before updating the grid width
setTimeout(() => {
uiStore.appGridWidth = window.innerWidth;
}, 200);
} }
function addAssistantMessages(assistantMessages: ChatRequest.MessageResponse[], id: string) { function addAssistantMessages(assistantMessages: ChatRequest.MessageResponse[], id: string) {

View file

@ -192,6 +192,8 @@ export const useUIStore = defineStore(STORES.UI, () => {
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({}); const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
const isCreateNodeActive = ref<boolean>(false); const isCreateNodeActive = ref<boolean>(false);
const appGridWidth = ref<number>(0);
// Last interacted with - Canvas v2 specific // Last interacted with - Canvas v2 specific
const lastInteractedWithNodeConnection = ref<Connection | null>(null); const lastInteractedWithNodeConnection = ref<Connection | null>(null);
const lastInteractedWithNodeHandle = ref<string | null>(null); const lastInteractedWithNodeHandle = ref<string | null>(null);
@ -623,6 +625,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
} }
return { return {
appGridWidth,
appliedTheme, appliedTheme,
logo, logo,
contextBasedTranslationKeys, contextBasedTranslationKeys,

View file

@ -2,7 +2,7 @@ import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils'; import { cleanupAppModals, createAppModals, getDropdownItems } from '@/__tests__/utils';
import ModalRoot from '@/components/ModalRoot.vue'; import ModalRoot from '@/components/ModalRoot.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue'; import DeleteUserModal from '@/components/DeleteUserModal.vue';
import SettingsUsersView from '@/views/SettingsUsersView.vue'; import SettingsUsersView from '@/views/SettingsUsersView.vue';
@ -55,6 +55,8 @@ describe('SettingsUsersView', () => {
usersStore = useUsersStore(); usersStore = useUsersStore();
rbacStore = useRBACStore(); rbacStore = useRBACStore();
createAppModals();
useSettingsStore().settings.enterprise = { useSettingsStore().settings.enterprise = {
...defaultSettings.enterprise, ...defaultSettings.enterprise,
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true, [EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
@ -71,6 +73,10 @@ describe('SettingsUsersView', () => {
usersStore.currentUserId = loggedInUser.id; usersStore.currentUserId = loggedInUser.id;
}); });
afterEach(() => {
cleanupAppModals();
});
it('should show confirmation modal before deleting user and delete with transfer', async () => { it('should show confirmation modal before deleting user and delete with transfer', async () => {
const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {}); const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {});