feat(editor): Main navigation redesign (#4144)

* refactor(editor): N8N-4540 Main navigation layout rework (#4060)

*  Implemented new editor layout using css grid

*  Reworking main navigation layout, migrating some styling to css modules

*  Reworking main sidebar layout and responsiveness

* 💄 Minor type update

*  Updated editor grid layout so empty cells are collapsed (`fit-content`), fixed updates menu items styling

*  Implemented new user area look & feel in main sidebar

* 💄 Adjusting sidebar bottom padding when user area is not shown

* 💄 CSS cleanup/refactor + minor vue refactoring

*  Fixing overscoll issue in chrome and scrolling behaviour of the content view

* 👌 Addressing review feedback

*  Added collapsed and expanded versions of n8n logo

*  Updating infinite scrolling in templates view to work with the new layout

* 💄 Updating main sidebar expanded width and templates view left margin

* 💄 Updating main content height

* 💄 Adding global styles for scrollable views with centered content, minor updates to user area

*  Updating zoomToFit logic, lasso select box position and new nodes positioning

*  Fixing new node drop position now that mouse detection has been adjusted

* 👌 Updating templates view scroll to top logic and responsive padding, aligning menu items titles

* 💄 Moving template layout style from global css class to component level

*  Moved 'Workflows'  menu to node view header. Added new dropdown component for user area and the new WF menu

* 💄 Updating disabled states in new WF menu

* 💄 Initial stab at new sidebar styling

*  Finished main navigation restyling

*  Updating `zoomToFit` and centering logic

*  Adding updates menu item to settings sidebar

* 💄 Adding updates item to the settings sidebar and final touches on main sidebar style

* 💄 Removing old code & refactoring

* 💄 Minor CSS tweaks

* 💄 Opening credentials modal on sidebar menu item click. Minor CSS updates

* 💄 Updating sidebar expand/collapse animation

* 💄 Few more refinements of sidebar animation

* 👌 Addressing code review comments

*  Moved ActionDropdown component to design system

* 👌 Fixing bugs reported during code review and testing

* 👌 Addressing design review comments for the new sidebar

* ✔️ Updating `N8nActionDropdown` component tests

*  Remembering scroll position when going back to templates list

*  Updating zoomToFit logic to account for footer content

* 👌 Addressing latest sidebar review comments

* 👌 Addressing main sidebar product review comments

* 💄 Updating css variable names after vite merge

* ✔️ Fixing linting errors in the design system

* ✔️ Fixing `element-ui` type import

* 👌 Addressing the code review comments.

*  Adding link to new credentials view, removed old modal

* 💄 Updating credentials view responsiveness and route highlight handling

* 💄 Adding highlight to workflows submenu when on new workflow page

* 💄 Updated active submenu text color
This commit is contained in:
Milorad FIlipović 2022-09-26 15:25:19 +02:00 committed by GitHub
parent e6e4f297c6
commit 3db53a1934
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1358 additions and 928 deletions

View file

@ -0,0 +1,72 @@
import N8nActionDropdown from "./ActionDropdown.vue";
import { StoryFn } from '@storybook/vue';
export default {
title: 'Atoms/ActionDropdown',
component: N8nActionDropdown,
argTypes: {
placement: {
control: {
type: 'select',
options: ['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start'],
},
},
activatorIcon: {
control: {
type: 'text',
},
},
trigger: {
control: {
type: 'select',
options: ['click', 'hover'],
},
},
},
};
const template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nActionDropdown,
},
template: `<n8n-action-dropdown v-bind="$props" />`,
});
export const defaultActionDropdown = template.bind({});
defaultActionDropdown.args = {
items: [
{
id: 'item1',
label: 'Action 1',
},
{
id: 'item2',
label: 'Action 2',
},
],
};
export const customStyling = template.bind({});
customStyling.args = {
activatorIcon: 'bars',
items: [
{
id: 'item1',
label: 'Action 1',
icon: 'thumbs-up',
},
{
id: 'item2',
label: 'Action 2',
icon: 'thumbs-down',
disabled: true,
},
{
id: 'item3',
label: 'Action 3',
icon: 'heart',
divided: true,
},
],
};

View file

@ -0,0 +1,130 @@
<template>
<div :class="['action-dropdown-container', $style.actionDropdownContainer]">
<el-dropdown :placement="placement" :trigger="trigger" @command="onSelect">
<div :class="$style.activator">
<n8n-icon :icon="activatorIcon"/>
</div>
<el-dropdown-menu slot="dropdown" :class="$style.userActionsMenu">
<el-dropdown-item
v-for="item in items"
:key="item.id"
:command="item.id"
:disabled="item.disabled"
:divided="item.divided"
>
<div :class="{
[$style.itemContainer]: true,
[$style.hasCustomStyling]: item.customClass !== undefined,
[item.customClass]: item.customClass !== undefined,
}">
<span v-if="item.icon" :class="$style.icon">
<n8n-icon :icon="item.icon"/>
</span>
<span :class="$style.label">
{{ item.label }}
</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from "vue";
import ElDropdown from 'element-ui/lib/dropdown';
import ElDropdownMenu from 'element-ui/lib/dropdown-menu';
import ElDropdownItem from 'element-ui/lib/dropdown-item';
import N8nIcon from '../N8nIcon';
interface IActionDropdownItem {
id: string;
label: string;
icon?: string;
divided?: boolean;
disabled?: boolean;
customClass?: string;
}
// This component is visually similar to the ActionToggle component
// but it offers more options when it comes to dropdown items styling
// (supports icons, separators, custom styling and all options provided
// by Element UI dropdown component).
// It can be used in different parts of editor UI while ActionToggle
// is designed to be used in card components.
export default Vue.extend({
name: 'n8n-action-dropdown',
components: {
ElDropdownMenu, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
ElDropdown, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
ElDropdownItem, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
N8nIcon,
},
props: {
items: {
type: Array as PropType<IActionDropdownItem[]>,
required: true,
},
placement: {
type: String,
default: 'bottom',
validator: (value: string): boolean =>
['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start'].includes(value),
},
activatorIcon: {
type: String,
default: 'ellipsis-v',
},
trigger: {
type: String,
default: 'click',
validator: (value: string): boolean =>
['click', 'hover'].includes(value),
},
},
methods: {
onSelect(action: string) : void {
this.$emit('select', action);
},
},
});
</script>
<style lang="scss" module>
.activator {
cursor: pointer;
padding: var(--spacing-2xs);
margin: 0;
border-radius: var(--border-radius-base);
line-height: normal !important;
svg {
position: static !important;
}
&:hover {
background-color: var(--color-background-base);
color: initial !important;
}
}
.itemContainer {
display: flex;
}
.icon {
text-align: center;
margin-right: var(--spacing-2xs);
svg { width: 1.2em !important; }
}
:global(li.is-disabled) {
.hasCustomStyling {
color: inherit !important;
}
}
</style>

View file

