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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<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 LoadingView from '@/views/LoadingView.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 { loadLanguage } from '@/plugins/i18n';
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 { useAssistantStore } from './stores/assistant.store';
import { useUIStore } from './stores/ui.store';
@ -32,34 +32,56 @@ const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtons);
const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
watch(defaultLocale, (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 = () => {
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
console.log(HIRING_BANNER);
}
};
onMounted(async () => {
logHiringBanner();
void useExternalHooks().run('app.mount');
loading.value = false;
});
const updateGridWidth = async () => {
await nextTick();
if (appGrid.value) {
uiStore.appGridWidth = appGrid.value.clientWidth;
}
};
</script>
<template>
<div :class="[$style.app, 'root-container']">
<LoadingView v-if="loading" />
<div
v-else
id="app"
:class="{
[$style.container]: true,
[$style.sidebarCollapsed]: uiStore.sidebarMenuCollapsed,
}"
>
<LoadingView v-if="loading" />
<div
v-else
id="n8n-app"
:class="{
[$style.container]: true,
[$style.sidebarCollapsed]: uiStore.sidebarMenuCollapsed,
}"
>
<div id="app-grid" ref="appGrid" :class="$style['app-grid']">
<div id="banners" :class="$style.banners">
<BannerStack v-if="!isDemoMode" />
</div>
@ -77,29 +99,37 @@ onMounted(async () => {
<component :is="Component" v-else />
</router-view>
</div>
<AskAssistantChat />
<Modals />
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
<Modals />
</div>
<Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" />
</div>
<AskAssistantChat />
</div>
</template>
<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;
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;
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;
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 {
@ -132,4 +162,8 @@ onMounted(async () => {
height: 100%;
z-index: 999;
}
.modals {
width: 100%;
}
</style>

View file

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import type { ISettingsState } from '@/Interface';
import { UserManagementAuthenticationMethod } from '@/Interface';
import { defaultSettings } from './defaults';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
/**
* 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();
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>
<SlideTransition>
<n8n-resize-wrapper
v-if="assistantStore.isAssistantOpen"
v-show="assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="assistantStore.chatWidth"
:class="$style.container"
@ -95,9 +95,9 @@ function onClose() {
<style module>
.container {
grid-area: rightsidebar;
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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import { mapStores } from 'pinia';
import type { EventBus } from 'n8n-design-system';
import { useUIStore } from '@/stores/ui.store';
import type { ModalKey } from '@/Interface';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
export default defineComponent({
name: 'Modal',
@ -13,15 +14,19 @@ export default defineComponent({
...ElDialog.props,
name: {
type: String as PropType<ModalKey>,
required: true,
},
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
eventBus: {
type: Object as PropType<EventBus>,
default: null,
},
showClose: {
type: Boolean,
@ -35,31 +40,39 @@ export default defineComponent({
},
beforeClose: {
type: Function,
default: null,
},
customClass: {
type: String,
default: '',
},
center: {
type: Boolean,
default: true,
},
width: {
type: String,
default: '50%',
},
minWidth: {
type: String,
type: [String, null] as PropType<string | null>,
default: null,
},
maxWidth: {
type: String,
type: [String, null] as PropType<string | null>,
default: null,
},
height: {
type: String,
type: [String, null] as PropType<string | null>,
default: null,
},
minHeight: {
type: String,
type: [String, null] as PropType<string | null>,
default: null,
},
maxHeight: {
type: String,
type: [String, null] as PropType<string | null>,
default: null,
},
scrollable: {
type: Boolean,
@ -79,23 +92,10 @@ export default defineComponent({
},
appendToBody: {
type: Boolean,
default: true,
default: false,
},
},
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);
},
emits: { enter: null },
computed: {
...mapStores(useUIStore),
styles() {
@ -117,6 +117,23 @@ export default defineComponent({
}
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: {
onWindowKeydown(event: KeyboardEvent) {
@ -175,9 +192,11 @@ export default defineComponent({
:close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape"
:style="styles"
:append-to="appendToBody ? undefined : appModalsId"
:append-to-body="appendToBody"
:data-test-id="`${name}-modal`"
:modal-class="center ? $style.center : ''"
z-index="2000"
>
<template v-if="$slots.header" #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 type { XYPosition } from '@/Interface';
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
import { useUIStore } from '@/stores/ui.store';
const SIDE_MARGIN = 24;
const SIDE_PANELS_MARGIN = 80;
const MIN_PANEL_WIDTH = 280;
const PANEL_WIDTH = 320;
const MIN_PANEL_WIDTH = 310;
const PANEL_WIDTH = 350;
const PANEL_WIDTH_LARGE = 420;
const MIN_WINDOW_WIDTH = 2 * (SIDE_MARGIN + SIDE_PANELS_MARGIN) + MIN_PANEL_WIDTH;
@ -35,10 +36,10 @@ interface Props {
const { callDebounced } = useDebounce();
const ndvStore = useNDVStore();
const uiStore = useUIStore();
const props = defineProps<Props>();
const windowWidth = ref<number>(1);
const isDragging = ref<boolean>(false);
const initialized = ref<boolean>(false);
@ -57,8 +58,6 @@ const slots = defineSlots<{
}>();
onMounted(() => {
setTotalWidth();
/*
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
@ -72,7 +71,6 @@ onMounted(() => {
restorePositionData();
}
window.addEventListener('resize', setTotalWidth);
emit('init', { position: mainPanelDimensions.value.relativeLeft });
setTimeout(() => {
initialized.value = true;
@ -82,11 +80,12 @@ onMounted(() => {
});
onBeforeUnmount(() => {
window.removeEventListener('resize', setTotalWidth);
ndvEventBus.off('setPositionByName', setPositionByName);
});
watch(windowWidth, (width) => {
const containerWidth = computed(() => uiStore.appGridWidth);
watch(containerWidth, (width) => {
const minRelativeWidth = pxToRelativeWidth(MIN_PANEL_WIDTH);
const isBelowMinWidthMainPanel = mainPanelDimensions.value.relativeWidth < minRelativeWidth;
@ -161,14 +160,14 @@ const hasInputSlot = computed((): boolean => {
const inputPanelMargin = computed(() => pxToRelativeWidth(SIDE_PANELS_MARGIN));
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);
return pxToRelativeWidth(SIDE_MARGIN + 20) + inputPanelMargin.value;
});
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;
});
@ -208,7 +207,7 @@ const hasDoubleWidth = computed((): boolean => {
const fixedPanelWidth = computed((): number => {
const multiplier = hasDoubleWidth.value ? 2 : 1;
if (windowWidth.value > 1700) {
if (containerWidth.value > 1700) {
return PANEL_WIDTH_LARGE * multiplier;
}
@ -288,15 +287,11 @@ function setPositionByName(position: 'minLeft' | 'maxRight' | 'initial') {
}
function pxToRelativeWidth(px: number): number {
return px / windowWidth.value;
return px / containerWidth.value;
}
function relativeWidthToPx(relativeWidth: number) {
return relativeWidth * windowWidth.value;
}
function onResizeStart() {
setTotalWidth();
return relativeWidth * containerWidth.value;
}
function onResizeEnd() {
@ -357,16 +352,12 @@ function onDragEnd() {
setTimeout(() => {
isDragging.value = false;
emit('dragend', {
windowWidth: windowWidth.value,
windowWidth: containerWidth.value,
position: mainPanelDimensions.value.relativeLeft,
});
}, 0);
storePositionData();
}
function setTotalWidth() {
windowWidth.value = window.innerWidth;
}
</script>
<template>
@ -390,7 +381,6 @@ function setTotalWidth() {
:grid-size="20"
:supported-directions="supportedResizeDirections"
@resize="onResizeDebounced"
@resizestart="onResizeStart"
@resizeend="onResizeEnd"
>
<div :class="$style.dragButtonContainer">

View file

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

View file

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

View file

@ -2,9 +2,16 @@ import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import ParameterInputWrapper from './ParameterInputWrapper.vue';
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', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
test('should resolve expression', async () => {
const { getByTestId } = renderComponent(ParameterInputWrapper, {
pinia: createTestingPinia({

View file

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

View file

@ -11,7 +11,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
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 { nextTick } from 'vue';
@ -36,6 +36,8 @@ describe('WorkflowSettingsVue', () => {
pinia = createPinia();
setActivePinia(pinia);
createAppModals();
workflowsStore = useWorkflowsStore();
settingsStore = useSettingsStore();
uiStore = useUIStore();
@ -65,6 +67,10 @@ describe('WorkflowSettingsVue', () => {
};
});
afterEach(() => {
cleanupAppModals();
});
afterAll(() => {
server.shutdown();
});

View file

@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing';
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
import type { createPinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const renderComponent = createComponentRenderer(ChangePasswordModal);
@ -9,9 +10,14 @@ describe('ChangePasswordModal', () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(() => {
createAppModals();
pinia = createTestingPinia({});
});
afterEach(() => {
cleanupAppModals();
});
it('should render correctly', () => {
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 { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const renderComponent = createComponentRenderer(ChatEmbedModal, {
props: {
@ -26,16 +27,22 @@ const renderComponent = createComponentRenderer(ChatEmbedModal, {
});
describe('ChatEmbedModal', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
it('should render correctly', async () => {
const wrapper = renderComponent();
const { getByTestId } = renderComponent();
await waitFor(() =>
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
await waitFor(() => expect(getByTestId('chatEmbed-modal')).toBeInTheDocument());
const tabs = wrapper.container.querySelectorAll('.n8n-tabs .tab');
const activeTab = wrapper.container.querySelector('.n8n-tabs .tab.activeTab');
const editor = wrapper.container.querySelector('.cm-editor');
const modalContainer = getByTestId('chatEmbed-modal');
const tabs = modalContainer.querySelectorAll('.n8n-tabs .tab');
const activeTab = modalContainer.querySelector('.n8n-tabs .tab.activeTab');
const editor = modalContainer.querySelector('.cm-editor');
expect(tabs).toHaveLength(4);
expect(activeTab).toBeVisible();

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import { faker } from '@faker-js/faker';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { useNodeTypesStore } from '../../stores/nodeTypes.store';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
@ -57,6 +58,11 @@ describe('ParameterInput.vue', () => {
mockNodeTypesState = {
allNodeTypes: [],
};
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
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 userEvent from '@testing-library/user-event';
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 { fireEvent } from '@testing-library/vue';
import { useUsersStore } from '@/stores/users.store';
@ -55,26 +55,32 @@ const renderComponent = createComponentRenderer(PersonalizationModal, {
});
describe('PersonalizationModal.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
});
it('should render correctly', async () => {
const wrapper = renderComponent();
const { getByTestId } = renderComponent();
await retry(() =>
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
await retry(() => expect(getByTestId('personalization-form')).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 () => {
const wrapper = renderComponent();
const { getByTestId } = renderComponent();
await retry(() =>
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
await retry(() => expect(getByTestId('personalization-form')).toBeInTheDocument());
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 select = wrapper.container.querySelectorAll('.n8n-select')[1];
const select = modalContent.querySelectorAll('.n8n-select')[1];
await fireEvent.click(select);
@ -83,27 +89,27 @@ describe('PersonalizationModal.vue', () => {
await fireEvent.click(item);
await retry(() => {
expectFn(wrapper.container.querySelectorAll('.n8n-select').length).toEqual(6);
expectFn(wrapper.container.querySelector('[name^="automationGoal"]')).toBeInTheDocument();
expectFn(modalContent.querySelectorAll('.n8n-select').length).toEqual(6);
expectFn(modalContent.querySelector('[name^="automationGoal"]')).toBeInTheDocument();
});
}
});
it('should display self serve trial option when company size is larger than 500', async () => {
const wrapper = renderComponent();
const { getByTestId } = renderComponent();
await retry(() =>
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
await retry(() => expect(getByTestId('personalization-form')).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);
const item = select.querySelectorAll('.el-select-dropdown__item')[3];
await fireEvent.click(item);
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 spyLicenseTrial = vi.spyOn(usageStore, 'requestEnterpriseLicenseTrial');
const wrapper = renderComponent();
const { getByTestId, getByRole } = renderComponent();
await retry(() =>
expect(wrapper.container.querySelector('.modal-content')).toBeInTheDocument(),
);
await retry(() => expect(getByTestId('personalization-form')).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);
const item = select.querySelectorAll('.el-select-dropdown__item')[3];
await fireEvent.click(item);
const agreeCheckbox = wrapper.container.querySelector('.n8n-checkbox');
const agreeCheckbox = modalContent.querySelector('.n8n-checkbox');
assert(agreeCheckbox);
await fireEvent.click(agreeCheckbox);
const submitButton = wrapper.getByRole('button');
const submitButton = getByRole('button');
await userEvent.click(submitButton);
await retry(() => expect(spyLicenseTrial).toHaveBeenCalled());

View file

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

View file

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

View file

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

View file

@ -575,7 +575,7 @@ export const EnterpriseEditionFeature: Record<
AdvancedPermissions: 'advancedPermissions',
};
export const MAIN_NODE_PANEL_WIDTH = 360;
export const MAIN_NODE_PANEL_WIDTH = 390;
export const enum MAIN_HEADER_TABS {
WORKFLOW = 'workflow',
@ -867,3 +867,5 @@ export const CanvasNodeHandleKey =
/** Auth */
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);
}
#app-modals .el-overlay {
position: absolute;
}
.el-dialog {
border: var(--border-base);
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 { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useUIStore } from './ui.store';
export const MAX_CHAT_WIDTH = 425;
export const MIN_CHAT_WIDTH = 250;
@ -42,6 +43,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
const chatMessages = ref<ChatUI.AssistantMessage[]>([]);
const chatWindowOpen = ref<boolean>(false);
const usersStore = useUsersStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const route = useRoute();
const streaming = ref<boolean>();
@ -117,13 +119,20 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
suggestions.value = {};
}
function closeChat() {
chatWindowOpen.value = false;
}
// As assistant sidebar opens and closes, use window width to calculate the container width
// This will prevent animation race conditions from making ndv twitchy
function openChat() {
chatWindowOpen.value = 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) {

View file

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

View file

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