fix(editor): Ensure toasts show above modal overlays (#11410)
Some checks failed
Test Master / install-and-build (push) Has been cancelled
Test Master / Unit tests (18.x) (push) Has been cancelled
Test Master / Unit tests (20.x) (push) Has been cancelled
Test Master / Unit tests (22.4) (push) Has been cancelled
Test Master / Lint (push) Has been cancelled
Test Master / Notify Slack on failure (push) Has been cancelled

This commit is contained in:
Mutasem Aldmour 2024-10-25 16:56:47 +02:00 committed by GitHub
parent d6aaeea2ab
commit 351134f786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 119 additions and 207 deletions

View file

@ -15,7 +15,7 @@ import {
TRELLO_NODE_NAME, TRELLO_NODE_NAME,
} from '../constants'; } from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages'; import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { successToast } from '../pages/notifications'; import { errorToast, successToast } from '../pages/notifications';
import { getVisibleSelect } from '../utils'; import { getVisibleSelect } from '../utils';
const credentialsPage = new CredentialsPage(); const credentialsPage = new CredentialsPage();
@ -278,4 +278,25 @@ describe('Credentials', () => {
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
nodeDetailsView.getters.copyInput().should('not.exist'); nodeDetailsView.getters.copyInput().should('not.exist');
}); });
it('ADO-2583 should show notifications above credential modal overlay', () => {
// check error notifications because they are sticky
cy.intercept('POST', '/rest/credentials', { forceNetworkError: true });
credentialsPage.getters.createCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
credentialsModal.getters.saveButton().click({ force: true });
errorToast().should('have.length', 1);
errorToast().should('be.visible');
errorToast().should('have.css', 'z-index', '2100');
cy.get('.el-overlay').should('have.css', 'z-index', '2001');
});
}); });

View file

