mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
fix(editor): Fix position of notification toast and "Ask AI" floating button (#19694)
This commit is contained in:
parent
27f48a2d79
commit
80e08db911
|
|
@ -30,6 +30,8 @@ import axios from 'axios';
|
|||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStyles } from './composables/useStyles';
|
||||
import { useExposeCssVar } from '@/composables/useExposeCssVar';
|
||||
import { useFloatingUiOffsets } from '@/composables/useFloatingUiOffsets';
|
||||
|
||||
const route = useRoute();
|
||||
const rootStore = useRootStore();
|
||||
|
|
@ -41,6 +43,7 @@ const settingsStore = useSettingsStore();
|
|||
const ndvStore = useNDVStore();
|
||||
|
||||
const { setAppZIndexes } = useStyles();
|
||||
const { toastBottomOffset, askAiFloatingButtonBottomOffset } = useFloatingUiOffsets();
|
||||
|
||||
// Initialize undo/redo
|
||||
useHistoryHelper(route);
|
||||
|
|
@ -51,10 +54,6 @@ useWorkflowDiffRouting();
|
|||
const loading = ref(true);
|
||||
const defaultLocale = computed(() => rootStore.defaultLocale);
|
||||
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
||||
const showAssistantFloatingButton = computed(
|
||||
() =>
|
||||
assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.hideAssistantFloatingButton,
|
||||
);
|
||||
const hasContentFooter = ref(false);
|
||||
const appGrid = ref<Element | null>(null);
|
||||
|
||||
|
|
@ -109,6 +108,9 @@ watch(
|
|||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
useExposeCssVar('--toast-bottom-offset', toastBottomOffset);
|
||||
useExposeCssVar('--ask-assistant-floating-button-bottom-offset', askAiFloatingButtonBottomOffset);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -148,7 +150,7 @@ watch(
|
|||
<Modals />
|
||||
</div>
|
||||
<Telemetry />
|
||||
<AskAssistantFloatingButton v-if="showAssistantFloatingButton" />
|
||||
<AskAssistantFloatingButton v-if="assistantStore.isFloatingButtonShown" />
|
||||
</div>
|
||||
<AssistantsHub />
|
||||
<div :id="CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||
import { computed } from 'vue';
|
||||
|
|
@ -10,7 +9,6 @@ import { computed } from 'vue';
|
|||
const assistantStore = useAssistantStore();
|
||||
const i18n = useI18n();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const lastUnread = computed(() => {
|
||||
const msg = assistantStore.lastUnread;
|
||||
|
|
@ -37,16 +35,7 @@ const onClick = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
assistantStore.canShowAssistantButtonsOnCanvas &&
|
||||
!assistantStore.isAssistantOpen &&
|
||||
!assistantStore.hideAssistantFloatingButton
|
||||
"
|
||||
:class="$style.container"
|
||||
data-test-id="ask-assistant-floating-button"
|
||||
:style="{ '--canvas-panel-height-offset': `${logsStore.height}px` }"
|
||||
>
|
||||
<div :class="$style.container" data-test-id="ask-assistant-floating-button">
|
||||
<n8n-tooltip
|
||||
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
||||
placement="top"
|
||||
|
|
@ -68,8 +57,8 @@ const onClick = () => {
|
|||
<style lang="scss" module>
|
||||
.container {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-2xl);
|
||||
right: var(--spacing-s);
|
||||
bottom: var(--ask-assistant-floating-button-bottom-offset, --spacing-2xl);
|
||||
z-index: var(--z-index-ask-assistant-floating-button);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { render } from '@testing-library/vue';
|
||||
import { useExposeCssVar } from './useExposeCssVar';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
describe(useExposeCssVar, () => {
|
||||
it('should set CSS variable', () => {
|
||||
const Component = defineComponent(() => {
|
||||
useExposeCssVar('--test-var', '1234px');
|
||||
return () => h('div');
|
||||
});
|
||||
|
||||
render(Component);
|
||||
expect(document.documentElement.style.getPropertyValue('--test-var')).toBe('1234px');
|
||||
});
|
||||
|
||||
it('should unset CSS variable when component is unmounted', () => {
|
||||
const Component = defineComponent(() => {
|
||||
useExposeCssVar('--test-var', '1234px');
|
||||
return () => h('div');
|
||||
});
|
||||
const rendered = render(Component);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue('--test-var')).toBe('1234px');
|
||||
rendered.unmount();
|
||||
expect(document.documentElement.style.getPropertyValue('--test-var')).toBe('');
|
||||
});
|
||||
|
||||
it('should restore CSS variable before mount when component is unmounted', () => {
|
||||
const Component = defineComponent(() => {
|
||||
useExposeCssVar('--test-var', '1234px');
|
||||
return () => h('div');
|
||||
});
|
||||
|
||||
document.documentElement.style.setProperty('--test-var', '3px');
|
||||
|
||||
const rendered = render(Component);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue('--test-var')).toBe('1234px');
|
||||
rendered.unmount();
|
||||
expect(document.documentElement.style.getPropertyValue('--test-var')).toBe('3px');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { useCssVar } from '@vueuse/core';
|
||||
import { onBeforeUnmount, toValue, watch, type MaybeRef } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable to exposes CSS variable globally
|
||||
*/
|
||||
export function useExposeCssVar(name: string, value: MaybeRef<string>) {
|
||||
const originalValue = document.documentElement.style.getPropertyValue(name);
|
||||
const variable = useCssVar(name);
|
||||
|
||||
watch(
|
||||
() => toValue(value),
|
||||
(latest) => {
|
||||
variable.value = latest;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.documentElement.style.setProperty(name, originalValue ?? null);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { defaultSettings } from '@/__tests__/defaults';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { useFloatingUiOffsets } from './useFloatingUiOffsets';
|
||||
import { reactive } from 'vue';
|
||||
import { EDITABLE_CANVAS_VIEWS } from '@/constants';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
let currentRouteName = '';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn(() =>
|
||||
reactive({
|
||||
path: '/',
|
||||
params: {},
|
||||
name: currentRouteName,
|
||||
}),
|
||||
),
|
||||
useRouter: vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
describe(useFloatingUiOffsets, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
currentRouteName = '';
|
||||
useWorkflowsStore().setNodes([createTestNode({ name: 'n0' })]);
|
||||
});
|
||||
|
||||
describe('toastBottomOffset', () => {
|
||||
it('should account for the height of log view only when NDV is closed', () => {
|
||||
useLogsStore().setHeight(3);
|
||||
useNDVStore().setActiveNodeName('n0', 'other'); // set NDV to be opened
|
||||
|
||||
const { toastBottomOffset } = useFloatingUiOffsets();
|
||||
|
||||
expect(toastBottomOffset.value).toBe('0px');
|
||||
|
||||
useNDVStore().unsetActiveNodeName(); // close NDV
|
||||
|
||||
expect(toastBottomOffset.value).toBe('3px');
|
||||
});
|
||||
|
||||
it.each(EDITABLE_CANVAS_VIEWS)(
|
||||
'should account for the height of AI assistant floating button in %s only when the button is displayed',
|
||||
async (view) => {
|
||||
currentRouteName = view;
|
||||
useSettingsStore().setSettings({ ...defaultSettings, aiAssistant: { enabled: true } });
|
||||
|
||||
const { toastBottomOffset } = useFloatingUiOffsets();
|
||||
|
||||
expect(toastBottomOffset.value).toBe('0px');
|
||||
|
||||
useNDVStore().setActiveNodeName('n0', 'other');
|
||||
|
||||
expect(toastBottomOffset.value).toBe('90px'); // 42px button + 48px NDV offset
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { NDV_UI_OVERHAUL_EXPERIMENT } from '@/constants';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const ASSISTANT_FLOATING_BUTTON_SIZE = 42;
|
||||
|
||||
export function useFloatingUiOffsets() {
|
||||
const assistantStore = useAssistantStore();
|
||||
const ndvStore = useNDVStore();
|
||||
const posthogStore = usePostHog();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const isNDVV2 = computed(() =>
|
||||
posthogStore.isVariantEnabled(
|
||||
NDV_UI_OVERHAUL_EXPERIMENT.name,
|
||||
NDV_UI_OVERHAUL_EXPERIMENT.variant,
|
||||
),
|
||||
);
|
||||
const askAiOffset = computed(() => (ndvStore.isNDVOpen && !isNDVV2.value ? 48 : 16));
|
||||
|
||||
return {
|
||||
askAiFloatingButtonBottomOffset: computed(() => `${askAiOffset.value}px`),
|
||||
toastBottomOffset: computed(() => {
|
||||
const logsPanelOffset = ndvStore.isNDVOpen ? 0 : logsStore.height;
|
||||
const assistantOffset = assistantStore.isFloatingButtonShown
|
||||
? ASSISTANT_FLOATING_BUTTON_SIZE + askAiOffset.value
|
||||
: 0;
|
||||
|
||||
return `${logsPanelOffset + assistantOffset}px`;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -2,12 +2,6 @@ import { screen, waitFor, within } from '@testing-library/vue';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { h, defineComponent } from 'vue';
|
||||
import { useToast } from './useToast';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { EDITABLE_CANVAS_VIEWS, VIEWS } from '@/constants';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
describe('useToast', () => {
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
|
|
@ -92,65 +86,4 @@ describe('useToast', () => {
|
|||
expect(screen.getByRole('alert')).toContainHTML('<p>Test <strong>content</strong></p>');
|
||||
});
|
||||
});
|
||||
describe('determineToastOffset', () => {
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
||||
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
||||
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isAiAssistantEnabled = false;
|
||||
|
||||
logsStore = mockedStore(useLogsStore);
|
||||
logsStore.height = 100;
|
||||
logsStore.state = 'attached';
|
||||
|
||||
ndvStore = mockedStore(useNDVStore);
|
||||
ndvStore.activeNode = null;
|
||||
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.currentView = VIEWS.WORKFLOW;
|
||||
});
|
||||
|
||||
it('applies logsStore offset on applicable views', () => {
|
||||
for (const view of EDITABLE_CANVAS_VIEWS) {
|
||||
uiStore.currentView = view;
|
||||
const offset = toast.determineToastOffset();
|
||||
expect(offset).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not apply logsStore offset on unrelated view', () => {
|
||||
uiStore.currentView = VIEWS.HOMEPAGE;
|
||||
const offset = toast.determineToastOffset();
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('does not apply logsStore offset if we have an activeNode', () => {
|
||||
ndvStore.activeNode = { name: 'myActiveNode' } as never;
|
||||
const offset = toast.determineToastOffset();
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('applies assistant offset with logsOffset', () => {
|
||||
settingsStore.isAiAssistantEnabled = true;
|
||||
const offset = toast.determineToastOffset();
|
||||
expect(offset).toBe(100 + 64);
|
||||
});
|
||||
|
||||
it('does not apply logsStore offset if floating', () => {
|
||||
logsStore.state = 'floating';
|
||||
const offset = toast.determineToastOffset();
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
|
||||
it('does apply logsStore offset if closed', () => {
|
||||
logsStore.state = 'closed';
|
||||
logsStore.height = 32;
|
||||
settingsStore.isAiAssistantEnabled = true;
|
||||
const offset = toast.determineToastOffset();
|
||||
expect(offset).toBe(32 + 64);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,13 +7,9 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useExternalHooks } from './useExternalHooks';
|
||||
import { VIEWS, VISIBLE_LOGS_VIEWS } from '@/constants';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { ApplicationError } from 'n8n-workflow';
|
||||
import { useStyles } from './useStyles';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { LOGS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||
|
||||
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
||||
node: {
|
||||
|
|
@ -30,29 +26,13 @@ export function useToast() {
|
|||
const uiStore = useUIStore();
|
||||
const externalHooks = useExternalHooks();
|
||||
const i18n = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
const logsStore = useLogsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
function determineToastOffset() {
|
||||
const assistantOffset = settingsStore.isAiAssistantEnabled ? 64 : 0;
|
||||
const logsOffset =
|
||||
VISIBLE_LOGS_VIEWS.includes(uiStore.currentView as VIEWS) &&
|
||||
ndvStore.activeNode === null &&
|
||||
logsStore.state !== LOGS_PANEL_STATE.FLOATING
|
||||
? logsStore.height
|
||||
: 0;
|
||||
|
||||
return assistantOffset + logsOffset;
|
||||
}
|
||||
|
||||
function showMessage(messageData: Partial<NotificationOptions>, track = true) {
|
||||
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
||||
dangerouslyUseHTMLString: true,
|
||||
position: 'bottom-right',
|
||||
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
||||
offset: determineToastOffset(),
|
||||
appendTo: '#app-grid',
|
||||
customClass: 'content-toast',
|
||||
};
|
||||
|
|
@ -214,6 +194,5 @@ export function useToast() {
|
|||
showError,
|
||||
clearAllStickyNotifications,
|
||||
showNotificationForViews,
|
||||
determineToastOffset,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -610,7 +610,6 @@ export const enum VIEWS {
|
|||
}
|
||||
|
||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
export const VISIBLE_LOGS_VIEWS = [...EDITABLE_CANVAS_VIEWS, VIEWS.EXECUTION_PREVIEW];
|
||||
|
||||
export const TEST_PIN_DATA = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { computed, type ComputedRef, type ShallowRef } from 'vue';
|
||||
import { computed, onBeforeUnmount, watch, type ComputedRef, type ShallowRef } from 'vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { watch } from 'vue';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useResizablePanel } from '@/composables/useResizablePanel';
|
||||
import { usePopOutWindow } from '@/features/logs/composables/usePopOutWindow';
|
||||
|
|
@ -58,11 +57,7 @@ export function useLogsPanelLayout(
|
|||
const popOutWindowTitle = computed(() => `Logs - ${workflowName.value}`);
|
||||
const shouldPopOut = computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING);
|
||||
|
||||
const {
|
||||
canPopOut,
|
||||
isPoppedOut,
|
||||
popOutWindow: popOutWindow,
|
||||
} = usePopOutWindow({
|
||||
const { canPopOut, isPoppedOut, popOutWindow } = usePopOutWindow({
|
||||
title: popOutWindowTitle,
|
||||
initialHeight: INITIAL_POPUP_HEIGHT,
|
||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||
|
|
@ -135,6 +130,8 @@ export function useLogsPanelLayout(
|
|||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => logsStore.setHeight(0));
|
||||
|
||||
return {
|
||||
height: resizer.size,
|
||||
chatPanelWidth: chatPanelResizer.size,
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@
|
|||
.el-notification {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
margin-bottom: var(--toast-bottom-offset, 0);
|
||||
|
||||
&.whats-new-notification {
|
||||
bottom: var(--spacing-xs) !important;
|
||||
|
|
|
|||
|
|
@ -116,9 +116,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
() => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS),
|
||||
);
|
||||
const hideAssistantFloatingButton = computed(
|
||||
() =>
|
||||
(route.name === VIEWS.WORKFLOW || route.name === VIEWS.NEW_WORKFLOW) &&
|
||||
!workflowsStore.activeNode(),
|
||||
() => EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS) && !workflowsStore.activeNode(),
|
||||
);
|
||||
|
||||
const unreadCount = computed(
|
||||
|
|
@ -128,6 +126,13 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
).length,
|
||||
);
|
||||
|
||||
const isFloatingButtonShown = computed(
|
||||
() =>
|
||||
canShowAssistantButtonsOnCanvas.value &&
|
||||
!isAssistantOpen.value &&
|
||||
!hideAssistantFloatingButton.value,
|
||||
);
|
||||
|
||||
function resetAssistantChat() {
|
||||
clearMessages();
|
||||
currentSessionId.value = undefined;
|
||||
|
|
@ -844,6 +849,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
|||
currentSessionId,
|
||||
lastUnread,
|
||||
isSessionEnded,
|
||||
isFloatingButtonShown,
|
||||
onNodeExecution,
|
||||
trackUserOpenedAssistant,
|
||||
closeChat,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user