fix(editor): Fix position of notification toast and "Ask AI" floating button (#19694)

This commit is contained in:
Suguru Inoue 2025-09-22 12:24:10 +02:00 committed by GitHub
parent 27f48a2d79
commit 80e08db911
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 187 additions and 118 deletions

View File

@ -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" />

View File

@ -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);
}

View File

@ -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');
});
});

View File

@ -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);
});
}

View File

@ -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
},
);
});
});

View File

@ -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`;
}),
};
}

View File

@ -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);
});
});
});

View File

@ -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,
};
}

View File

@ -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 = [
{

View File

@ -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,

View File

@ -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;

View File

@ -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,