@ -0,0 +1,52 @@
import { render } from '@testing-library/vue';
import N8nActionDropdown from '../ActionDropdown.vue';
describe('components', () => {
describe('N8nActionDropdown', () => {
it('should render default styling correctly', () => {
const wrapper = render(N8nActionDropdown, {
props: {
items: [
{
id: 'item1',
label: 'Action 1',
},
{
id: 'item2',
label: 'Action 2',
},
],
},
stubs: ['n8n-icon', 'el-dropdown', 'el-dropdown-menu', 'el-dropdown-item'],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render custom styling correctly', () => {
const wrapper = render(N8nActionDropdown, {
props: {
items: [
{
id: 'item1',
label: 'Action 1',
icon: 'thumbs-up',
},
{
id: 'item2',
label: 'Action 2',
icon: 'thumbs-down',
disabled: true,
},
{
id: 'item3',
label: 'Action 3',
icon: 'heart',
divided: true,
},
],
},
stubs: ['n8n-icon', 'el-dropdown', 'el-dropdown-menu', 'el-dropdown-item'],
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,42 @@
// Vitest Snapshot v1
exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = `
"<div class=\\"action-dropdown-container\\">
<el-dropdown-stub trigger=\\"click\\" size=\\"\\" hideonclick=\\"true\\" placement=\\"bottom\\" visiblearrow=\\"true\\" showtimeout=\\"250\\" hidetimeout=\\"150\\" tabindex=\\"0\\">
<div class=\\"_activator_lf4ng_1\\">
<n8n-icon-stub icon=\\"ellipsis-v\\" size=\\"medium\\"></n8n-icon-stub>
</div>
<el-dropdown-menu-stub transformorigin=\\"true\\" placement=\\"bottom\\" boundariespadding=\\"5\\" offset=\\"0\\" visiblearrow=\\"true\\" arrowoffset=\\"0\\" appendtobody=\\"true\\" popperoptions=\\"[object Object]\\">
<el-dropdown-item-stub command=\\"item1\\">
<div class=\\"_itemContainer_lf4ng_16\\"><span class=\\"_icon_lf4ng_20\\"><n8n-icon-stub icon=\\"thumbs-up\\" size=\\"medium\\"></n8n-icon-stub></span><span> Action 1 </span></div>
</el-dropdown-item-stub>
<el-dropdown-item-stub command=\\"item2\\" disabled=\\"true\\">
<div class=\\"_itemContainer_lf4ng_16\\"><span class=\\"_icon_lf4ng_20\\"><n8n-icon-stub icon=\\"thumbs-down\\" size=\\"medium\\"></n8n-icon-stub></span><span> Action 2 </span></div>
</el-dropdown-item-stub>
<el-dropdown-item-stub command=\\"item3\\" divided=\\"true\\">
<div class=\\"_itemContainer_lf4ng_16\\"><span class=\\"_icon_lf4ng_20\\"><n8n-icon-stub icon=\\"heart\\" size=\\"medium\\"></n8n-icon-stub></span><span> Action 3 </span></div>
</el-dropdown-item-stub>
</el-dropdown-menu-stub>
</el-dropdown-stub>
</div>"
`;
exports[`components > N8nActionDropdown > should render default styling correctly 1`] = `
"<div class=\\"action-dropdown-container\\">
<el-dropdown-stub trigger=\\"click\\" size=\\"\\" hideonclick=\\"true\\" placement=\\"bottom\\" visiblearrow=\\"true\\" showtimeout=\\"250\\" hidetimeout=\\"150\\" tabindex=\\"0\\">
<div class=\\"_activator_lf4ng_1\\">
<n8n-icon-stub icon=\\"ellipsis-v\\" size=\\"medium\\"></n8n-icon-stub>
</div>
<el-dropdown-menu-stub transformorigin=\\"true\\" placement=\\"bottom\\" boundariespadding=\\"5\\" offset=\\"0\\" visiblearrow=\\"true\\" arrowoffset=\\"0\\" appendtobody=\\"true\\" popperoptions=\\"[object Object]\\">
<el-dropdown-item-stub command=\\"item1\\">
<div class=\\"_itemContainer_lf4ng_16\\">
<!----><span> Action 1 </span></div>
</el-dropdown-item-stub>
<el-dropdown-item-stub command=\\"item2\\">
<div class=\\"_itemContainer_lf4ng_16\\">
<!----><span> Action 2 </span></div>
</el-dropdown-item-stub>
</el-dropdown-menu-stub>
</el-dropdown-stub>
</div>"
`;

View file

@ -0,0 +1,2 @@
import N8nActionDropdown from './ActionDropdown.vue';
export default N8nActionDropdown;

View file

@ -15,7 +15,7 @@ html {
body {
height: 100%;
width: 100%;
overscroll-behavior-x: none;
overscroll-behavior: none;
line-height: 1;
font-size: var(--font-size-m);
font-weight: var(--font-weight-regular);

View file

@ -1,5 +1,6 @@
import Vue from 'vue';
import N8nActionBox from '../components/N8nActionBox';
import N8nActionDropdown from '../components/N8nActionDropdown';
import N8nActionToggle from '../components/N8nActionToggle';
import N8nAvatar from '../components/N8nAvatar';
import N8nBadge from "../components/N8nBadge";
@ -46,6 +47,7 @@ export default {
install: (app: typeof Vue, options?: {}) => {
app.component('n8n-info-accordion', N8nInfoAccordion);
app.component('n8n-action-box', N8nActionBox);
app.component('n8n-action-dropdown', N8nActionDropdown);
app.component('n8n-action-toggle', N8nActionToggle);
app.component('n8n-avatar', N8nAvatar);
app.component('n8n-badge', N8nBadge);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,3 @@
<svg width="32" height="16" viewBox="0 0 32 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.0125 6.4C26.5214 6.4 25.2686 5.38018 24.9133 4H21.2456C20.4635 4 19.796 4.56546 19.6674 5.33696L19.5359 6.12608C19.411 6.87531 19.032 7.52738 18.496 8C19.032 8.47262 19.411 9.12469 19.5359 9.87392L19.6674 10.663C19.796 11.4345 20.4635 12 21.2456 12H21.7133C22.0686 10.6198 23.3214 9.6 24.8125 9.6C26.5798 9.6 28.0125 11.0327 28.0125 12.8C28.0125 14.5673 26.5798 16 24.8125 16C23.3214 16 22.0685 14.9802 21.7133 13.6H21.2456C19.6813 13.6 18.3463 12.4691 18.0891 10.9261L17.9576 10.137C17.829 9.36546 17.1615 8.8 16.3794 8.8H15.1117C14.7565 10.1802 13.5036 11.2 12.0125 11.2C10.5214 11.2 9.26855 10.1802 8.91331 8.8H7.11169C6.75645 10.1802 5.50357 11.2 4.0125 11.2C2.24519 11.2 0.8125 9.76731 0.8125 8C0.8125 6.23269 2.24519 4.8 4.0125 4.8C5.50357 4.8 6.75645 5.81982 7.11169 7.2H8.91331C9.26855 5.81982 10.5214 4.8 12.0125 4.8C13.5036 4.8 14.7565 5.81982 15.1117 7.2H16.3794C17.1615 7.2 17.829 6.63454 17.9576 5.86304L18.0891 5.07392C18.3463 3.53092 19.6813 2.4 21.2456 2.4L24.9133 2.4C25.2685 1.01982 26.5214 0 28.0125 0C29.7798 0 31.2125 1.43269 31.2125 3.2C31.2125 4.96731 29.7798 6.4 28.0125 6.4ZM28.0125 4.8C28.8962 4.8 29.6125 4.08366 29.6125 3.2C29.6125 2.31634 28.8962 1.6 28.0125 1.6C27.1288 1.6 26.4125 2.31634 26.4125 3.2C26.4125 4.08366 27.1288 4.8 28.0125 4.8ZM4.0125 9.6C4.89616 9.6 5.6125 8.88366 5.6125 8C5.6125 7.11634 4.89616 6.4 4.0125 6.4C3.12884 6.4 2.4125 7.11634 2.4125 8C2.4125 8.88366 3.12884 9.6 4.0125 9.6ZM13.6125 8C13.6125 8.88366 12.8962 9.6 12.0125 9.6C11.1288 9.6 10.4125 8.88366 10.4125 8C10.4125 7.11634 11.1288 6.4 12.0125 6.4C12.8962 6.4 13.6125 7.11634 13.6125 8ZM26.4125 12.8C26.4125 13.6837 25.6962 14.4 24.8125 14.4C23.9288 14.4 23.2125 13.6837 23.2125 12.8C23.2125 11.9163 23.9288 11.2 24.8125 11.2C25.6962 11.2 26.4125 11.9163 26.4125 12.8Z" fill="#EA4B71"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,6 @@
<svg width="58" height="16" viewBox="0 0 58 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.2 6.4C25.7089 6.4 24.4561 5.38018 24.1008 4H20.4331C19.651 4 18.9835 4.56546 18.8549 5.33696L18.7234 6.12608C18.5985 6.87531 18.2195 7.52738 17.6835 8C18.2195 8.47262 18.5985 9.12469 18.7234 9.87392L18.8549 10.663C18.9835 11.4345 19.651 12 20.4331 12H20.9008C21.2561 10.6198 22.5089 9.6 24 9.6C25.7673 9.6 27.2 11.0327 27.2 12.8C27.2 14.5673 25.7673 16 24 16C22.5089 16 21.256 14.9802 20.9008 13.6H20.4331C18.8688 13.6 17.5338 12.4691 17.2766 10.9261L17.1451 10.137C17.0165 9.36546 16.349 8.8 15.5669 8.8H14.2992C13.944 10.1802 12.6911 11.2 11.2 11.2C9.70893 11.2 8.45605 10.1802 8.10081 8.8H6.29919C5.94395 10.1802 4.69107 11.2 3.2 11.2C1.43269 11.2 0 9.76731 0 8C0 6.23269 1.43269 4.8 3.2 4.8C4.69107 4.8 5.94395 5.81982 6.29919 7.2H8.10081C8.45605 5.81982 9.70893 4.8 11.2 4.8C12.6911 4.8 13.944 5.81982 14.2992 7.2H15.5669C16.349 7.2 17.0165 6.63454 17.1451 5.86304L17.2766 5.07392C17.5338 3.53092 18.8688 2.4 20.4331 2.4L24.1008 2.4C24.456 1.01982 25.7089 0 27.2 0C28.9673 0 30.4 1.43269 30.4 3.2C30.4 4.96731 28.9673 6.4 27.2 6.4ZM27.2 4.8C28.0837 4.8 28.8 4.08366 28.8 3.2C28.8 2.31634 28.0837 1.6 27.2 1.6C26.3163 1.6 25.6 2.31634 25.6 3.2C25.6 4.08366 26.3163 4.8 27.2 4.8ZM3.2 9.6C4.08366 9.6 4.8 8.88366 4.8 8C4.8 7.11634 4.08366 6.4 3.2 6.4C2.31634 6.4 1.6 7.11634 1.6 8C1.6 8.88366 2.31634 9.6 3.2 9.6ZM12.8 8C12.8 8.88366 12.0837 9.6 11.2 9.6C10.3163 9.6 9.6 8.88366 9.6 8C9.6 7.11634 10.3163 6.4 11.2 6.4C12.0837 6.4 12.8 7.11634 12.8 8ZM25.6 12.8C25.6 13.6837 24.8837 14.4 24 14.4C23.1163 14.4 22.4 13.6837 22.4 12.8C22.4 11.9163 23.1163 11.2 24 11.2C24.8837 11.2 25.6 11.9163 25.6 12.8Z" fill="#EA4B71"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.0015 6.91832V6.84207C48.5598 6.5625 49.118 6.07959 49.118 5.12649C49.118 3.75402 47.9888 2.928 46.4282 2.928C44.8296 2.928 43.6877 3.80486 43.6877 5.15191C43.6877 6.06688 44.2206 6.5625 44.8042 6.84207V6.91832C44.1571 7.14707 43.3832 7.8333 43.3832 8.97702C43.3832 10.3622 44.5251 11.328 46.4155 11.328C48.306 11.328 49.4098 10.3622 49.4098 8.97702C49.4098 7.8333 48.6486 7.15977 48.0015 6.91832ZM46.4155 4.09714C47.0499 4.09714 47.5194 4.5038 47.5194 5.19003C47.5194 5.87626 47.0372 6.28292 46.4155 6.28292C45.7938 6.28292 45.2737 5.87626 45.2737 5.19003C45.2737 4.49109 45.7685 4.09714 46.4155 4.09714ZM46.4155 10.108C45.6797 10.108 45.0833 9.63784 45.0833 8.83723C45.0833 8.11287 45.5782 7.56643 46.4029 7.56643C47.2149 7.56643 47.7097 8.10017 47.7097 8.86265C47.7097 9.63784 47.1387 10.108 46.4155 10.108Z" fill="#101330"/>
<path d="M51.3672 11.2009H52.9912V7.75705C52.9912 6.62604 53.6763 6.13042 54.4503 6.13042C55.2116 6.13042 55.8079 6.63874 55.8079 7.6808V11.2009H57.4319V7.35039C57.4319 5.68564 56.4676 4.71983 54.9578 4.71983C54.0062 4.71983 53.4733 5.10107 53.0927 5.59669H52.9912L52.8516 4.84691H51.3672V11.2009Z" fill="#101330"/>
<path d="M36.9912 11.2009H35.3672V4.84691H36.8516L36.9912 5.59669H37.0927C37.4733 5.10107 38.0062 4.71983 38.9578 4.71983C40.4676 4.71983 41.4319 5.68564 41.4319 7.35039V11.2009H39.8079V7.6808C39.8079 6.63874 39.2116 6.13042 38.4503 6.13042C37.6763 6.13042 36.9912 6.62604 36.9912 7.75705V11.2009Z" fill="#101330"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -1,14 +1,21 @@
<template>
<div :class="$style.container">
<div :class="[$style.app, 'root-container']">
<LoadingView v-if="loading" />
<div v-else id="app" :class="$style.container">
<div id="header" :class="$style.header">
<div
v-else
id="app"
:class="{
[$style.container]: true,
[$style.sidebarCollapsed]: sidebarMenuCollapsed
}"
>
<div id="header" :class="$style['header']">
<router-view name="header"></router-view>
</div>
<div id="sidebar" :class="$style.sidebar">
<div id="sidebar" :class="$style['sidebar']">
<router-view name="sidebar"></router-view>
</div>
<div id="content" :class="$style.content">
<div id="content" :class="$style['content']">
<router-view />
</div>
<Modals />
@ -47,6 +54,7 @@ export default mixins(
computed: {
...mapGetters('settings', ['isHiringBannerEnabled', 'isTemplatesEnabled', 'isTemplatesEndpointReachable', 'isUserManagementEnabled', 'showSetupPage']),
...mapGetters('users', ['currentUser']),
...mapGetters('ui', ['sidebarMenuCollapsed']),
defaultLocale (): string {
return this.$store.getters.defaultLocale;
},
@ -180,26 +188,34 @@ export default mixins(
</script>
<style lang="scss" module>
.app {
height: 100vh;
overflow: hidden;
}
.container {
height: 100%;
width: 100%;
display: grid;
grid-template-areas:
"sidebar header"
"sidebar content";
grid-auto-columns: fit-content($sidebar-expanded-width) 1fr;
grid-template-rows: fit-content($sidebar-width) 1fr;
}
.content {
composes: container;
background-color: var(--color-background-light);
position: relative;
grid-area: content;
overflow: auto;
height: 100vh;
}
.header {
z-index: 10;
position: fixed;
width: 100%;
grid-area: header;
z-index: 999;
}
.sidebar {
z-index: 15;
position: fixed;
grid-area: sidebar;
height: 100vh;
z-index: 999;
}
</style>

View file

@ -1,167 +0,0 @@
<template>
<Modal
:name="CREDENTIAL_LIST_MODAL_KEY"
width="80%"
:title="$locale.baseText('credentialsList.credentials')"
>
<template v-slot:content>
<n8n-heading tag="h3" size="small" color="text-light">{{ $locale.baseText('credentialsList.yourSavedCredentials') + ':' }}</n8n-heading>
<div class="new-credentials-button">
<n8n-button
:title="$locale.baseText('credentialsList.createNewCredential')"
icon="plus"
:label="$locale.baseText('credentialsList.addNew')"
size="large"
@click="createCredential()"
/>
</div>
<el-table class="ph-no-capture" :data="credentialsToDisplay" v-loading="loading" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table-column property="name" :label="$locale.baseText('credentialsList.name')" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" :label="$locale.baseText('credentialsList.type')" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" :label="$locale.baseText('credentialsList.created')" class-name="clickable" sortable></el-table-column>
<el-table-column property="updatedAt" :label="$locale.baseText('credentialsList.updated')" class-name="clickable" sortable></el-table-column>
<el-table-column
:label="$locale.baseText('credentialsList.operations')"
width="120">
<template slot-scope="scope">
<div class="cred-operations">
<n8n-icon-button :title="$locale.baseText('credentialsList.editCredential')" @click.stop="editCredential(scope.row)" size="small" icon="pen" />
<n8n-icon-button :title="$locale.baseText('credentialsList.deleteCredential')" @click.stop="deleteCredential(scope.row)" size="small" icon="trash" />
</div>
</template>
</el-table-column>
</el-table>
</template>
</Modal>
</template>
<script lang="ts">
import { externalHooks } from '@/components/mixins/externalHooks';
import { ICredentialsResponse } from '@/Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
import { convertToDisplayDate } from './helpers';
import { CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY } from '@/constants';
import Modal from './Modal.vue';
export default mixins(
externalHooks,
genericHelpers,
nodeHelpers,
showMessage,
).extend({
name: 'CredentialsList',
components: {
Modal,
},
data() {
return {
CREDENTIAL_LIST_MODAL_KEY,
loading: true,
};
},
computed: {
...mapGetters('credentials', ['allCredentials']),
credentialsToDisplay(): ICredentialsResponse[] {
return this.allCredentials.reduce((accu: ICredentialsResponse[], cred: ICredentialsResponse) => {
const type = this.$store.getters['credentials/getCredentialTypeByName'](cred.type);
if (type) {
accu.push({
...cred,
type: type.displayName,
createdAt: convertToDisplayDate(cred.createdAt as number),
updatedAt: convertToDisplayDate(cred.updatedAt as number),
});
}
return accu;
}, []);
},
},
async mounted() {
try {
await Promise.all([
await this.$store.dispatch('credentials/fetchCredentialTypes'),
await this.$store.dispatch('credentials/fetchAllCredentials'),
]);
} catch (e) {
this.$showError(e, this.$locale.baseText('credentialsList.errorLoadingCredentials'));
}
this.loading = false;
this.$telemetry.track('User opened Credentials panel', { workflow_id: this.$store.getters.workflowId });
},
destroyed() {
this.$externalHooks().run('credentialsList.destroyed');
},
methods: {
createCredential () {
this.$store.dispatch('ui/openModal', CREDENTIAL_SELECT_MODAL_KEY);
},
editCredential (credential: ICredentialsResponse) {
this.$store.dispatch('ui/openExistingCredential', { id: credential.id});
this.$telemetry.track('User opened Credential modal', { credential_type: credential.type, source: 'primary_menu', new_credential: false, workflow_id: this.$store.getters.workflowId });
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText(
'credentialsList.confirmMessage.message',
{ interpolate: { credentialName: credential.name }},
),
this.$locale.baseText('credentialsList.confirmMessage.headline'),
null,
this.$locale.baseText('credentialsList.confirmMessage.confirmButtonText'),
this.$locale.baseText('credentialsList.confirmMessage.cancelButtonText'),
);
if (deleteConfirmed === false) {
return;
}
try {
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
} catch (error) {
this.$showError(
error,
this.$locale.baseText('credentialsList.showError.deleteCredential.title'),
);
return;
}
// Now that the credentials got removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: this.$locale.baseText('credentialsList.showMessage.title'),
type: 'success',
});
},
},
});
</script>
<style lang="scss" scoped>
.new-credentials-button {
float: right;
position: relative;
margin-bottom: var(--spacing-2xs);
}
.cred-operations {
> * {
margin-left: 10px;
}
}
</style>

View file

@ -12,6 +12,10 @@
display: flex;
position: relative;
svg {
margin-right: 0 !important;
}
.notification {
height: .47em;
width: .47em;
@ -30,6 +34,7 @@
width: .36em;
background-color: $gift-notification-inner-color;
border-radius: 50%;
}
}
}

View file

@ -1,6 +1,6 @@
<template>
<img
:src="basePath + 'n8n-logo.svg'"
:src="basePath + 'n8n-logo-expanded.svg'"
:class="$style.img"
alt="n8n.io"
/>

View file

@ -55,18 +55,11 @@ export default mixins(
<style lang="scss">
.main-header {
position: fixed;
top: 0;
background-color: var(--color-background-xlight);
height: 65px;
width: 100%;
box-sizing: border-box;
padding-left: $sidebar-width;
&.expanded {
padding-left: $sidebar-expanded-width;
}
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
}
.top-menu {

View file

@ -70,6 +70,10 @@
:disabled="isWorkflowSaving"
@click="onSaveButtonClick"
/>
<div :class="$style.workflowMenuContainer">
<input :class="$style.hiddenInput" type="file" ref="importFile" @change="handleFileImport()">
<n8n-action-dropdown :items="workflowMenuItems" @select="onWorkflowMenuSelect" />
</div>
</template>
</PushConnectionTracker>
</div>
@ -79,7 +83,12 @@
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { MAX_WORKFLOW_NAME_LENGTH } from "@/constants";
import {
DUPLICATE_MODAL_KEY,
MAX_WORKFLOW_NAME_LENGTH,
VIEWS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY,
} from "@/constants";
import ShortenName from "@/components/ShortenName.vue";
import TagsContainer from "@/components/TagsContainer.vue";
@ -90,6 +99,11 @@ import SaveButton from "@/components/SaveButton.vue";
import TagsDropdown from "@/components/TagsDropdown.vue";
import InlineTextEdit from "@/components/InlineTextEdit.vue";
import BreakpointsObserver from "@/components/BreakpointsObserver.vue";
import { IWorkflowDataUpdate, IWorkflowToShare } from "@/Interface";
import { saveAs } from 'file-saver';
import { titleChange } from "../mixins/titleChange";
import type { MessageBoxInputData } from 'element-ui/types/message-box';
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
@ -100,7 +114,7 @@ const hasChanged = (prev: string[], curr: string[]) => {
return curr.reduce((accu, val) => accu || !set.has(val), false);
};
export default mixins(workflowHelpers).extend({
export default mixins(workflowHelpers, titleChange).extend({
name: "WorkflowDetails",
components: {
TagsContainer,
@ -139,6 +153,51 @@ export default mixins(workflowHelpers).extend({
currentWorkflowId(): string {
return this.$route.params.name;
},
currentWorkflow (): string {
return this.$route.params.name;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
onWorkflowPage(): boolean {
return this.$route.meta && this.$route.meta.nodeView;
},
workflowMenuItems(): Array<{}> {
return [
{
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: this.$locale.baseText('menuActions.duplicate'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
},
{
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
label: this.$locale.baseText('menuActions.download'),
disabled: !this.onWorkflowPage,
},
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
label: this.$locale.baseText('menuActions.importFromUrl'),
disabled: !this.onWorkflowPage,
},
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
label: this.$locale.baseText('menuActions.importFromFile'),
disabled: !this.onWorkflowPage,
},
{
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: this.$locale.baseText('generic.settings'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
},
{
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: this.$locale.baseText('menuActions.delete'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
customClass: this.$style.deleteItem,
divided: true,
},
];
},
},
methods: {
async onSaveButtonClick () {
@ -220,6 +279,132 @@ export default mixins(workflowHelpers).extend({
}
cb(saved);
},
async handleFileImport(): Promise<void> {
const reader = new FileReader();
reader.onload = (event: ProgressEvent) => {
const data = (event.target as FileReader).result;
let workflowData: IWorkflowDataUpdate;
try {
workflowData = JSON.parse(data as string);
} catch (error) {
this.$showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
type: 'error',
});
return;
}
this.$root.$emit('importWorkflowData', { data: workflowData });
};
const input = this.$refs.importFile as HTMLInputElement;
if (input !== null && input.files !== null && input.files.length !== 0) {
reader.readAsText(input!.files[0]!);
}
},
async onWorkflowMenuSelect(action: string): Promise<void> {
switch (action) {
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.DOWNLOAD: {
const workflowData = await this.getWorkflowDataToSave();
const {tags, ...data} = workflowData;
if (data.id && typeof data.id === 'string') {
data.id = parseInt(data.id, 10);
}
const exportData: IWorkflowToShare = {
...data,
meta: {
instanceId: this.$store.getters.instanceId,
},
tags: (tags || []).map(tagId => {
const {usageCount, ...tag} = this.$store.getters["tags/getTagById"](tagId);
return tag;
}),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json;charset=utf-8',
});
let workflowName = this.$store.getters.workflowName || 'unsaved_workflow';
workflowName = workflowName.replace(/[^a-z0-9]/gi, '_');
this.$telemetry.track('User exported workflow', { workflow_id: workflowData.id });
saveAs(blob, workflowName + '.json');
break;
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
try {
const promptResponse = await this.$prompt(
this.$locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
this.$locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
{
confirmButtonText: this.$locale.baseText('mainSidebar.prompt.import'),
cancelButtonText: this.$locale.baseText('mainSidebar.prompt.cancel'),
inputErrorMessage: this.$locale.baseText('mainSidebar.prompt.invalidUrl'),
inputPattern: /^http[s]?:\/\/.*\.json$/i,
},
) as MessageBoxInputData;
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
break;
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: {
(this.$refs.importFile as HTMLInputElement).click();
break;
}
case WORKFLOW_MENU_ACTIONS.SETTINGS: {
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.DELETE: {
const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.message',
{ interpolate: { workflowName: this.workflowName } },
),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'),
'warning',
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.confirmButtonText'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.cancelButtonText'),
);
if (deleteConfirmed === false) {
return;
}
try {
await this.restApi().deleteWorkflow(this.currentWorkflow);
} catch (error) {
this.$showError(
error,
this.$locale.baseText('mainSidebar.showError.stopExecution.title'),
);
return;
}
this.$store.commit('setStateDirty', false);
// Reset tab title since workflow is deleted.
this.$titleReset();
this.$showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
type: 'success',
});
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
break;
}
default:
break;
}
},
},
watch: {
currentWorkflowId() {
@ -235,6 +420,8 @@ $--text-line-height: 24px;
$--header-spacing: 20px;
.container {
position: relative;
top: -1px;
width: 100%;
display: flex;
align-items: center;
@ -291,3 +478,17 @@ $--header-spacing: 20px;
align-items: center;
}
</style>
<style module lang="scss">
.workflowMenuContainer {
margin-left: var(--spacing-2xs);
}
.hiddenInput {
display: none;
}
.deleteItem {
color: var(--color-danger);
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -25,10 +25,6 @@
<CredentialsSelectModal />
</ModalRoot>
<ModalRoot :name="CREDENTIAL_LIST_MODAL_KEY">
<CredentialsList />
</ModalRoot>
<ModalRoot :name="DUPLICATE_MODAL_KEY">
<template v-slot:default="{ modalName, active }">
<DuplicateWorkflowDialog
@ -119,7 +115,6 @@ import {
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
@ -141,7 +136,6 @@ import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue';
import ChangePasswordModal from "./ChangePasswordModal.vue";
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import CredentialsList from "./CredentialsList.vue";
import InviteUsersModal from "./InviteUsersModal.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
import DuplicateWorkflowDialog from "./DuplicateWorkflowDialog.vue";
@ -167,7 +161,6 @@ export default Vue.extend({
ContactPromptModal,
ChangePasswordModal,
CredentialEdit,
CredentialsList,
CredentialsSelectModal,
DeleteUserModal,
DuplicateWorkflowDialog,
@ -187,7 +180,6 @@ export default Vue.extend({
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
ABOUT_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,

View file

@ -63,7 +63,6 @@ export default mixins(externalHooks).extend({
align-items: center;
padding-left: 14px;
padding-right: 20px;
border-top: 1px solid $node-creator-border-color;
border-bottom: 1px solid $node-creator-border-color;
background-color: $node-creator-search-background-color;
color: $node-creator-search-placeholder-color;

View file

@ -5,7 +5,7 @@
<i :class="$style.icon">
<font-awesome-icon icon="arrow-left" />
</i>
<n8n-heading slot="title" size="large" :bold="true">{{ $locale.baseText('settings') }}</n8n-heading>
<n8n-heading slot="title" size="large" :class="$style.settingsHeading" :bold="true">{{ $locale.baseText('settings') }}</n8n-heading>
</div>
<n8n-menu-item index="/settings/personal" v-if="canAccessPersonalSettings()" :class="$style.tab">
<i :class="$style.icon">
@ -13,13 +13,13 @@
</i>
<span slot="title">{{ $locale.baseText('settings.personal') }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/users" v-if="canAccessUsersSettings()" :class="$style.tab">
<n8n-menu-item index="/settings/users" v-if="canAccessUsersSettings()" :class="[$style.tab, $style.usersMenu]">
<i :class="$style.icon">
<font-awesome-icon icon="user-friends" />
</i>
<span slot="title">{{ $locale.baseText('settings.users') }}</span>
</n8n-menu-item>
<n8n-menu-item index="/settings/api" v-if="canAccessApiSettings()" :class="$style.tab">
<n8n-menu-item index="/settings/api" v-if="canAccessApiSettings()" :class="[$style.tab, $style.apiMenu]">
<i :class="$style.icon">
<font-awesome-icon icon="plug" />
</i>
@ -54,14 +54,18 @@
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { ABOUT_MODAL_KEY, VIEWS } from '@/constants';
import { ABOUT_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS } from '@/constants';
import { userHelpers } from './mixins/userHelpers';
import { IFakeDoor } from '@/Interface';
import GiftNotificationIcon from './GiftNotificationIcon.vue';
export default mixins(
userHelpers,
).extend({
name: 'SettingsSidebar',
components: {
GiftNotificationIcon,
},
computed: {
...mapGetters('settings', ['versionCli']),
settingsFakeDoorFeatures(): IFakeDoor[] {
@ -87,27 +91,51 @@ export default mixins(
onReturn() {
this.$router.push({name: VIEWS.HOMEPAGE});
},
openUpdatesPanel() {
this.$store.dispatch('ui/openModal', VERSIONS_MODAL_KEY);
},
},
});
</script>
<style lang="scss" module>
:global(.el-menu) {
--menu-item-height: 35px;
--submenu-item-height: 27px;
}
.container {
min-width: 200px;
height: 100%;
background-color: var(--color-background-xlight);
border-right: var(--border-base);
position: relative;
padding: var(--spacing-s);
padding: var(--spacing-xs);
overflow: auto;
ul {
height: 100%;
}
:global(.el-menu-item) > span{
position: relative;
left: 8px;
}
}
.settingsHeading {
position: relative;
left: 8px;
}
.tab {
margin-bottom: var(--spacing-2xs);
svg:global(.svg-inline--fa) { position: relative; }
}
.returnButton {
composes: tab;
margin-bottom: var(--spacing-xl);
margin-bottom: var(--spacing-l);
padding: 0 var(--spacing-xs);
height: 38px;
display: flex;
@ -125,6 +153,9 @@ export default mixins(
}
}
.usersMenu svg { left: -2px; }
.apiMenu svg { left: 2px; }
.icon {
width: 16px;
display: inline-flex;
@ -133,7 +164,11 @@ export default mixins(
.versionContainer {
position: absolute;
left: 20px;
left: 23px;
bottom: 20px;
}
@media screen and (max-height: 420px) {
.updatesSubmenu, .versionContainer { display: none; }
}
</style>

View file

@ -180,24 +180,12 @@ export default mixins(showMessage).extend({
else if (tags && tags[0] && tags[0].hoverItem) {
// @ts-ignore
tags[0].hoverItem();
// @ts-ignore
if (tags[0] && tags[0].$el && tags[0].$el.scrollIntoView) {
// @ts-ignore
tags[0].$el.scrollIntoView();
}
}
},
focusOnTag(tagId: string) {
const tagOptions = (this.$refs.tag as Vue[]) || [];
if (tagOptions && tagOptions.length) {
const added = tagOptions.find((ref: any) => ref.value === tagId); // tslint:disable-line:no-any
// @ts-ignore // focus on newly created item
if (added && added.$el && added.$el.scrollIntoView && added.hoverItem) {
// @ts-ignore
added.hoverItem();
added.$el.scrollIntoView();
}
}
},
focusOnInput() {

View file

@ -59,11 +59,17 @@ export default mixins(genericHelpers).extend({
},
mounted() {
if (this.infiniteScrollEnabled) {
window.addEventListener('scroll', this.onScroll);
const content = document.getElementById('content');
if (content) {
content.addEventListener('scroll', this.onScroll);
}
}
},
destroyed() {
window.removeEventListener('scroll', this.onScroll);
const content = document.getElementById('content');
if (content) {
content.removeEventListener('scroll', this.onScroll);
}
},
components: {
TemplateCard,

View file

@ -25,6 +25,16 @@
<font-awesome-icon slot="prefix" icon="search"></font-awesome-icon>
</n8n-input>
</div>
<div class="open-wf-button">
<n8n-button
:label="$locale.baseText('workflowOpen.newWFButton.label')"
:title="$locale.baseText('workflowOpen.newWFButton.title')"
size="large"
icon="plus"
type="primary"
@click="onNewWFClick"
/>
</div>
</div>
</template>
@ -68,12 +78,14 @@ import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { convertToDisplayDate } from './helpers';
import { mapGetters } from 'vuex';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, VIEWS, WORKFLOW_OPEN_MODAL_KEY } from '../constants';
import { titleChange } from './mixins/titleChange';
export default mixins(
genericHelpers,
restApi,
showMessage,
workflowHelpers,
titleChange,
).extend({
name: 'WorkflowOpen',
components: {
@ -166,10 +178,10 @@ export default mixins(
const result = this.$store.getters.getStateIsDirty;
if(result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('workflowOpen.confirmMessage.message'),
this.$locale.baseText('workflowOpen.confirmMessage.headline'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('workflowOpen.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('workflowOpen.confirmMessage.cancelButtonText'),
true,
);
@ -226,6 +238,55 @@ export default mixins(
);
}
},
async onNewWFClick () {
const result = this.$store.getters.getStateIsDirty;
if(result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
if (this.$router.currentRoute.name === VIEWS.NEW_WORKFLOW) {
this.$root.$emit('newWorkflow');
} else {
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
type: 'success',
});
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
if (this.$router.currentRoute.name === VIEWS.NEW_WORKFLOW) {
this.$root.$emit('newWorkflow');
} else {
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
type: 'success',
});
} else if (confirmModal === MODAL_CLOSE) {
return;
}
} else {
if (this.$router.currentRoute.name !== VIEWS.NEW_WORKFLOW) {
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.title'),
type: 'success',
});
}
this.$titleReset();
this.$store.commit('ui/closeModal', WORKFLOW_OPEN_MODAL_KEY);
},
workflowActiveChanged (data: { id: string, active: boolean }) {
for (const workflow of this.workflows) {
if (workflow.id === data.id) {
@ -253,7 +314,8 @@ export default mixins(
}
.search-filter {
margin-left: 10px;
margin-left: 12px;
margin-right: 24px;
min-width: 160px;
}

View file

@ -33,7 +33,6 @@ export default Vue.extend({
.wrapper {
display: flex;
height: 100%;
margin-left: 65px;
justify-content: center;
box-sizing: border-box;
background: var(--color-gray-light);
@ -43,10 +42,6 @@ export default Vue.extend({
}
}
.expandedSidebar {
margin-left: 200px;
}
.container {
max-width: 1280px;
display: flex;
@ -76,4 +71,14 @@ export default Vue.extend({
flex: 1 1 100%;
height: 100%;
}
@media (max-width: 500px) {
.container {
flex-direction: column;
}
.aside {
height: auto;
margin: 0;
}
}
</style>

View file

@ -3,7 +3,8 @@ import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { getMousePosition, getRelativePosition } from '@/views/canvasHelpers';
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
import { VIEWS } from '@/constants';
export const mouseSelect = mixins(
deviceSupportHelpers,
@ -17,6 +18,11 @@ export const mouseSelect = mixins(
mounted () {
this.createSelectBox();
},
computed: {
isDemo (): boolean {
return this.$route.name === VIEWS.DEMO;
},
},
methods: {
createSelectBox () {
this.selectBox.id = 'select-box';
@ -43,8 +49,10 @@ export const mouseSelect = mixins(
},
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
const [x, y] = getMousePosition(event);
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
const headerOffset = this.isDemo ? 0 : HEADER_HEIGHT;
// @ts-ignore
return getRelativePosition(x, y, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
return getRelativePosition(x - sidebarOffset, y - headerOffset, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
},
showSelectBox (event: MouseEvent) {
const [x, y] = this.getMousePositionWithinNodeView(event);

View file

@ -33,7 +33,6 @@ export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKFLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const VERSIONS_MODAL_KEY = 'versions';
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
export const CREDENTIAL_LIST_MODAL_KEY = 'credentialsList';
export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
@ -285,6 +284,7 @@ export enum VIEWS {
NOT_FOUND = "NotFoundView",
FAKE_DOOR = "ComingSoon",
COMMUNITY_NODES = "CommunityNodes",
WORKFLOWS = "WorkflowsView",
}
export enum FAKE_DOOR_FEATURES {
@ -311,10 +311,18 @@ export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$no
export const DEFAULT_STICKY_HEIGHT = 160;
export const DEFAULT_STICKY_WIDTH = 240;
export enum WORKFLOW_MENU_ACTIONS {
DUPLICATE = 'duplicate',
DOWNLOAD = 'download',
IMPORT_FROM_URL = 'import-from-url',
IMPORT_FROM_FILE = 'import-from-file',
SETTINGS = 'settings',
DELETE = 'delete',
}
/**
* Enterprise edition
*/
export enum EnterpriseEditionFeature {
Sharing = 'sharing',
}

View file

@ -7,7 +7,6 @@ import {
CREDENTIAL_SELECT_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
EXECUTIONS_MODAL_KEY,
@ -23,7 +22,6 @@ import {
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
FAKE_DOOR_FEATURES,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
MAIN_NODE_PANEL_WIDTH,
} from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
@ -54,9 +52,6 @@ const module: Module<IUiState, IRootState> = {
mode: '',
activeId: null,
},
[CREDENTIAL_LIST_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_SELECT_MODAL_KEY]: {
open: false,
},
@ -214,6 +209,8 @@ const module: Module<IUiState, IRootState> = {
},
draggableStickyPos: (state: IUiState) => state.draggable.stickyPosition,
mappingTelemetry: (state: IUiState) => state.ndv.mappingTelemetry,
getCurrentView: (state: IUiState) => state.currentView,
isNodeView: (state: IUiState) => [VIEWS.NEW_WORKFLOW.toString(), VIEWS.WORKFLOW.toString(), VIEWS.EXECUTION.toString()].includes(state.currentView),
},
mutations: {
setMainPanelDimensions: (state: IUiState, params: { panelType:string, dimensions: { relativeLeft?: number, relativeRight?: number, relativeWidth?: number }}) => {
@ -255,6 +252,12 @@ const module: Module<IUiState, IRootState> = {
toggleSidebarMenuCollapse: (state: IUiState) => {
state.sidebarMenuCollapsed = !state.sidebarMenuCollapsed;
},
collapseSidebarMenu: (state: IUiState) => {
state.sidebarMenuCollapsed = true;
},
expandSidebarMenu: (state: IUiState) => {
state.sidebarMenuCollapsed = false;
},
setCurrentView: (state: IUiState, currentView: string) => {
state.currentView = currentView;
},

View file

@ -1,5 +1,5 @@
.clickable {
cursor: pointer;
cursor: pointer !important;
}
.primary-color {
color: $color-primary;

View file

@ -20,6 +20,11 @@
"generic.beta": "beta",
"generic.yes": "Yes",
"generic.no": "No",
"generic.settings": "Settings",
"generic.unsavedWork.confirmMessage.headline": "Save changes before leaving?",
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@ -344,22 +349,13 @@
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
"mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?",
"mainSidebar.confirmMessage.workflowDelete.message": "Are you sure that you want to delete '{workflowName}'?",
"mainSidebar.confirmMessage.workflowNew.cancelButtonText": "Leave without saving",
"mainSidebar.confirmMessage.workflowNew.confirmButtonText": "Save",
"mainSidebar.confirmMessage.workflowNew.headline": "Save changes before leaving?",
"mainSidebar.confirmMessage.workflowNew.message": "If you don't save, you will lose your changes.",
"mainSidebar.credentials": "Credentials",
"mainSidebar.delete": "Delete",
"mainSidebar.download": "Download",
"mainSidebar.duplicate": "Duplicate",
"mainSidebar.executions": "Executions",
"mainSidebar.help": "Help",
"mainSidebar.helpMenuItems.course": "Course",
"mainSidebar.helpMenuItems.documentation": "Documentation",
"mainSidebar.helpMenuItems.forum": "Forum",
"mainSidebar.helpMenuItems.quickstart": "Quickstart",
"mainSidebar.importFromFile": "Import from File",
"mainSidebar.importFromUrl": "Import from URL",
"mainSidebar.new": "New",
"mainSidebar.newTemplate": "New from template",
"mainSidebar.open": "Open",
@ -369,7 +365,6 @@
"mainSidebar.prompt.invalidUrl": "Invalid URL",
"mainSidebar.prompt.workflowUrl": "Workflow URL",
"mainSidebar.save": "@:_reusableBaseText.save",
"mainSidebar.settings": "Settings",
"mainSidebar.showError.stopExecution.title": "Problem stopping execution",
"mainSidebar.showMessage.handleFileImport.message": "The file does not contain valid JSON data",
"mainSidebar.showMessage.handleFileImport.title": "Could not import file",
@ -379,6 +374,11 @@
"mainSidebar.showMessage.stopExecution.title": "Execution stopped",
"mainSidebar.templates": "Templates",
"mainSidebar.workflows": "Workflows",
"menuActions.duplicate": "Duplicate",
"menuActions.download": "Download",
"menuActions.importFromUrl": "Import from URL...",
"menuActions.importFromFile": "Import from File...",
"menuActions.delete": "Delete",
"multipleParameter.addItem": "Add item",
"multipleParameter.currentlyNoItemsExist": "Currently no items exist",
"multipleParameter.deleteItem": "Delete item",
@ -560,14 +560,6 @@
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
"nodeView.addNode": "Add node",
"nodeView.addSticky": "Click to add sticky note",
"nodeView.confirmMessage.beforeRouteLeave.cancelButtonText": "Leave without saving",
"nodeView.confirmMessage.beforeRouteLeave.confirmButtonText": "Save",
"nodeView.confirmMessage.beforeRouteLeave.headline": "Save changes before leaving?",
"nodeView.confirmMessage.beforeRouteLeave.message": "If you don't save, you will lose your changes.",
"nodeView.confirmMessage.initView.cancelButtonText": "Leave without saving",
"nodeView.confirmMessage.initView.confirmButtonText": "Save",
"nodeView.confirmMessage.initView.headline": "Save changes before leaving?",
"nodeView.confirmMessage.initView.message": "If you don't save, you will lose your changes.",
"nodeView.confirmMessage.receivedCopyPasteData.cancelButtonText": "",
"nodeView.confirmMessage.receivedCopyPasteData.confirmButtonText": "Yes, import",
"nodeView.confirmMessage.receivedCopyPasteData.headline": "Import Workflow?",
@ -1058,10 +1050,6 @@
"workflowDetails.showMessage.title": "Name missing",
"workflowHelpers.showMessage.title": "Problem saving workflow",
"workflowOpen.active": "Active",
"workflowOpen.confirmMessage.cancelButtonText": "Leave without saving",
"workflowOpen.confirmMessage.confirmButtonText": "Save",
"workflowOpen.confirmMessage.headline": "Save changes before leaving?",
"workflowOpen.confirmMessage.message": "If you don't save, you will lose your changes.",
"workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows",
"workflowOpen.created": "Created",
"workflowOpen.filterWorkflows": "Filter by tags",
@ -1072,6 +1060,8 @@
"workflowOpen.showMessage.message": "This is the current workflow",
"workflowOpen.showMessage.title": "Workflow already open",
"workflowOpen.updated": "Updated",
"workflowOpen.newWFButton.label": "Add workflow",
"workflowOpen.newWFButton.title": "Create a new workflow",
"workflowPreview.showError.arrayEmpty": "Must have an array of nodes",
"workflowPreview.showError.missingWorkflow": "Missing workflow",
"workflowPreview.showError.previewError.message": "Unable to preview workflow",
@ -1113,7 +1103,7 @@
"workflowSettings.saveManualOptions.yes": "Yes",
"workflowSettings.seconds": "seconds",
"workflowSettings.selectOption": "Select Option",
"workflowSettings.settingsFor": "Settings for {workflowName} (#{workflowId})",
"workflowSettings.settingsFor": "Workflow settings for {workflowName} (#{workflowId})",
"workflowSettings.showError.saveSettings1.errorMessage": "Timeout is activated but set to 0",
"workflowSettings.showError.saveSettings1.message": "There was a problem saving the settings",
"workflowSettings.showError.saveSettings1.title": "Problem saving settings",

View file

@ -4,6 +4,7 @@ import { IconDefinition, library } from '@fortawesome/fontawesome-svg-core';
import {
faAngleDoubleLeft,
faAngleDown,
faAngleLeft,
faAngleRight,
faAngleUp,
faArrowLeft,
@ -120,6 +121,7 @@ function addIcon(icon: any) { // tslint:disable-line:no-any
addIcon(faAngleDoubleLeft);
addIcon(faAngleDown);
addIcon(faAngleLeft);
addIcon(faAngleRight);
addIcon(faAngleUp);
addIcon(faArrowLeft);

View file

@ -21,11 +21,12 @@ import TemplatesWorkflowView from '@/views/TemplatesWorkflowView.vue';
import TemplatesSearchView from '@/views/TemplatesSearchView.vue';
import CredentialsView from '@/views/CredentialsView.vue';
import { Store } from 'vuex';
import { IPermissions, IRootState } from './Interface';
import { IPermissions, IRootState, IWorkflowsState } from './Interface';
import { LOGIN_STATUS, ROLE } from './modules/userHelpers';
import { RouteConfigSingleView } from 'vue-router/types/router';
import { VIEWS } from './constants';
import { store } from './store';
import e from 'express';
Vue.use(Router);
@ -39,6 +40,7 @@ interface IRouteConfig extends RouteConfigSingleView {
disabled?: true;
getProperties: (route: Route, store: Store<IRootState>) => object;
};
scrollOffset?: number;
};
}
@ -55,6 +57,13 @@ const router = new Router({
mode: 'history',
// @ts-ignore
base: window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH,
scrollBehavior(to, from, savedPosition) {
// saved position == null means the page is NOT visited from history (back button)
if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta) {
// for templates view, reset scroll position in this case
to.meta.setScrollPosition(0);
}
},
routes: [
{
path: '/',
@ -147,6 +156,8 @@ const router = new Router({
meta: {
templatesEnabled: true,
getRedirect: getTemplatesRedirect,
// Templates view remembers it's scroll position on back
scrollOffset: 0,
telemetry: {
getProperties(route: Route, store: Store<IRootState>) {
return {
@ -154,6 +165,9 @@ const router = new Router({
};
},
},
setScrollPosition(pos: number) {
this.scrollOffset = pos;
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],

View file

@ -307,11 +307,11 @@ export default mixins(
const result = this.$store.getters.getStateIsDirty;
if(result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.message'),
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.headline'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.confirmButtonText'),
this.$locale.baseText('nodeView.confirmMessage.beforeRouteLeave.cancelButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
@ -1470,9 +1470,13 @@ export default mixins(
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
if (nodeTypeName) {
const mousePosition = this.getMousePositionWithinNodeView(event);
const sidebarOffset = this.sidebarMenuCollapsed ? CanvasHelpers.SIDEBAR_WIDTH : CanvasHelpers.SIDEBAR_WIDTH_EXPANDED;
this.addNodeButton(nodeTypeName, {
position: [mousePosition[0] - CanvasHelpers.NODE_SIZE / 2, mousePosition[1] - CanvasHelpers.NODE_SIZE / 2],
position: [
mousePosition[0] - CanvasHelpers.NODE_SIZE / 2,
mousePosition[1] - CanvasHelpers.NODE_SIZE / 2,
],
dragAndDrop: true,
});
this.createNodeActive = false;
@ -3210,7 +3214,7 @@ export default mixins(
<style scoped lang="scss">
.zoom-menu {
$--zoom-menu-margin: 5;
$--zoom-menu-margin: 15;
position: fixed;
left: $sidebar-width + $--zoom-menu-margin;
@ -3288,19 +3292,14 @@ export default mixins(
}
.node-view-root {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
overflow: hidden;
background-color: var(--color-canvas-background);
width: 100%;
height: 100%;
}
.node-view-wrapper {
position: fixed;
width: 100%;
height: 100%;
}
.node-view {
@ -3308,6 +3307,7 @@ export default mixins(
width: 100%;
height: 100%;
transform-origin: 0 0;
z-index: -1;
}
.node-view-background {
@ -3315,6 +3315,7 @@ export default mixins(
position: absolute;
width: 10000px;
height: 10000px;
z-index: -2;
}
.move-active {

View file

@ -96,10 +96,13 @@ export default mixins(workflowHelpers).extend({
methods: {
scrollToTop() {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
const contentArea = document.getElementById('content');
if (contentArea) {
contentArea.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}, 50);
},
onOpenTemplate({event, id}: {event: MouseEvent, id: string}) {

View file

@ -320,23 +320,34 @@ export default mixins(genericHelpers, debounceHelper).extend({
this.updateSearchTracking(search, categories);
}
},
scrollToTop() {
scrollTo(position: number, behavior: ScrollBehavior = 'smooth') {
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}, 100);
const contentArea = document.getElementById('content');
if (contentArea) {
contentArea.scrollTo({
top: position,
behavior,
});
}
}, 0);
},
},
watch: {
workflows(newWorkflows) {
if (newWorkflows.length === 0) {
this.scrollToTop();
this.scrollTo(0);
}
},
},
beforeRouteLeave(to, from, next) {
const contentArea = document.getElementById('content');
if (contentArea) {
// When leaving this page, store current scroll position in route data
if (this.$route.meta && this.$route.meta.setScrollPosition && typeof this.$route.meta.setScrollPosition === 'function') {
this.$route.meta.setScrollPosition(contentArea.scrollTop);
}
}
this.trackSearch();
next();
},
@ -345,6 +356,13 @@ export default mixins(genericHelpers, debounceHelper).extend({
this.loadCategories();
this.loadWorkflowsAndCollections(true);
this.$store.dispatch('users/showPersonalizationSurvey');
setTimeout(() => {
// Check if there is scroll position saved in route and scroll to it
if (this.$route.meta && this.$route.meta.scrollOffset > 0) {
this.scrollTo(this.$route.meta.scrollOffset, 'auto');
}
}, 100);
},
async created() {
if (this.$route.query.search && typeof this.$route.query.search === 'string') {

View file

@ -1,6 +1,5 @@
<template>
<div :class="$style.template">
<div :class="isMenuCollapsed ? $style.menu : $style.expandedMenu"></div>
<div :class="$style.container">
<div :class="$style.header">
<div :class="$style.goBack" v-if="goBackEnabled">
@ -30,38 +29,18 @@ export default Vue.extend({
default: false,
},
},
computed: {
isMenuCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
},
});
</script>
<style lang="scss" module>
.mockMenu {
height: 100%;
min-height: 100vh;
}
.menu {
composes: mockMenu;
min-width: $sidebar-width;
}
.expandedMenu {
composes: mockMenu;
min-width: $sidebar-expanded-width;
}
.template {
display: flex;
padding: var(--spacing-3xl) var(--spacing-xl) var(--spacing-4xl) var(--spacing-xl);
}
.container {
width: 100%;
max-width: 1024px;
padding: var(--spacing-3xl) var(--spacing-3xl) var(--spacing-4xl) var(--spacing-3xl);
margin: 0 auto;
@media (max-width: $breakpoint-md) {

View file

@ -110,9 +110,13 @@ export default mixins(workflowHelpers).extend({
this.showPreview = false;
},
scrollToTop() {
window.scrollTo({
top: 0,
});
const contentArea = document.getElementById('content');
if (contentArea) {
contentArea.scrollTo({
top: 0,
});
}
},
},
watch: {

View file

@ -28,10 +28,11 @@ const MIN_X_TO_SHOW_OUTPUT_LABEL = 90;
const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100;
export const NODE_SIZE = 100;
export const DEFAULT_START_POSITION_X = 240;
export const DEFAULT_START_POSITION_Y = 300;
export const DEFAULT_START_POSITION_X = 175;
export const DEFAULT_START_POSITION_Y = 235;
export const HEADER_HEIGHT = 65;
export const SIDEBAR_WIDTH = 65;
export const SIDEBAR_WIDTH_EXPANDED = 200;
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = 300;
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
const LOOPBACK_MINIMUM = 140;
@ -476,7 +477,8 @@ export const getRelativePosition = (x: number, y: number, scale: number, offset:
};
export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosition => {
return getRelativePosition((window.innerWidth - SIDEBAR_WIDTH) / 2, (window.innerHeight - HEADER_HEIGHT) / 2, scale, offset);
const { editorWidth, editorHeight } = getContentDimensions();
return getRelativePosition((editorWidth - SIDEBAR_WIDTH) / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
};
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) => {
@ -612,24 +614,40 @@ export const addConnectionOutputSuccess = (connection: Connection, output: {tota
};
const getContentDimensions = (): { editorWidth: number, editorHeight: number } => {
let contentWidth = window.innerWidth;
let contentHeight = window.innerHeight;
const contentElement = document.getElementById('content');
if (contentElement) {
const contentBounds = contentElement.getBoundingClientRect();
contentWidth = contentBounds.width;
contentHeight = contentBounds.height;
}
return {
editorWidth: contentWidth,
editorHeight: contentHeight,
};
};
export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {offset: XYPosition, zoomLevel: number} => {
const {minX, minY, maxX, maxY} = getWorkflowCorners(nodes);
const sidebarWidth = addComponentPadding? SIDEBAR_WIDTH: 0;
const headerHeight = addComponentPadding? HEADER_HEIGHT: 0;
const footerHeight = addComponentPadding? 200: 100;
const { editorWidth, editorHeight } = getContentDimensions();
const sidebarWidth = addComponentPadding ? SIDEBAR_WIDTH : 0;
const headerHeight = addComponentPadding ? HEADER_HEIGHT: 0;
const footerHeight = addComponentPadding ? 200 : 100;
const PADDING = NODE_SIZE * 4;
const editorWidth = window.innerWidth;
const diffX = maxX - minX + sidebarWidth + PADDING;
const scaleX = editorWidth / diffX;
const editorHeight = window.innerHeight;
const diffY = maxY - minY + headerHeight + PADDING;
const diffY = maxY - minY + PADDING;
const scaleY = editorHeight / diffY;
const zoomLevel = Math.min(scaleX, scaleY, 1);
let xOffset = (minX * -1) * zoomLevel + sidebarWidth; // find top right corner
let xOffset = (minX * -1) * zoomLevel; // find top right corner
xOffset += (editorWidth - sidebarWidth - (maxX - minX) * zoomLevel) / 2; // add padding to center workflow
let yOffset = (minY * -1) * zoomLevel + headerHeight; // find top right corner
@ -637,7 +655,7 @@ export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {off
return {
zoomLevel,
offset: [xOffset, yOffset],
offset: [xOffset, yOffset - headerHeight],
};
};