@ -16,6 +16,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useHistoryHelper } from '@/composables/useHistoryHelper'; import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useStyles } from './composables/useStyles';
const route = useRoute(); const route = useRoute();
const rootStore = useRootStore(); const rootStore = useRootStore();
@ -24,6 +25,8 @@ const uiStore = useUIStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { setAppZIndexes } = useStyles();
// Initialize undo/redo // Initialize undo/redo
useHistoryHelper(route); useHistoryHelper(route);
@ -41,6 +44,7 @@ watch(defaultLocale, (newLocale) => {
}); });
onMounted(async () => { onMounted(async () => {
setAppZIndexes();
logHiringBanner(); logHiringBanner();
void useExternalHooks().run('app.mount'); void useExternalHooks().run('app.mount');
loading.value = false; loading.value = false;
@ -134,7 +138,7 @@ const updateGridWidth = async () => {
.banners { .banners {
grid-area: banners; grid-area: banners;
z-index: 999; z-index: var(--z-index-top-banners);
} }
.content { .content {
@ -154,13 +158,13 @@ const updateGridWidth = async () => {
.header { .header {
grid-area: header; grid-area: header;
z-index: 99; z-index: var(--z-index-app-header);
} }
.sidebar { .sidebar {
grid-area: sidebar; grid-area: sidebar;
height: 100%; height: 100%;
z-index: 999; z-index: var(--z-index-app-sidebar);
} }
.modals { .modals {

View file

@ -106,7 +106,7 @@ function onClose() {
.container { .container {
height: 100%; height: 100%;
flex-basis: content; flex-basis: content;
z-index: 300; z-index: var(--z-index-ask-assistant-chat);
} }
.wrapper { .wrapper {

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store'; import { useAssistantStore } from '@/stores/assistant.store';
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue'; import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue'; import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
@ -7,6 +8,7 @@ import { computed } from 'vue';
const assistantStore = useAssistantStore(); const assistantStore = useAssistantStore();
const i18n = useI18n(); const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const lastUnread = computed(() => { const lastUnread = computed(() => {
const msg = assistantStore.lastUnread; const msg = assistantStore.lastUnread;
@ -39,7 +41,7 @@ const onClick = () => {
data-test-id="ask-assistant-floating-button" data-test-id="ask-assistant-floating-button"
> >
<n8n-tooltip <n8n-tooltip
:z-index="4000" :z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
placement="top" placement="top"
:visible="!!lastUnread" :visible="!!lastUnread"
:popper-class="$style.tooltip" :popper-class="$style.tooltip"
@ -61,7 +63,7 @@ const onClick = () => {
position: absolute; position: absolute;
bottom: var(--spacing-s); bottom: var(--spacing-s);
right: var(--spacing-s); right: var(--spacing-s);
z-index: 3000; z-index: var(--z-index-ask-assistant-floating-button);
} }
.tooltip { .tooltip {

View file

@ -1,184 +0,0 @@
<script lang="ts">
import { type StyleValue, defineComponent, type PropType } from 'vue';
import type { ITemplatesNode } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/root.store';
interface NodeIconData {
type: string;
path?: string;
icon?: string;
fileExtension?: string;
fileBuffer?: string;
}
export default defineComponent({
name: 'HoverableNodeIcon',
props: {
circle: {
type: Boolean,
default: false,
},
clickButton: {
type: Function,
},
disabled: {
type: Boolean,
default: false,
},
nodeType: {
type: Object as PropType<INodeTypeDescription>,
required: true,
},
size: {
type: Number,
},
},
computed: {
...mapStores(useRootStore),
fontStyleData(): object {
return {
'max-width': this.size + 'px',
};
},
iconStyleData(): StyleValue {
const nodeType = this.nodeType;
const nodeTypeColor = nodeType?.defaults?.color;
const color = typeof nodeTypeColor === 'string' ? nodeTypeColor : '';
if (!this.size) {
return { color };
}
return {
color,
width: this.size + 'px',
height: this.size + 'px',
'font-size': this.size + 'px',
'line-height': this.size + 'px',
'border-radius': this.circle ? '50%' : '2px',
...(this.disabled && {
color: 'var(--color-text-light)',
'-webkit-filter': 'contrast(40%) brightness(1.5) grayscale(100%)',
filter: 'contrast(40%) brightness(1.5) grayscale(100%)',
}),
};
},
imageStyleData(): StyleValue {
return {
width: '100%',
'max-width': '100%',
'max-height': '100%',
};
},
nodeIconData(): null | NodeIconData {
const nodeType = this.nodeType as INodeTypeDescription | ITemplatesNode | null;
if (nodeType === null) {
return null;
}
if ((nodeType as ITemplatesNode).iconData) {
return (nodeType as ITemplatesNode).iconData;
}
const restUrl = this.rootStore.restUrl;
if (typeof nodeType.icon === 'string') {
const [type, path] = nodeType.icon.split(':');
const returnData: NodeIconData = {
type,
path,
};
if (type === 'file') {
returnData.path = restUrl + '/node-icon/' + nodeType.name;
returnData.fileExtension = path.split('.').slice(-1).join();
}
return returnData;
}
return null;
},
},
data() {
return {
showTooltip: false,
};
},
});
</script>
<template>
<div
:class="$style.wrapper"
:style="iconStyleData"
@click="() => $emit('click')"
@mouseover="showTooltip = true"
@mouseleave="showTooltip = false"
>
<div :class="$style.tooltip">
<n8n-tooltip placement="top" :visible="showTooltip">
<template #content>
<div v-text="nodeType.displayName"></div>
</template>
<span />
</n8n-tooltip>
</div>
<div v-if="nodeIconData !== null" :class="$style.icon" title="">
<div :class="$style.iconWrapper" :style="iconStyleData">
<div v-if="nodeIconData !== null" :class="$style.icon">
<img
v-if="nodeIconData.type === 'file'"
:src="nodeIconData.fileBuffer || nodeIconData.path"
:style="imageStyleData"
/>
<font-awesome-icon
v-else
:icon="nodeIconData.icon || nodeIconData.path"
:style="fontStyleData"
/>
</div>
<div v-else class="node-icon-placeholder">
{{ nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
</div>
</div>
</div>
<div v-else :class="$style.placeholder">
{{ nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
cursor: pointer;
z-index: 2000;
}
.icon {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.iconWrapper {
svg {
height: 100%;
width: 100%;
}
}
.placeholder {
text-align: center;
}
.tooltip {
left: 10px;
position: relative;
z-index: 9999;
}
</style>

View file

@ -5,6 +5,7 @@ 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'; import { APP_MODALS_ELEMENT_ID } from '@/constants';
import { useStyles } from '@/composables/useStyles';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -50,6 +51,8 @@ const props = withDefaults(
const emit = defineEmits<{ enter: [] }>(); const emit = defineEmits<{ enter: [] }>();
const { APP_Z_INDEXES } = useStyles();
const styles = computed(() => { const styles = computed(() => {
const styles: { [prop: string]: string } = {}; const styles: { [prop: string]: string } = {};
if (props.height) { if (props.height) {
@ -143,7 +146,7 @@ function getCustomClass() {
: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" :z-index="APP_Z_INDEXES.MODALS"
> >
<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

@ -173,7 +173,7 @@ onBeforeUnmount(() => {
top: $header-height; top: $header-height;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 200; z-index: var(--z-index-node-creator);
width: $node-creator-width; width: $node-creator-width;
color: $node-creator-text-color; color: $node-creator-text-color;
} }

View file

@ -36,6 +36,7 @@ import { usePinnedData } from '@/composables/usePinnedData';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useStyles } from '@/composables/useStyles';
const emit = defineEmits<{ const emit = defineEmits<{
saveKeyboardShortcut: [event: KeyboardEvent]; saveKeyboardShortcut: [event: KeyboardEvent];
@ -73,6 +74,7 @@ const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
const message = useMessage(); const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
const settingsEventBus = createEventBus(); const settingsEventBus = createEventBus();
const redrawRequired = ref(false); const redrawRequired = ref(false);
@ -668,7 +670,7 @@ onBeforeUnmount(() => {
width="auto" width="auto"
:append-to="`#${APP_MODALS_ELEMENT_ID}`" :append-to="`#${APP_MODALS_ELEMENT_ID}`"
data-test-id="ndv" data-test-id="ndv"
:z-index="1800" :z-index="APP_Z_INDEXES.NDV"
:data-has-output-connection="hasOutputConnection" :data-has-output-connection="hasOutputConnection"
> >
<n8n-tooltip <n8n-tooltip

View file

@ -20,6 +20,7 @@ import { assert } from '@/utils/assert';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui'; import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { useNodeBase } from '@/composables/useNodeBase'; import { useNodeBase } from '@/composables/useNodeBase';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useStyles } from '@/composables/useStyles';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -54,6 +55,7 @@ const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const { APP_Z_INDEXES } = useStyles();
const isResizing = ref<boolean>(false); const isResizing = ref<boolean>(false);
const isTouchActive = ref<boolean>(false); const isTouchActive = ref<boolean>(false);
@ -136,7 +138,9 @@ const stickySize = computed<StyleValue>(() => ({
const stickyPosition = computed<StyleValue>(() => ({ const stickyPosition = computed<StyleValue>(() => ({
left: position.value[0] + 'px', left: position.value[0] + 'px',
top: position.value[1] + 'px', top: position.value[1] + 'px',
zIndex: props.isActive ? 9999999 : -1 * Math.floor((height.value * width.value) / 1000), zIndex: props.isActive
? APP_Z_INDEXES.ACTIVE_STICKY
: -1 * Math.floor((height.value * width.value) / 1000),
})); }));
const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning); const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning);

View file

@ -242,7 +242,7 @@ watch(
left: 0; left: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
z-index: 9999999; z-index: var(--z-index-workflow-preview-ndv);
} }
.spinner { .spinner {

View file

@ -7,6 +7,7 @@ import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useContextMenu } from './useContextMenu'; import { useContextMenu } from './useContextMenu';
import { useStyles } from './useStyles';
interface ExtendedHTMLSpanElement extends HTMLSpanElement { interface ExtendedHTMLSpanElement extends HTMLSpanElement {
x: number; x: number;
@ -22,6 +23,7 @@ export default function useCanvasMouseSelect() {
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const { isOpen: isContextMenuOpen } = useContextMenu(); const { isOpen: isContextMenuOpen } = useContextMenu();
const { APP_Z_INDEXES } = useStyles();
function _setSelectBoxStyle(styles: Record<string, string>) { function _setSelectBoxStyle(styles: Record<string, string>) {
Object.assign(selectBox.value.style, styles); Object.assign(selectBox.value.style, styles);
@ -106,7 +108,7 @@ export default function useCanvasMouseSelect() {
border: '2px dotted #FF0000', border: '2px dotted #FF0000',
// Positioned absolutely within #node-view. This is consistent with how nodes are positioned. // Positioned absolutely within #node-view. This is consistent with how nodes are positioned.
position: 'absolute', position: 'absolute',
zIndex: '100', zIndex: `${APP_Z_INDEXES.SELECT_BOX}`,
visibility: 'hidden', visibility: 'hidden',
}); });

View file

@ -0,0 +1,25 @@
import { useStyles } from './useStyles';
describe('useStyles', () => {
it('sets z-index as css variables', () => {
vi.spyOn(global.document.documentElement.style, 'setProperty');
const { setAppZIndexes } = useStyles();
setAppZIndexes();
expect(global.document.documentElement.style.setProperty).toHaveBeenNthCalledWith(
1,
'--z-index-app-header',
'99',
);
expect(global.document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--z-index-canvas-add-button',
'101',
);
expect(global.document.documentElement.style.setProperty).toHaveBeenLastCalledWith(
'--z-index-workflow-preview-ndv',
'9999999',
);
});
});

View file

@ -0,0 +1,31 @@
const APP_Z_INDEXES = {
APP_HEADER: 99,
SELECT_BOX: 100,
CANVAS_ADD_BUTTON: 101,
NODE_CREATOR: 200,
ASK_ASSISTANT_CHAT: 300,
APP_SIDEBAR: 999,
CANVAS_SELECT_BOX: 100,
TOP_BANNERS: 999,
NDV: 1800,
MODALS: 2000,
TOASTS: 2100,
ASK_ASSISTANT_FLOATING_BUTTON: 3000,
ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP: 3000,
DRAGGABLE: 9999999,
ACTIVE_STICKY: 9999999,
WORKFLOW_PREVIEW_NDV: 9999999,
} as const;
const setAppZIndexes = () => {
Object.keys(APP_Z_INDEXES).forEach((key) => {
const variableName = `--z-index-${key.toLowerCase().replaceAll('_', '-')}`;
const value = APP_Z_INDEXES[key as keyof typeof APP_Z_INDEXES];
document.documentElement.style.setProperty(variableName, `${value}`);
});
};
export const useStyles = () => ({
APP_Z_INDEXES,
setAppZIndexes,
});

View file

@ -9,6 +9,7 @@ import { useI18n } from './useI18n';
import { useExternalHooks } from './useExternalHooks'; import { useExternalHooks } from './useExternalHooks';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import type { ApplicationError } from 'n8n-workflow'; import type { ApplicationError } from 'n8n-workflow';
import { useStyles } from './useStyles';
export interface NotificationErrorWithNodeAndDescription extends ApplicationError { export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
node: { node: {
@ -17,15 +18,6 @@ export interface NotificationErrorWithNodeAndDescription extends ApplicationErro
description: string; description: string;
} }
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
dangerouslyUseHTMLString: false,
position: 'bottom-right',
zIndex: 1900, // above NDV and below the modals
offset: 64,
appendTo: '#app-grid',
customClass: 'content-toast',
};
const stickyNotificationQueue: NotificationHandle[] = []; const stickyNotificationQueue: NotificationHandle[] = [];
export function useToast() { export function useToast() {
@ -34,6 +26,16 @@ export function useToast() {
const uiStore = useUIStore(); const uiStore = useUIStore();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
const i18n = useI18n(); const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
dangerouslyUseHTMLString: false,
position: 'bottom-right',
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset: 64,
appendTo: '#app-grid',
customClass: 'content-toast',
};
function showMessage(messageData: Partial<NotificationOptions>, track = true) { function showMessage(messageData: Partial<NotificationOptions>, track = true) {
const { message, title } = messageData; const { message, title } = messageData;

View file

@ -55,7 +55,7 @@ const containerCssVars = computed(() => ({
left: var(--trigger-placeholder-left-position); left: var(--trigger-placeholder-left-position);
// We have to increase z-index to make sure it's higher than selecting box in NodeView // We have to increase z-index to make sure it's higher than selecting box in NodeView
// otherwise the clicks wouldn't register // otherwise the clicks wouldn't register
z-index: 101; z-index: var(--z-index-canvas-add-button);
&:hover .button svg path { &:hover .button svg path {
fill: var(--color-primary); fill: var(--color-primary);