mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add 'Whats new' section and modal (#16664)
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
|
import { MainSidebar } from '../pages/sidebar/main-sidebar';
|
||||||
|
|
||||||
|
const mainSidebar = new MainSidebar();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getters
|
* Getters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function getVersionUpdatesPanelOpenButton() {
|
export function getVersionUpdatesPanelOpenButton() {
|
||||||
return cy.getByTestId('version-updates-panel-button');
|
return cy.getByTestId('version-update-next-versions-link');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVersionUpdatesPanel() {
|
export function getVersionUpdatesPanel() {
|
||||||
@@ -22,9 +26,13 @@ export function getVersionCard() {
|
|||||||
* Actions
|
* Actions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export function openWhatsNewMenu() {
|
||||||
|
mainSidebar.getters.whatsNew().should('be.visible');
|
||||||
|
mainSidebar.getters.whatsNew().click();
|
||||||
|
}
|
||||||
|
|
||||||
export function openVersionUpdatesPanel() {
|
export function openVersionUpdatesPanel() {
|
||||||
getVersionUpdatesPanelOpenButton().click();
|
getVersionUpdatesPanelOpenButton().should('be.visible').click();
|
||||||
getVersionUpdatesPanel().should('be.visible');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeVersionUpdatesPanel() {
|
export function closeVersionUpdatesPanel() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
closeVersionUpdatesPanel,
|
closeVersionUpdatesPanel,
|
||||||
getVersionCard,
|
getVersionCard,
|
||||||
getVersionUpdatesPanelOpenButton,
|
getVersionUpdatesPanelOpenButton,
|
||||||
|
openWhatsNewMenu,
|
||||||
openVersionUpdatesPanel,
|
openVersionUpdatesPanel,
|
||||||
} from '../composables/versions';
|
} from '../composables/versions';
|
||||||
import { WorkflowsPage } from '../pages/workflows';
|
import { WorkflowsPage } from '../pages/workflows';
|
||||||
@@ -16,6 +17,8 @@ describe('Versions', () => {
|
|||||||
versionNotifications: {
|
versionNotifications: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
endpoint: 'https://api.n8n.io/api/versions/',
|
endpoint: 'https://api.n8n.io/api/versions/',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
|
||||||
infoUrl: 'https://docs.n8n.io/getting-started/installation/updating.html',
|
infoUrl: 'https://docs.n8n.io/getting-started/installation/updating.html',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -23,7 +26,8 @@ describe('Versions', () => {
|
|||||||
cy.visit(workflowsPage.url);
|
cy.visit(workflowsPage.url);
|
||||||
cy.wait('@loadSettings');
|
cy.wait('@loadSettings');
|
||||||
|
|
||||||
getVersionUpdatesPanelOpenButton().should('contain', '2 updates');
|
openWhatsNewMenu();
|
||||||
|
getVersionUpdatesPanelOpenButton().should('contain', '2 versions behind');
|
||||||
openVersionUpdatesPanel();
|
openVersionUpdatesPanel();
|
||||||
getVersionCard().should('have.length', 2);
|
getVersionCard().should('have.length', 2);
|
||||||
closeVersionUpdatesPanel();
|
closeVersionUpdatesPanel();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class MainSidebar extends BasePage {
|
|||||||
credentials: () => this.getters.menuItem('credentials'),
|
credentials: () => this.getters.menuItem('credentials'),
|
||||||
executions: () => this.getters.menuItem('executions'),
|
executions: () => this.getters.menuItem('executions'),
|
||||||
adminPanel: () => this.getters.menuItem('cloud-admin'),
|
adminPanel: () => this.getters.menuItem('cloud-admin'),
|
||||||
|
whatsNew: () => this.getters.menuItem('whats-new'),
|
||||||
userMenu: () => cy.getByTestId('user-menu'),
|
userMenu: () => cy.getByTestId('user-menu'),
|
||||||
logo: () => cy.getByTestId('n8n-logo'),
|
logo: () => cy.getByTestId('n8n-logo'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { type InsightsDateRange } from './schemas/insights.schema';
|
|||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
whatsNewEnabled: boolean;
|
||||||
|
whatsNewEndpoint: string;
|
||||||
infoUrl: string;
|
infoUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export class VersionNotificationsConfig {
|
|||||||
@Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT')
|
@Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT')
|
||||||
endpoint: string = 'https://api.n8n.io/api/versions/';
|
endpoint: string = 'https://api.n8n.io/api/versions/';
|
||||||
|
|
||||||
|
/** Whether to request What's New articles. Also requires `N8N_VERSION_NOTIFICATIONS_ENABLED` to be enabled */
|
||||||
|
@Env('N8N_VERSION_NOTIFICATIONS_WHATS_NEW_ENABLED')
|
||||||
|
whatsNewEnabled: boolean = true;
|
||||||
|
|
||||||
|
/** Endpoint to retrieve "What's New" articles from */
|
||||||
|
@Env('N8N_VERSION_NOTIFICATIONS_WHATS_NEW_ENDPOINT')
|
||||||
|
whatsNewEndpoint: string = 'https://api.n8n.io/api/whats-new';
|
||||||
|
|
||||||
/** URL for versions panel to page instructing user on how to update n8n instance */
|
/** URL for versions panel to page instructing user on how to update n8n instance */
|
||||||
@Env('N8N_VERSION_NOTIFICATIONS_INFO_URL')
|
@Env('N8N_VERSION_NOTIFICATIONS_INFO_URL')
|
||||||
infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/';
|
infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/';
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ describe('GlobalConfig', () => {
|
|||||||
versionNotifications: {
|
versionNotifications: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
endpoint: 'https://api.n8n.io/api/versions/',
|
endpoint: 'https://api.n8n.io/api/versions/',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
|
||||||
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
|
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
|
||||||
},
|
},
|
||||||
workflows: {
|
workflows: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const INSTANCE_ID_HEADER = 'n8n-instance-id';
|
export const INSTANCE_ID_HEADER = 'n8n-instance-id';
|
||||||
|
export const INSTANCE_VERSION_HEADER = 'n8n-version';
|
||||||
|
|
||||||
export const INSTANCE_TYPES = ['main', 'webhook', 'worker'] as const;
|
export const INSTANCE_TYPES = ['main', 'webhook', 'worker'] as const;
|
||||||
export type InstanceType = (typeof INSTANCE_TYPES)[number];
|
export type InstanceType = (typeof INSTANCE_TYPES)[number];
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export class FrontendService {
|
|||||||
versionNotifications: {
|
versionNotifications: {
|
||||||
enabled: this.globalConfig.versionNotifications.enabled,
|
enabled: this.globalConfig.versionNotifications.enabled,
|
||||||
endpoint: this.globalConfig.versionNotifications.endpoint,
|
endpoint: this.globalConfig.versionNotifications.endpoint,
|
||||||
|
whatsNewEnabled: this.globalConfig.versionNotifications.whatsNewEnabled,
|
||||||
|
whatsNewEndpoint: this.globalConfig.versionNotifications.whatsNewEndpoint,
|
||||||
infoUrl: this.globalConfig.versionNotifications.infoUrl,
|
infoUrl: this.globalConfig.versionNotifications.infoUrl,
|
||||||
},
|
},
|
||||||
instanceId: this.instanceSettings.instanceId,
|
instanceId: this.instanceSettings.instanceId,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { computed, ref } from 'vue';
|
|||||||
import xss, { friendlyAttrValue, whiteList } from 'xss';
|
import xss, { friendlyAttrValue, whiteList } from 'xss';
|
||||||
|
|
||||||
import { markdownYoutubeEmbed, YOUTUBE_EMBED_SRC_REGEX, type YoutubeEmbedConfig } from './youtube';
|
import { markdownYoutubeEmbed, YOUTUBE_EMBED_SRC_REGEX, type YoutubeEmbedConfig } from './youtube';
|
||||||
import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown';
|
import { toggleCheckbox } from '../../utils/markdown';
|
||||||
import N8nLoading from '../N8nLoading';
|
import N8nLoading from '../N8nLoading';
|
||||||
|
|
||||||
interface IImage {
|
interface IImage {
|
||||||
@@ -44,7 +44,7 @@ const props = withDefaults(defineProps<MarkdownProps>(), {
|
|||||||
theme: 'markdown',
|
theme: 'markdown',
|
||||||
options: () => ({
|
options: () => ({
|
||||||
markdown: {
|
markdown: {
|
||||||
html: true,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true,
|
typographer: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
@@ -110,7 +110,8 @@ const htmlContent = computed(() => {
|
|||||||
if (props.withMultiBreaks) {
|
if (props.withMultiBreaks) {
|
||||||
contentToRender = contentToRender.replaceAll('\n\n', '\n \n');
|
contentToRender = contentToRender.replaceAll('\n\n', '\n \n');
|
||||||
}
|
}
|
||||||
const html = md.render(escapeMarkdown(contentToRender));
|
const html = md.render(contentToRender);
|
||||||
|
|
||||||
const safeHtml = xss(html, {
|
const safeHtml = xss(html, {
|
||||||
onTagAttr(tag, name, value) {
|
onTagAttr(tag, name, value) {
|
||||||
if (tag === 'img' && name === 'src') {
|
if (tag === 'img' && name === 'src') {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { action } from '@storybook/addon-actions';
|
|||||||
import type { StoryFn } from '@storybook/vue3';
|
import type { StoryFn } from '@storybook/vue3';
|
||||||
|
|
||||||
import N8nMenu from './Menu.vue';
|
import N8nMenu from './Menu.vue';
|
||||||
|
import N8nCallout from '../N8nCallout';
|
||||||
import N8nIcon from '../N8nIcon';
|
import N8nIcon from '../N8nIcon';
|
||||||
import N8nText from '../N8nText';
|
import N8nText from '../N8nText';
|
||||||
|
|
||||||
@@ -159,3 +160,69 @@ withHeaderAndFooter.args = { items: menuItems };
|
|||||||
|
|
||||||
export const withAllSlots = templateWithAllSlots.bind({});
|
export const withAllSlots = templateWithAllSlots.bind({});
|
||||||
withAllSlots.args = { items: menuItems };
|
withAllSlots.args = { items: menuItems };
|
||||||
|
|
||||||
|
export const withCustomComponent = templateWithHeaderAndFooter.bind({});
|
||||||
|
withCustomComponent.args = {
|
||||||
|
items: [
|
||||||
|
...menuItems,
|
||||||
|
{
|
||||||
|
id: 'custom',
|
||||||
|
icon: 'bell',
|
||||||
|
label: "What's New",
|
||||||
|
position: 'bottom',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'custom-callout',
|
||||||
|
component: N8nCallout,
|
||||||
|
available: true,
|
||||||
|
props: {
|
||||||
|
theme: 'warning',
|
||||||
|
icon: 'bell',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withNotification = templateWithHeaderAndFooter.bind({});
|
||||||
|
withNotification.args = {
|
||||||
|
items: [
|
||||||
|
...menuItems,
|
||||||
|
{
|
||||||
|
id: 'notification',
|
||||||
|
icon: 'bell',
|
||||||
|
label: 'Notification',
|
||||||
|
position: 'top',
|
||||||
|
notification: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withSmallMenu = templateWithHeaderAndFooter.bind({});
|
||||||
|
withSmallMenu.args = {
|
||||||
|
items: [
|
||||||
|
...menuItems,
|
||||||
|
{
|
||||||
|
id: 'notification',
|
||||||
|
icon: {
|
||||||
|
type: 'icon',
|
||||||
|
value: 'bell',
|
||||||
|
color: 'primary',
|
||||||
|
},
|
||||||
|
label: 'Small items',
|
||||||
|
position: 'top',
|
||||||
|
children: [
|
||||||
|
{ icon: 'info', label: 'About n8n', id: 'about', size: 'small' },
|
||||||
|
{ icon: 'book', label: 'Documentation', id: 'docs', size: 'small' },
|
||||||
|
{
|
||||||
|
icon: 'bell',
|
||||||
|
label: 'Notification',
|
||||||
|
id: 'notification',
|
||||||
|
notification: true,
|
||||||
|
size: 'small',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ElMenu } from 'element-plus';
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import type { IMenuItem } from '../../types';
|
import type { IMenuItem, IMenuElement } from '../../types';
|
||||||
|
import { isCustomMenuItem } from '../../types';
|
||||||
import N8nMenuItem from '../N8nMenuItem';
|
import N8nMenuItem from '../N8nMenuItem';
|
||||||
import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil';
|
import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil';
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ interface MenuProps {
|
|||||||
transparentBackground?: boolean;
|
transparentBackground?: boolean;
|
||||||
mode?: 'router' | 'tabs';
|
mode?: 'router' | 'tabs';
|
||||||
tooltipDelay?: number;
|
tooltipDelay?: number;
|
||||||
items?: IMenuItem[];
|
items?: IMenuElement[];
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +37,13 @@ const emit = defineEmits<{
|
|||||||
const activeTab = ref(props.modelValue);
|
const activeTab = ref(props.modelValue);
|
||||||
|
|
||||||
const upperMenuItems = computed(() =>
|
const upperMenuItems = computed(() =>
|
||||||
props.items.filter((item: IMenuItem) => item.position === 'top' && item.available !== false),
|
props.items.filter((item: IMenuElement) => item.position === 'top' && item.available !== false),
|
||||||
);
|
);
|
||||||
|
|
||||||
const lowerMenuItems = computed(() =>
|
const lowerMenuItems = computed(() =>
|
||||||
props.items.filter((item: IMenuItem) => item.position === 'bottom' && item.available !== false),
|
props.items.filter(
|
||||||
|
(item: IMenuElement) => item.position === 'bottom' && item.available !== false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentRoute = computed(() => {
|
const currentRoute = computed(() => {
|
||||||
@@ -89,31 +92,35 @@ const onSelect = (item: IMenuItem): void => {
|
|||||||
<slot name="menuPrefix"></slot>
|
<slot name="menuPrefix"></slot>
|
||||||
</div>
|
</div>
|
||||||
<ElMenu :default-active="defaultActive" :collapse="collapsed">
|
<ElMenu :default-active="defaultActive" :collapse="collapsed">
|
||||||
<N8nMenuItem
|
<template v-for="item in upperMenuItems" :key="item.id">
|
||||||
v-for="item in upperMenuItems"
|
<component :is="item.component" v-if="isCustomMenuItem(item)" v-bind="item.props" />
|
||||||
:key="item.id"
|
<N8nMenuItem
|
||||||
:item="item"
|
v-else
|
||||||
:compact="collapsed"
|
:item="item"
|
||||||
:tooltip-delay="tooltipDelay"
|
:compact="collapsed"
|
||||||
:mode="mode"
|
:tooltip-delay="tooltipDelay"
|
||||||
:active-tab="activeTab"
|
:mode="mode"
|
||||||
:handle-select="onSelect"
|
:active-tab="activeTab"
|
||||||
/>
|
:handle-select="onSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
</div>
|
</div>
|
||||||
<div :class="[$style.lowerContent, 'pb-2xs']">
|
<div :class="[$style.lowerContent, 'pb-2xs']">
|
||||||
<slot name="beforeLowerMenu"></slot>
|
<slot name="beforeLowerMenu"></slot>
|
||||||
<ElMenu :default-active="defaultActive" :collapse="collapsed">
|
<ElMenu :default-active="defaultActive" :collapse="collapsed">
|
||||||
<N8nMenuItem
|
<template v-for="item in lowerMenuItems" :key="item.id">
|
||||||
v-for="item in lowerMenuItems"
|
<component :is="item.component" v-if="isCustomMenuItem(item)" v-bind="item.props" />
|
||||||
:key="item.id"
|
<N8nMenuItem
|
||||||
:item="item"
|
v-else
|
||||||
:compact="collapsed"
|
:item="item"
|
||||||
:tooltip-delay="tooltipDelay"
|
:compact="collapsed"
|
||||||
:mode="mode"
|
:tooltip-delay="tooltipDelay"
|
||||||
:active-tab="activeTab"
|
:mode="mode"
|
||||||
:handle-select="onSelect"
|
:active-tab="activeTab"
|
||||||
/>
|
:handle-select="onSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
<div v-if="$slots.menuSuffix" :class="$style.menuSuffix">
|
<div v-if="$slots.menuSuffix" :class="$style.menuSuffix">
|
||||||
<slot name="menuSuffix"></slot>
|
<slot name="menuSuffix"></slot>
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { computed, useCssModule, getCurrentInstance } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
|
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
|
||||||
import type { IMenuItem } from '../../types';
|
import type { IMenuItem, IMenuElement } from '../../types';
|
||||||
|
import { isCustomMenuItem } from '../../types';
|
||||||
|
import type { IconColor } from '../../types/icon';
|
||||||
import { getInitials } from '../../utils/labelUtil';
|
import { getInitials } from '../../utils/labelUtil';
|
||||||
import ConditionalRouterLink from '../ConditionalRouterLink';
|
import ConditionalRouterLink from '../ConditionalRouterLink';
|
||||||
import N8nIcon from '../N8nIcon';
|
import N8nIcon from '../N8nIcon';
|
||||||
import N8nSpinner from '../N8nSpinner';
|
import N8nSpinner from '../N8nSpinner';
|
||||||
|
import N8nText from '../N8nText';
|
||||||
import N8nTooltip from '../N8nTooltip';
|
import N8nTooltip from '../N8nTooltip';
|
||||||
|
|
||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
@@ -26,12 +29,14 @@ const props = withDefaults(defineProps<MenuItemProps>(), {
|
|||||||
tooltipDelay: 300,
|
tooltipDelay: 300,
|
||||||
popperClass: '',
|
popperClass: '',
|
||||||
mode: 'router',
|
mode: 'router',
|
||||||
|
activeTab: undefined,
|
||||||
|
handleSelect: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const $route = useRoute();
|
const $route = useRoute();
|
||||||
|
|
||||||
const availableChildren = computed((): IMenuItem[] =>
|
const availableChildren = computed((): IMenuElement[] =>
|
||||||
Array.isArray(props.item.children)
|
Array.isArray(props.item.children)
|
||||||
? props.item.children.filter((child) => child.available !== false)
|
? props.item.children.filter((child) => child.available !== false)
|
||||||
: [],
|
: [],
|
||||||
@@ -49,7 +54,7 @@ const submenuPopperClass = computed((): string => {
|
|||||||
return popperClass.join(' ');
|
return popperClass.join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
const isActive = (item: IMenuItem): boolean => {
|
const isActive = (item: IMenuElement): boolean => {
|
||||||
if (props.mode === 'router') {
|
if (props.mode === 'router') {
|
||||||
return doesMenuItemMatchCurrentRoute(item, currentRoute.value);
|
return doesMenuItemMatchCurrentRoute(item, currentRoute.value);
|
||||||
} else {
|
} else {
|
||||||
@@ -65,6 +70,14 @@ const isItemActive = (item: IMenuItem): boolean => {
|
|||||||
|
|
||||||
// Get self component to avoid dependency cycle
|
// Get self component to avoid dependency cycle
|
||||||
const N8nMenuItem = getCurrentInstance()?.type;
|
const N8nMenuItem = getCurrentInstance()?.type;
|
||||||
|
|
||||||
|
const getIconColor = (item: IMenuItem): IconColor | undefined => {
|
||||||
|
if (typeof item.icon === 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.icon?.color;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -82,28 +95,51 @@ const N8nMenuItem = getCurrentInstance()?.type;
|
|||||||
:popper-class="submenuPopperClass"
|
:popper-class="submenuPopperClass"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<N8nIcon
|
<template v-if="item.icon">
|
||||||
v-if="item.icon"
|
<div :class="$style.icon">
|
||||||
:class="$style.icon"
|
<div :class="$style.notificationContainer">
|
||||||
:icon="item.icon"
|
<N8nIcon
|
||||||
:size="item.customIconSize || 'large'"
|
v-if="typeof item.icon === 'string' || item.icon.type === 'icon'"
|
||||||
/>
|
:icon="typeof item.icon === 'object' ? item.icon.value : item.icon"
|
||||||
|
:size="item.customIconSize || 'large'"
|
||||||
|
:color="getIconColor(item)"
|
||||||
|
/>
|
||||||
|
<N8nText
|
||||||
|
v-else-if="item.icon.type === 'emoji'"
|
||||||
|
:size="item.customIconSize || 'large'"
|
||||||
|
:color="getIconColor(item)"
|
||||||
|
>
|
||||||
|
{{ item.icon.value }}
|
||||||
|
</N8nText>
|
||||||
|
<div v-if="item.notification" :class="$style.notification">
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<span v-if="!compact" :class="$style.label">{{ item.label }}</span>
|
<span v-if="!compact" :class="$style.label">{{ item.label }}</span>
|
||||||
<span v-if="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
<span v-if="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
||||||
getInitials(item.label)
|
getInitials(item.label)
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
<N8nMenuItem
|
<template v-for="child in availableChildren" :key="child.id">
|
||||||
v-for="child in availableChildren"
|
<component
|
||||||
:key="child.id"
|
:is="child.component"
|
||||||
:item="child"
|
v-if="isCustomMenuItem(child)"
|
||||||
:compact="false"
|
v-bind="child.props"
|
||||||
:tooltip-delay="tooltipDelay"
|
:class="$style.custom"
|
||||||
:popper-class="popperClass"
|
/>
|
||||||
:mode="mode"
|
<N8nMenuItem
|
||||||
:active-tab="activeTab"
|
v-else
|
||||||
:handle-select="handleSelect"
|
:item="child"
|
||||||
/>
|
:compact="false"
|
||||||
|
:tooltip-delay="tooltipDelay"
|
||||||
|
:popper-class="popperClass"
|
||||||
|
:mode="mode"
|
||||||
|
:active-tab="activeTab"
|
||||||
|
:handle-select="handleSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
v-else
|
v-else
|
||||||
@@ -121,23 +157,36 @@ const N8nMenuItem = getCurrentInstance()?.type;
|
|||||||
[$style.disableActiveStyle]: !isItemActive(item),
|
[$style.disableActiveStyle]: !isItemActive(item),
|
||||||
[$style.active]: isItemActive(item),
|
[$style.active]: isItemActive(item),
|
||||||
[$style.compact]: compact,
|
[$style.compact]: compact,
|
||||||
|
[$style.small]: item.size === 'small',
|
||||||
}"
|
}"
|
||||||
data-test-id="menu-item"
|
data-test-id="menu-item"
|
||||||
:index="item.id"
|
:index="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
@click="handleSelect?.(item)"
|
@click="handleSelect?.(item)"
|
||||||
>
|
>
|
||||||
<div v-if="item.icon">
|
<template v-if="item.icon">
|
||||||
<N8nIcon
|
<div :class="$style.icon">
|
||||||
v-if="typeof item.icon === 'string' || item.icon.type === 'icon'"
|
<div :class="$style.notificationContainer">
|
||||||
:class="$style.icon"
|
<N8nIcon
|
||||||
:icon="typeof item.icon === 'object' ? item.icon.value : item.icon"
|
v-if="typeof item.icon === 'string' || item.icon.type === 'icon'"
|
||||||
:size="item.customIconSize || 'large'"
|
:icon="typeof item.icon === 'object' ? item.icon.value : item.icon"
|
||||||
/>
|
:size="item.customIconSize || 'large'"
|
||||||
<span v-else-if="item.icon.type === 'emoji'" :class="$style.icon">{{
|
:color="getIconColor(item)"
|
||||||
item.icon.value
|
/>
|
||||||
}}</span>
|
<N8nText
|
||||||
</div>
|
v-else-if="item.icon.type === 'emoji'"
|
||||||
|
:size="item.customIconSize || 'large'"
|
||||||
|
:color="getIconColor(item)"
|
||||||
|
>
|
||||||
|
{{ item.icon.value }}
|
||||||
|
</N8nText>
|
||||||
|
<div v-if="item.notification" :class="$style.notification">
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<span v-if="!compact" :class="$style.label">{{ item.label }}</span>
|
<span v-if="!compact" :class="$style.label">{{ item.label }}</span>
|
||||||
<span v-if="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
<span v-if="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
||||||
getInitials(item.label)
|
getInitials(item.label)
|
||||||
@@ -261,12 +310,53 @@ const N8nMenuItem = getCurrentInstance()?.type;
|
|||||||
padding: var(--spacing-2xs) 0 !important;
|
padding: var(--spacing-2xs) 0 !important;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
font-size: var(--font-size-2xs) !important;
|
||||||
|
padding-top: var(--spacing-3xs) !important;
|
||||||
|
padding-bottom: var(--spacing-3xs) !important;
|
||||||
|
padding-left: var(--spacing-s) !important;
|
||||||
|
padding-right: var(--spacing-xs) !important;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: var(--spacing-3xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
min-width: var(--spacing-s);
|
min-width: var(--spacing-s);
|
||||||
margin-right: var(--spacing-xs);
|
margin-right: var(--spacing-xs);
|
||||||
text-align: center;
|
|
||||||
|
svg {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContainer {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: -0.15em;
|
||||||
|
right: -0.3em;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
div {
|
||||||
|
height: 0.36em;
|
||||||
|
width: 0.36em;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
@@ -312,11 +402,12 @@ const N8nMenuItem = getCurrentInstance()?.type;
|
|||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
padding: 0 var(--spacing-xs) !important;
|
padding: var(--spacing-3xs) var(--spacing-2xs) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem {
|
.menuItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: var(--spacing-2xs) var(--spacing-xs) !important;
|
padding: var(--spacing-2xs) !important;
|
||||||
margin: var(--spacing-2xs) 0 !important;
|
margin: var(--spacing-2xs) 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,5 +420,9 @@ const N8nMenuItem = getCurrentInstance()?.type;
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
|
import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
|
||||||
|
|
||||||
import type { IMenuItem } from '@n8n/design-system/types';
|
import { isCustomMenuItem, type IMenuElement } from '@n8n/design-system/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the given menu item matches the current route.
|
* Checks if the given menu item matches the current route.
|
||||||
*/
|
*/
|
||||||
export function doesMenuItemMatchCurrentRoute(
|
export function doesMenuItemMatchCurrentRoute(
|
||||||
item: IMenuItem,
|
item: IMenuElement,
|
||||||
currentRoute: RouteLocationNormalizedLoaded,
|
currentRoute: RouteLocationNormalizedLoaded,
|
||||||
) {
|
) {
|
||||||
|
if (isCustomMenuItem(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let activateOnRouteNames: string[] = [];
|
let activateOnRouteNames: string[] = [];
|
||||||
|
|
||||||
if (Array.isArray(item.activateOnRouteNames)) {
|
if (Array.isArray(item.activateOnRouteNames)) {
|
||||||
activateOnRouteNames = item.activateOnRouteNames;
|
activateOnRouteNames = item.activateOnRouteNames;
|
||||||
} else if (item.route && isNamedRouteLocation(item.route.to)) {
|
} else if (item.route && isNamedRouteLocation(item.route.to)) {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { ElTooltipProps } from 'element-plus';
|
import type { ElTooltipProps } from 'element-plus';
|
||||||
import type { AnchorHTMLAttributes } from 'vue';
|
import type { AnchorHTMLAttributes, Component } from 'vue';
|
||||||
import type { RouteLocationRaw, RouterLinkProps } from 'vue-router';
|
import type { RouteLocationRaw, RouterLinkProps } from 'vue-router';
|
||||||
|
|
||||||
|
import type { IconColor } from './icon';
|
||||||
|
|
||||||
export type IMenuItem = {
|
export type IMenuItem = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: string | { type: 'icon' | 'emoji'; value: string };
|
icon?: string | { type: 'icon' | 'emoji'; value: string; color?: IconColor };
|
||||||
secondaryIcon?: {
|
secondaryIcon?: {
|
||||||
name: string;
|
name: string;
|
||||||
size?: 'xsmall' | 'small' | 'medium' | 'large';
|
size?: 'xsmall' | 'small' | 'medium' | 'large';
|
||||||
@@ -26,11 +28,25 @@ export type IMenuItem = {
|
|||||||
activateOnRouteNames?: string[];
|
activateOnRouteNames?: string[];
|
||||||
activateOnRoutePaths?: string[];
|
activateOnRoutePaths?: string[];
|
||||||
|
|
||||||
children?: IMenuItem[];
|
children?: IMenuElement[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
notification?: boolean;
|
||||||
|
size?: 'medium' | 'small';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ICustomMenuItem {
|
||||||
|
id: string;
|
||||||
|
component: Component;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
available?: boolean;
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IMenuElement = IMenuItem | ICustomMenuItem;
|
||||||
|
|
||||||
|
export const isCustomMenuItem = (e: IMenuElement): e is ICustomMenuItem => 'component' in e;
|
||||||
|
|
||||||
export type IRouteMenuItemProperties = {
|
export type IRouteMenuItemProperties = {
|
||||||
route: RouteLocationRaw;
|
route: RouteLocationRaw;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,3 @@
|
|||||||
export const escapeMarkdown = (html: string | undefined): string => {
|
|
||||||
if (!html) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const escaped = html.replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
// unescape greater than quotes at start of line
|
|
||||||
const withQuotes = escaped.replace(/^((\s)*(>)+)+\s*/gm, (matches) => {
|
|
||||||
return matches.replace(/>/g, '>');
|
|
||||||
});
|
|
||||||
|
|
||||||
return withQuotes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkedRegEx = /(\*|-) \[x\]/;
|
const checkedRegEx = /(\*|-) \[x\]/;
|
||||||
const uncheckedRegEx = /(\*|-) \[\s\]/;
|
const uncheckedRegEx = /(\*|-) \[\s\]/;
|
||||||
|
|
||||||
|
|||||||
@@ -1081,6 +1081,8 @@
|
|||||||
"mainSidebar.workflows.readOnlyEnv.tooltip.link": "More info",
|
"mainSidebar.workflows.readOnlyEnv.tooltip.link": "More info",
|
||||||
"mainSidebar.executions": "Executions",
|
"mainSidebar.executions": "Executions",
|
||||||
"mainSidebar.workersView": "Workers",
|
"mainSidebar.workersView": "Workers",
|
||||||
|
"mainSidebar.whatsNew": "What’s New",
|
||||||
|
"mainSidebar.whatsNew.fullChangelog": "Full changelog",
|
||||||
"menuActions.duplicate": "Duplicate",
|
"menuActions.duplicate": "Duplicate",
|
||||||
"menuActions.download": "Download",
|
"menuActions.download": "Download",
|
||||||
"menuActions.push": "Push to Git",
|
"menuActions.push": "Push to Git",
|
||||||
@@ -3228,5 +3230,10 @@
|
|||||||
"insights.upgradeModal.perks.0": "View up to one year of insights history",
|
"insights.upgradeModal.perks.0": "View up to one year of insights history",
|
||||||
"insights.upgradeModal.perks.1": "Zoom into last 24 hours with hourly granularity",
|
"insights.upgradeModal.perks.1": "Zoom into last 24 hours with hourly granularity",
|
||||||
"insights.upgradeModal.perks.2": "Gain deeper visibility into workflow trends over time",
|
"insights.upgradeModal.perks.2": "Gain deeper visibility into workflow trends over time",
|
||||||
"insights.upgradeModal.title": "Upgrade to Enterprise"
|
"insights.upgradeModal.title": "Upgrade to Enterprise",
|
||||||
|
"whatsNew.modal.title": "What's New in n8n {version}",
|
||||||
|
"whatsNew.versionsBehind": "{count} version behind | {count} versions behind",
|
||||||
|
"whatsNew.update": "Update",
|
||||||
|
"whatsNew.updateAvailable": "You're currently on version {currentVersion}. Update to {latestVersion} to get {count} versions worth of new features, improvements, and fixes. See what changed",
|
||||||
|
"whatsNew.updateAvailable.changelogLink": "in the full changelog"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { INSTANCE_ID_HEADER } from '@n8n/constants';
|
import { INSTANCE_ID_HEADER, INSTANCE_VERSION_HEADER } from '@n8n/constants';
|
||||||
import type { INodeParameters } from 'n8n-workflow';
|
import type { INodeParameters } from 'n8n-workflow';
|
||||||
|
|
||||||
import { get } from '../utils';
|
import { get } from '../utils';
|
||||||
@@ -29,11 +29,34 @@ export interface Version {
|
|||||||
securityIssueFixVersion: string;
|
securityIssueFixVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WhatsNewArticle {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
publishedAt: string;
|
||||||
|
content: string;
|
||||||
|
calloutTitle: string;
|
||||||
|
calloutText: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNextVersions(
|
export async function getNextVersions(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
version: string,
|
currentVersion: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
): Promise<Version[]> {
|
): Promise<Version[]> {
|
||||||
const headers = { [INSTANCE_ID_HEADER as string]: instanceId };
|
const headers = { [INSTANCE_ID_HEADER as string]: instanceId };
|
||||||
return await get(endpoint, version, {}, headers);
|
return await get(endpoint, currentVersion, {}, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWhatsNewArticles(
|
||||||
|
endpoint: string,
|
||||||
|
currentVersion: string,
|
||||||
|
instanceId: string,
|
||||||
|
): Promise<WhatsNewArticle[]> {
|
||||||
|
const headers = {
|
||||||
|
[INSTANCE_ID_HEADER as string]: instanceId,
|
||||||
|
[INSTANCE_VERSION_HEADER as string]: currentVersion,
|
||||||
|
};
|
||||||
|
return await get(endpoint, '', {}, headers);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export const defaultSettings: FrontendSettings = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
infoUrl: '',
|
infoUrl: '',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: '',
|
||||||
},
|
},
|
||||||
workflowCallerPolicyDefaultOption: 'any',
|
workflowCallerPolicyDefaultOption: 'any',
|
||||||
workflowTagsDisabled: false,
|
workflowTagsDisabled: false,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||||
|
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system';
|
||||||
|
import type { IMenuItem } from '@n8n/design-system';
|
||||||
|
import { ABOUT_MODAL_KEY, VIEWS, WHATS_NEW_MODAL_KEY } from '@/constants';
|
||||||
|
import { hasPermission } from '@/utils/rbac/permissions';
|
||||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@@ -11,23 +17,16 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import { useVersionsStore } from '@/stores/versions.store';
|
import { useVersionsStore } from '@/stores/versions.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
|
||||||
import { hasPermission } from '@/utils/rbac/permissions';
|
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useI18n } from '@n8n/i18n';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useUserHelpers } from '@/composables/useUserHelpers';
|
import { useUserHelpers } from '@/composables/useUserHelpers';
|
||||||
|
|
||||||
import { ABOUT_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS } from '@/constants';
|
|
||||||
import { useBugReporting } from '@/composables/useBugReporting';
|
import { useBugReporting } from '@/composables/useBugReporting';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||||
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system';
|
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
||||||
import type { IMenuItem } from '@n8n/design-system';
|
import Logo from '@/components/Logo/Logo.vue';
|
||||||
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
import VersionUpdateCTA from '@/components/VersionUpdateCTA.vue';
|
||||||
import Logo from './Logo/Logo.vue';
|
|
||||||
|
|
||||||
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
||||||
const cloudPlanStore = useCloudPlanStore();
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
@@ -68,6 +67,14 @@ const userMenuItems = ref([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const showWhatsNewNotification = computed(
|
||||||
|
() =>
|
||||||
|
versionsStore.hasVersionUpdates ||
|
||||||
|
versionsStore.whatsNewArticles.some(
|
||||||
|
(article) => !versionsStore.isWhatsNewArticleRead(article.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const mainMenuItems = computed<IMenuItem[]>(() => [
|
const mainMenuItems = computed<IMenuItem[]>(() => [
|
||||||
{
|
{
|
||||||
id: 'cloud-admin',
|
id: 'cloud-admin',
|
||||||
@@ -175,16 +182,52 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'whats-new',
|
||||||
|
icon: 'bell',
|
||||||
|
notification: showWhatsNewNotification.value,
|
||||||
|
label: i18n.baseText('mainSidebar.whatsNew'),
|
||||||
|
position: 'bottom',
|
||||||
|
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
|
||||||
|
children: [
|
||||||
|
...versionsStore.whatsNewArticles.map(
|
||||||
|
(article) =>
|
||||||
|
({
|
||||||
|
id: `whats-new-article-${article.id}`,
|
||||||
|
label: article.title,
|
||||||
|
size: 'small',
|
||||||
|
customIconSize: 'small',
|
||||||
|
icon: {
|
||||||
|
type: 'emoji',
|
||||||
|
value: '•',
|
||||||
|
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
|
||||||
|
},
|
||||||
|
}) satisfies IMenuItem,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
id: 'full-changelog',
|
||||||
|
icon: 'external-link-alt',
|
||||||
|
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
|
||||||
|
link: {
|
||||||
|
href: 'https://docs.n8n.io/release-notes/',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
size: 'small',
|
||||||
|
customIconSize: 'small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version-upgrade-cta',
|
||||||
|
component: VersionUpdateCTA,
|
||||||
|
available: versionsStore.hasVersionUpdates,
|
||||||
|
props: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
|
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
|
||||||
|
|
||||||
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
|
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
|
||||||
|
|
||||||
const hasVersionUpdates = computed(
|
|
||||||
() => settingsStore.settings.releaseChannel === 'stable' && versionsStore.hasVersionUpdates,
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextVersions = computed(() => versionsStore.nextVersions);
|
|
||||||
const showUserArea = computed(() => hasPermission(['authenticated']));
|
const showUserArea = computed(() => hasPermission(['authenticated']));
|
||||||
const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
|
const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
|
||||||
|
|
||||||
@@ -250,10 +293,6 @@ const toggleCollapse = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUpdatesPanel = () => {
|
|
||||||
uiStore.openModal(VERSIONS_MODAL_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (key: string) => {
|
const handleSelect = (key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'templates':
|
case 'templates':
|
||||||
@@ -280,6 +319,20 @@ const handleSelect = (key: string) => {
|
|||||||
case 'insights':
|
case 'insights':
|
||||||
telemetry.track('User clicked insights link from side menu');
|
telemetry.track('User clicked insights link from side menu');
|
||||||
default:
|
default:
|
||||||
|
if (key.startsWith('whats-new-article-')) {
|
||||||
|
const articleId = Number(key.replace('whats-new-article-', ''));
|
||||||
|
|
||||||
|
telemetry.track("User clicked on what's new section", {
|
||||||
|
article_id: articleId,
|
||||||
|
});
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: WHATS_NEW_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
articleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -432,24 +485,6 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||||||
</template>
|
</template>
|
||||||
<template #menuSuffix>
|
<template #menuSuffix>
|
||||||
<div>
|
<div>
|
||||||
<div
|
|
||||||
v-if="hasVersionUpdates"
|
|
||||||
data-test-id="version-updates-panel-button"
|
|
||||||
:class="$style.updates"
|
|
||||||
@click="openUpdatesPanel"
|
|
||||||
>
|
|
||||||
<div :class="$style.giftContainer">
|
|
||||||
<GiftNotificationIcon />
|
|
||||||
</div>
|
|
||||||
<N8nText
|
|
||||||
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
|
||||||
color="text-base"
|
|
||||||
>
|
|
||||||
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
|
||||||
nextVersions.length > 1 ? 's' : ''
|
|
||||||
}}
|
|
||||||
</N8nText>
|
|
||||||
</div>
|
|
||||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
FROM_AI_PARAMETERS_MODAL_KEY,
|
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
||||||
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||||
|
WHATS_NEW_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
@@ -323,5 +324,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
|||||||
<WorkflowExtractionNameModal :modal-name="modalName" :data="data" />
|
<WorkflowExtractionNameModal :modal-name="modalName" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="WHATS_NEW_MODAL_KEY">
|
||||||
|
<template #default="{ modalName, data }">
|
||||||
|
<WhatsNewModal :modal-name="modalName" :data="data" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { WHATS_NEW_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
|
||||||
|
import { useVersionsStore } from '@/stores/versions.store';
|
||||||
|
import type { Version } from '@n8n/rest-api-client/api/versions';
|
||||||
|
|
||||||
|
import VersionUpdateCTA from './VersionUpdateCTA.vue';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||||
|
const goToVersions = vi.fn();
|
||||||
|
return {
|
||||||
|
usePageRedirectionHelper: vi.fn().mockReturnValue({
|
||||||
|
goToVersions,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
return {
|
||||||
|
useTelemetry: () => {
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(VersionUpdateCTA, {
|
||||||
|
props: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let uiStore: MockedStore<typeof useUIStore>;
|
||||||
|
let versionsStore: MockedStore<typeof useVersionsStore>;
|
||||||
|
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
|
||||||
|
const version: Version = {
|
||||||
|
name: '1.100.0',
|
||||||
|
nodes: [],
|
||||||
|
createdAt: '2025-06-24T00:00:00Z',
|
||||||
|
description: 'Latest version description',
|
||||||
|
documentationUrl: 'https://docs.n8n.io',
|
||||||
|
hasBreakingChange: false,
|
||||||
|
hasSecurityFix: false,
|
||||||
|
hasSecurityIssue: false,
|
||||||
|
securityIssueFixVersion: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('VersionUpdateCTA', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createTestingPinia();
|
||||||
|
uiStore = mockedStore(useUIStore);
|
||||||
|
uiStore.modalsById = {
|
||||||
|
[WHATS_NEW_MODAL_KEY]: {
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
versionsStore = mockedStore(useVersionsStore);
|
||||||
|
versionsStore.nextVersions = [version];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render', async () => {
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('version-update-cta-button')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should take user to update page when Update is clicked', async () => {
|
||||||
|
versionsStore.hasVersionUpdates = true;
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('version-update-cta-button')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('version-update-cta-button'));
|
||||||
|
|
||||||
|
expect(pageRedirectionHelper.goToVersions).toHaveBeenCalled();
|
||||||
|
expect(telemetry.track).toHaveBeenCalledWith('User clicked on update button', {
|
||||||
|
source: 'main-sidebar',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open the next versions drawer when clicking on the next versions link', async () => {
|
||||||
|
versionsStore.hasVersionUpdates = true;
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getByTestId('version-update-next-versions-link')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('version-update-next-versions-link'));
|
||||||
|
|
||||||
|
expect(uiStore.openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useVersionsStore } from '@/stores/versions.store';
|
||||||
|
import { N8nButton, N8nLink } from '@n8n/design-system';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { VERSIONS_MODAL_KEY } from '@/constants';
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const openUpdatesPanel = () => {
|
||||||
|
uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateClick = async () => {
|
||||||
|
telemetry.track('User clicked on update button', {
|
||||||
|
source: 'main-sidebar',
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageRedirectionHelper.goToVersions();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<N8nLink
|
||||||
|
size="small"
|
||||||
|
theme="text"
|
||||||
|
data-test-id="version-update-next-versions-link"
|
||||||
|
@click="openUpdatesPanel"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
i18n.baseText('whatsNew.versionsBehind', {
|
||||||
|
interpolate: {
|
||||||
|
count:
|
||||||
|
versionsStore.nextVersions.length > 99 ? '99+' : versionsStore.nextVersions.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nLink>
|
||||||
|
|
||||||
|
<N8nButton
|
||||||
|
:class="$style.button"
|
||||||
|
:label="i18n.baseText('whatsNew.update')"
|
||||||
|
data-test-id="version-update-cta-button"
|
||||||
|
size="mini"
|
||||||
|
@click="onUpdateClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||||
|
margin-left: var(--spacing-s);
|
||||||
|
margin-bottom: var(--spacing-3xs);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
border: var(--border-base);
|
||||||
|
background: var(--color-background-light-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
223
packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts
Normal file
223
packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { waitFor, screen } from '@testing-library/vue';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import {
|
||||||
|
cleanupAppModals,
|
||||||
|
createAppModals,
|
||||||
|
mockedStore,
|
||||||
|
type MockedStore,
|
||||||
|
} from '@/__tests__/utils';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { WHATS_NEW_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
|
||||||
|
import { useVersionsStore } from '@/stores/versions.store';
|
||||||
|
import type { Version } from '@n8n/rest-api-client/api/versions';
|
||||||
|
|
||||||
|
import WhatsNewModal from './WhatsNewModal.vue';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||||
|
const goToVersions = vi.fn();
|
||||||
|
return {
|
||||||
|
usePageRedirectionHelper: vi.fn().mockReturnValue({
|
||||||
|
goToVersions,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
return {
|
||||||
|
useTelemetry: () => {
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(WhatsNewModal, {
|
||||||
|
props: {
|
||||||
|
modalName: WHATS_NEW_MODAL_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let uiStore: MockedStore<typeof useUIStore>;
|
||||||
|
let versionsStore: MockedStore<typeof useVersionsStore>;
|
||||||
|
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
|
||||||
|
const currentVersion: Version = {
|
||||||
|
name: '1.100.0',
|
||||||
|
nodes: [],
|
||||||
|
createdAt: '2025-06-24T00:00:00Z',
|
||||||
|
description: 'Latest version description',
|
||||||
|
documentationUrl: 'https://docs.n8n.io',
|
||||||
|
hasBreakingChange: false,
|
||||||
|
hasSecurityFix: false,
|
||||||
|
hasSecurityIssue: false,
|
||||||
|
securityIssueFixVersion: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('WhatsNewModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createAppModals();
|
||||||
|
createTestingPinia();
|
||||||
|
uiStore = mockedStore(useUIStore);
|
||||||
|
uiStore.modalsById = {
|
||||||
|
[WHATS_NEW_MODAL_KEY]: {
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
versionsStore = mockedStore(useVersionsStore);
|
||||||
|
versionsStore.hasVersionUpdates = false;
|
||||||
|
versionsStore.currentVersion = currentVersion;
|
||||||
|
versionsStore.latestVersion = currentVersion;
|
||||||
|
versionsStore.nextVersions = [];
|
||||||
|
versionsStore.whatsNewArticles = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Convert to sub-workflow',
|
||||||
|
content:
|
||||||
|
'Large, monolithic workflows can slow things down. They’re harder to maintain, ' +
|
||||||
|
'tougher to debug, and more difficult to scale. With sub-workflows, you can take a ' +
|
||||||
|
'more modular approach, breaking up big workflows into smaller, manageable parts that ' +
|
||||||
|
'are easier to reuse, test, understand, and explain.\n\nUntil now, creating sub-workflows ' +
|
||||||
|
'required copying and pasting nodes manually, setting up a new workflow from scratch, and ' +
|
||||||
|
'reconnecting everything by hand. **Convert to sub-workflow** allows you to simplify this ' +
|
||||||
|
'process into a single action, so you can spend more time building and less time restructuring.\n\n' +
|
||||||
|
'### How it works\n\n1. Highlight the nodes you want to convert to a sub-workflow. These must:\n' +
|
||||||
|
' - Be fully connected, meaning no missing steps in between them\n' +
|
||||||
|
' - Start from a single starting node\n' +
|
||||||
|
' - End with a single node\n' +
|
||||||
|
'2. Right-click to open the context menu and select ' +
|
||||||
|
'**Convert to sub-workflow**\n' +
|
||||||
|
' - Or use the shortcut: `Alt + X`\n' +
|
||||||
|
'3. n8n will:\n' +
|
||||||
|
' - Open a new tab containing the selected nodes\n' +
|
||||||
|
' - Preserve all node parameters as-is\n' +
|
||||||
|
' - Replace the selected nodes in the original workflow with a **Call My Sub-workflow** node\n\n' +
|
||||||
|
'_Note:_ You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.\n\n' +
|
||||||
|
'This makes it easier to keep workflows modular, performant, and easier to maintain.\n\n' +
|
||||||
|
'Learn more about [sub-workflows](https://docs.n8n.io/flow-logic/subworkflows/).\n\n' +
|
||||||
|
'This release contains performance improvements and bug fixes.\n\n' +
|
||||||
|
'@[youtube](ZCuL2e4zC_4)\n\n' +
|
||||||
|
'Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.\n\n' +
|
||||||
|
'## Second level title\n\n### Third level title\n\nThis **is bold**, this _in italics_.\n' +
|
||||||
|
"~~Strikethrough is also something we support~~.\n\nHere's a peace of code:\n\n" +
|
||||||
|
'```typescript\nconst props = defineProps<{\n\tmodalName: string;\n\tdata: {\n\t\tarticleId: number;\n\t};\n}>();\n```\n\n' +
|
||||||
|
'Inline `code also works` withing text.\n\nThis is a list:\n- first\n- second\n- third\n\nAnd this list is ordered\n' +
|
||||||
|
'1. foo\n2. bar\n3. qux\n\nDividers:\n\nThree or more...\n\n---\n\nHyphens\n\n***\n\nAsterisks\n\n___\n\nUnderscores\n\n---\n\n' +
|
||||||
|
'<details>\n<summary>Fixes (4)</summary>\n\n' +
|
||||||
|
'- **Credential Storage Issue** Resolved an issue where credentials would occasionally become inaccessible after server restarts\n' +
|
||||||
|
'- **Webhook Timeout Handling** Fixed timeout issues with long-running webhook requests\n' +
|
||||||
|
'- **Node Connection Validation** Improved validation for node connections to prevent invalid workflow configurations\n' +
|
||||||
|
'- **Memory Leak in Execution Engine** Fixed memory leak that could occur during long-running workflow executions\n\n</details>\n\n',
|
||||||
|
calloutTitle: 'Convert to sub-workflow',
|
||||||
|
calloutText: 'Simplify process of extracting nodes into a single action',
|
||||||
|
createdAt: '2025-06-19T12:35:14.454Z',
|
||||||
|
updatedAt: '2025-06-19T12:41:53.220Z',
|
||||||
|
publishedAt: '2025-06-19T12:41:53.216Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupAppModals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with update button disabled', async () => {
|
||||||
|
const { getByTestId, queryByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText("What's New in n8n 1.100.0")).toBeInTheDocument();
|
||||||
|
expect(getByTestId('whats-new-article-1')).toMatchSnapshot();
|
||||||
|
expect(getByTestId('whats-new-modal-update-button')).toBeDisabled();
|
||||||
|
expect(queryByTestId('whats-new-modal-next-versions-link')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with update button enabled', async () => {
|
||||||
|
versionsStore.hasVersionUpdates = true;
|
||||||
|
versionsStore.nextVersions = [
|
||||||
|
{
|
||||||
|
name: '1.100.1',
|
||||||
|
nodes: [],
|
||||||
|
createdAt: '2025-06-24T00:00:00Z',
|
||||||
|
description: 'Next version description',
|
||||||
|
documentationUrl: 'https://docs.n8n.io',
|
||||||
|
hasBreakingChange: false,
|
||||||
|
hasSecurityFix: false,
|
||||||
|
hasSecurityIssue: false,
|
||||||
|
securityIssueFixVersion: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(getByTestId('whats-new-modal-update-button')).toBeEnabled();
|
||||||
|
expect(getByTestId('whats-new-modal-next-versions-link')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('whats-new-modal-next-versions-link')).toHaveTextContent('1 version behind');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should take user to update page when Update is clicked', async () => {
|
||||||
|
versionsStore.hasVersionUpdates = true;
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('whats-new-modal-update-button'));
|
||||||
|
|
||||||
|
expect(telemetry.track).toHaveBeenCalledWith('User clicked on update button', {
|
||||||
|
source: 'whats-new-modal',
|
||||||
|
});
|
||||||
|
expect(pageRedirectionHelper.goToVersions).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open the next versions drawer when clicking on the next versions link', async () => {
|
||||||
|
versionsStore.hasVersionUpdates = true;
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
articleId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('whats-new-modal-next-versions-link'));
|
||||||
|
|
||||||
|
expect(uiStore.openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
263
packages/frontend/editor-ui/src/components/WhatsNewModal.vue
Normal file
263
packages/frontend/editor-ui/src/components/WhatsNewModal.vue
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DynamicScroller,
|
||||||
|
DynamicScrollerItem,
|
||||||
|
type RecycleScrollerInstance,
|
||||||
|
} from 'vue-virtual-scroller';
|
||||||
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||||
|
import dateformat from 'dateformat';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { VERSIONS_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/constants';
|
||||||
|
import { N8nCallout, N8nHeading, N8nIcon, N8nLink, N8nMarkdown, N8nText } from '@n8n/design-system';
|
||||||
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
|
import { useVersionsStore } from '@/stores/versions.store';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modalName: string;
|
||||||
|
data: {
|
||||||
|
articleId: number;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
|
||||||
|
const scroller = ref<RecycleScrollerInstance>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const nextVersions = computed(() => versionsStore.nextVersions);
|
||||||
|
|
||||||
|
const openUpdatesPanel = () => {
|
||||||
|
uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateClick = async () => {
|
||||||
|
telemetry.track('User clicked on update button', {
|
||||||
|
source: 'whats-new-modal',
|
||||||
|
});
|
||||||
|
|
||||||
|
await pageRedirectionHelper.goToVersions();
|
||||||
|
};
|
||||||
|
|
||||||
|
modalBus.on('opened', () => {
|
||||||
|
const articleIndex = versionsStore.whatsNewArticles.findIndex(
|
||||||
|
(article) => article.id === props.data.articleId,
|
||||||
|
);
|
||||||
|
|
||||||
|
scroller.value?.scrollToItem(articleIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Mark all items as read when the modal is opened.
|
||||||
|
// Later versions of the What's new articles might contain partially same items,
|
||||||
|
// but we only want to show the new ones as unread on the main sidebar.
|
||||||
|
for (const item of versionsStore.whatsNewArticles) {
|
||||||
|
if (!versionsStore.isWhatsNewArticleRead(item.id)) {
|
||||||
|
versionsStore.setWhatsNewArticleRead(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
max-width="860px"
|
||||||
|
max-height="85vh"
|
||||||
|
:event-bus="modalBus"
|
||||||
|
:name="WHATS_NEW_MODAL_KEY"
|
||||||
|
:center="true"
|
||||||
|
:show-close="false"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div :class="$style.header">
|
||||||
|
<div :class="$style.row">
|
||||||
|
<N8nIcon :icon="'bell'" :color="'primary'" :size="'large'" />
|
||||||
|
<div :class="$style.column">
|
||||||
|
<N8nHeading size="xlarge">
|
||||||
|
{{
|
||||||
|
i18n.baseText('whatsNew.modal.title', {
|
||||||
|
interpolate: {
|
||||||
|
version: versionsStore.latestVersion.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nHeading>
|
||||||
|
|
||||||
|
<div :class="$style.row">
|
||||||
|
<N8nHeading size="medium" color="text-light">{{
|
||||||
|
dateformat(versionsStore.latestVersion.createdAt, `d mmmm, yyyy`)
|
||||||
|
}}</N8nHeading>
|
||||||
|
<template v-if="versionsStore.hasVersionUpdates">
|
||||||
|
<N8nText :size="'medium'" :class="$style.text" :color="'text-base'">•</N8nText>
|
||||||
|
<N8nLink
|
||||||
|
size="medium"
|
||||||
|
theme="primary"
|
||||||
|
data-test-id="whats-new-modal-next-versions-link"
|
||||||
|
@click="openUpdatesPanel"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
i18n.baseText('whatsNew.versionsBehind', {
|
||||||
|
interpolate: {
|
||||||
|
count: nextVersions.length > 99 ? '99+' : nextVersions.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n8n-button
|
||||||
|
:size="'large'"
|
||||||
|
:label="i18n.baseText('whatsNew.update')"
|
||||||
|
:disabled="!versionsStore.hasVersionUpdates"
|
||||||
|
data-test-id="whats-new-modal-update-button"
|
||||||
|
@click="onUpdateClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<DynamicScroller
|
||||||
|
ref="scroller"
|
||||||
|
:min-item-size="10"
|
||||||
|
:items="versionsStore.whatsNewArticles"
|
||||||
|
class="full-height scroller"
|
||||||
|
style="max-height: 80vh"
|
||||||
|
>
|
||||||
|
<template #before>
|
||||||
|
<N8nCallout
|
||||||
|
v-if="versionsStore.hasVersionUpdates"
|
||||||
|
:class="$style.callout"
|
||||||
|
theme="warning"
|
||||||
|
>
|
||||||
|
<slot name="callout-message">
|
||||||
|
<N8nText size="small">
|
||||||
|
{{
|
||||||
|
i18n.baseText('whatsNew.updateAvailable', {
|
||||||
|
interpolate: {
|
||||||
|
currentVersion: versionsStore.currentVersion?.name ?? 'unknown',
|
||||||
|
latestVersion: versionsStore.latestVersion?.name,
|
||||||
|
count: nextVersions.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<N8nLink
|
||||||
|
:size="'small'"
|
||||||
|
:underline="true"
|
||||||
|
theme="primary"
|
||||||
|
to="https://docs.n8n.io/release-notes/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }}
|
||||||
|
</N8nLink>
|
||||||
|
</N8nText>
|
||||||
|
</slot>
|
||||||
|
</N8nCallout>
|
||||||
|
</template>
|
||||||
|
<template #default="{ item, index, active }">
|
||||||
|
<DynamicScrollerItem
|
||||||
|
:item="item"
|
||||||
|
:active="active"
|
||||||
|
:size-dependencies="[item.content]"
|
||||||
|
:data-index="index"
|
||||||
|
>
|
||||||
|
<div :class="$style.article" :data-test-id="`whats-new-article-${item.id}`">
|
||||||
|
<N8nHeading bold tag="h2" size="xlarge">
|
||||||
|
{{ item.title }}
|
||||||
|
</N8nHeading>
|
||||||
|
<N8nMarkdown
|
||||||
|
:content="item.content"
|
||||||
|
:class="$style.markdown"
|
||||||
|
:options="{
|
||||||
|
markdown: {
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
breaks: true,
|
||||||
|
},
|
||||||
|
tasklists: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
linkAttributes: {
|
||||||
|
attrs: {
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
width: '100%',
|
||||||
|
height: '315',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DynamicScrollerItem>
|
||||||
|
</template>
|
||||||
|
</DynamicScroller>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-base);
|
||||||
|
padding-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-dialog__header) {
|
||||||
|
padding-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article {
|
||||||
|
padding: var(--spacing-s) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown {
|
||||||
|
margin: var(--spacing-s) 0;
|
||||||
|
|
||||||
|
p,
|
||||||
|
strong,
|
||||||
|
em,
|
||||||
|
s,
|
||||||
|
code,
|
||||||
|
a,
|
||||||
|
li {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`WhatsNewModal > should render with update button disabled 1`] = `
|
||||||
|
<div
|
||||||
|
class="article"
|
||||||
|
data-test-id="whats-new-article-1"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
class="n8n-heading size-xlarge bold"
|
||||||
|
>
|
||||||
|
|
||||||
|
Convert to sub-workflow
|
||||||
|
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="n8n-markdown markdown"
|
||||||
|
>
|
||||||
|
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
class="markdown"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Large, monolithic workflows can slow things down. They’re harder to maintain, tougher to debug, and more difficult to scale. With sub-workflows, you can take a more modular approach, breaking up big workflows into smaller, manageable parts that are easier to reuse, test, understand, and explain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Until now, creating sub-workflows required copying and pasting nodes manually, setting up a new workflow from scratch, and reconnecting everything by hand.
|
||||||
|
<strong>
|
||||||
|
Convert to sub-workflow
|
||||||
|
</strong>
|
||||||
|
allows you to simplify this process into a single action, so you can spend more time building and less time restructuring.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
How it works
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Highlight the nodes you want to convert to a sub-workflow. These must:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Be fully connected, meaning no missing steps in between them
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Start from a single starting node
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
End with a single node
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Right-click to open the context menu and select
|
||||||
|
<strong>
|
||||||
|
Convert to sub-workflow
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Or use the shortcut:
|
||||||
|
<code>
|
||||||
|
Alt + X
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
n8n will:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Open a new tab containing the selected nodes
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Preserve all node parameters as-is
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Replace the selected nodes in the original workflow with a
|
||||||
|
<strong>
|
||||||
|
Call My Sub-workflow
|
||||||
|
</strong>
|
||||||
|
node
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<em>
|
||||||
|
Note:
|
||||||
|
</em>
|
||||||
|
You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This makes it easier to keep workflows modular, performant, and easier to maintain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Learn more about
|
||||||
|
<a
|
||||||
|
href="https://docs.n8n.io/flow-logic/subworkflows/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
sub-workflows
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This release contains performance improvements and bug fixes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<iframe
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowfullscreen=""
|
||||||
|
frameborder="0"
|
||||||
|
height="315"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4"
|
||||||
|
title="YouTube video player"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Second level title
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
Third level title
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This
|
||||||
|
<strong>
|
||||||
|
is bold
|
||||||
|
</strong>
|
||||||
|
, this
|
||||||
|
<em>
|
||||||
|
in italics
|
||||||
|
</em>
|
||||||
|
.
|
||||||
|
<br />
|
||||||
|
|
||||||
|
|
||||||
|
<s>
|
||||||
|
Strikethrough is also something we support
|
||||||
|
</s>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Here’s a peace of code:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
<code>
|
||||||
|
const props = defineProps<{
|
||||||
|
modalName: string;
|
||||||
|
data: {
|
||||||
|
articleId: number;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Inline
|
||||||
|
<code>
|
||||||
|
code also works
|
||||||
|
</code>
|
||||||
|
withing text.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This is a list:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
first
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
second
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
third
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
And this list is ordered
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
foo
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
bar
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
qux
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Dividers:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Three or more…
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Hyphens
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Asterisks
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Underscores
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
|
||||||
|
<summary>
|
||||||
|
Fixes (4)
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Credential Storage Issue
|
||||||
|
</strong>
|
||||||
|
Resolved an issue where credentials would occasionally become inaccessible after server restarts
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Webhook Timeout Handling
|
||||||
|
</strong>
|
||||||
|
Fixed timeout issues with long-running webhook requests
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Node Connection Validation
|
||||||
|
</strong>
|
||||||
|
Improved validation for node connections to prevent invalid workflow configurations
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Memory Leak in Execution Engine
|
||||||
|
</strong>
|
||||||
|
Fixed memory leak that could occur during long-running workflow executions
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -56,6 +56,8 @@ describe('usePageRedirectionHelper', () => {
|
|||||||
endpoint: '',
|
endpoint: '',
|
||||||
infoUrl:
|
infoUrl:
|
||||||
'https://docs.n8n.io/release-notes/#n8n1652?utm_source=n8n_app&utm_medium=instance_upgrade_releases',
|
'https://docs.n8n.io/release-notes/#n8n1652?utm_source=n8n_app&utm_medium=instance_upgrade_releases',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
|
|||||||
'workflowActivationConflictingWebhook';
|
'workflowActivationConflictingWebhook';
|
||||||
export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
|
export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
|
||||||
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
|
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
|
||||||
|
export const WHATS_NEW_MODAL_KEY = 'whatsNew';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
@@ -489,6 +490,8 @@ export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
|
|||||||
'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS';
|
'N8N_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS';
|
||||||
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
|
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
|
||||||
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
|
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
|
||||||
|
export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES';
|
||||||
|
|
||||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
export const COMMUNITY_PLUS_DOCS_URL =
|
export const COMMUNITY_PLUS_DOCS_URL =
|
||||||
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
faBan,
|
faBan,
|
||||||
faBalanceScaleLeft,
|
faBalanceScaleLeft,
|
||||||
faBars,
|
faBars,
|
||||||
|
faBell,
|
||||||
faBolt,
|
faBolt,
|
||||||
faBook,
|
faBook,
|
||||||
faBoxOpen,
|
faBoxOpen,
|
||||||
@@ -219,6 +220,7 @@ export const FontAwesomePlugin: Plugin = {
|
|||||||
addIcon(faBan);
|
addIcon(faBan);
|
||||||
addIcon(faBalanceScaleLeft);
|
addIcon(faBalanceScaleLeft);
|
||||||
addIcon(faBars);
|
addIcon(faBars);
|
||||||
|
addIcon(faBell);
|
||||||
addIcon(faBolt);
|
addIcon(faBolt);
|
||||||
addIcon(faBook);
|
addIcon(faBook);
|
||||||
addIcon(faBoxOpen);
|
addIcon(faBoxOpen);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
||||||
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||||
LOCAL_STORAGE_THEME,
|
LOCAL_STORAGE_THEME,
|
||||||
|
WHATS_NEW_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
import type {
|
import type {
|
||||||
@@ -121,6 +122,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
PROJECT_MOVE_RESOURCE_MODAL,
|
PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
||||||
|
WHATS_NEW_MODAL_KEY,
|
||||||
].map((modalKey) => [modalKey, { open: false }]),
|
].map((modalKey) => [modalKey, { open: false }]),
|
||||||
),
|
),
|
||||||
[DELETE_USER_MODAL_KEY]: {
|
[DELETE_USER_MODAL_KEY]: {
|
||||||
@@ -206,6 +208,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
workflowName: '',
|
workflowName: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[WHATS_NEW_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
data: {
|
||||||
|
articleId: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalStack = ref<string[]>([]);
|
const modalStack = ref<string[]>([]);
|
||||||
|
|||||||
354
packages/frontend/editor-ui/src/stores/versions.store.test.ts
Normal file
354
packages/frontend/editor-ui/src/stores/versions.store.test.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { useVersionsStore } from './versions.store';
|
||||||
|
import * as versionsApi from '@n8n/rest-api-client/api/versions';
|
||||||
|
import type { IVersionNotificationSettings } from '@n8n/api-types';
|
||||||
|
import type { Version, WhatsNewArticle } from '@n8n/rest-api-client/api/versions';
|
||||||
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
|
import { useSettingsStore } from './settings.store';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => {
|
||||||
|
const showToast = vi.fn();
|
||||||
|
return {
|
||||||
|
useToast: () => {
|
||||||
|
return {
|
||||||
|
showToast,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings: IVersionNotificationSettings = {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'https://test.api.n8n.io/api/versions/',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: 'https://test.api.n8n.io/api/whats-new',
|
||||||
|
infoUrl: 'https://test.docs.n8n.io/hosting/installation/updating/',
|
||||||
|
};
|
||||||
|
|
||||||
|
const instanceId = 'test-instance-id';
|
||||||
|
const currentVersionName = '1.100.0';
|
||||||
|
const currentVersion: Version = {
|
||||||
|
name: currentVersionName,
|
||||||
|
nodes: [],
|
||||||
|
createdAt: '2025-06-24T00:00:00Z',
|
||||||
|
description: 'Latest version description',
|
||||||
|
documentationUrl: 'https://docs.n8n.io',
|
||||||
|
hasBreakingChange: false,
|
||||||
|
hasSecurityFix: false,
|
||||||
|
hasSecurityIssue: false,
|
||||||
|
securityIssueFixVersion: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const whatsNewArticle: WhatsNewArticle = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test article',
|
||||||
|
content: 'Some markdown content here',
|
||||||
|
calloutTitle: 'Callout title',
|
||||||
|
calloutText: 'Callout text.',
|
||||||
|
createdAt: '2025-06-19T12:37:54.885Z',
|
||||||
|
updatedAt: '2025-06-19T12:41:44.919Z',
|
||||||
|
publishedAt: '2025-06-19T12:41:44.914Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
describe('versions.store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchVersions()', () => {
|
||||||
|
it('should fetch versions and set current version', async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
rootStore.setVersionCli(currentVersionName);
|
||||||
|
rootStore.setInstanceId(instanceId);
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize(settings);
|
||||||
|
|
||||||
|
await versionsStore.fetchVersions();
|
||||||
|
|
||||||
|
expect(versionsApi.getNextVersions).toHaveBeenCalledWith(
|
||||||
|
settings.endpoint,
|
||||||
|
currentVersionName,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versionsStore.nextVersions).toEqual([]);
|
||||||
|
expect(versionsStore.currentVersion).toEqual(currentVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fetch versions if not enabled', async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getNextVersions');
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize({
|
||||||
|
...settings,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await versionsStore.fetchVersions();
|
||||||
|
|
||||||
|
expect(versionsApi.getNextVersions).not.toHaveBeenCalled();
|
||||||
|
expect(versionsStore.nextVersions).toEqual([]);
|
||||||
|
expect(versionsStore.currentVersion).toEqual(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchWhatsNew()', () => {
|
||||||
|
it("should fetch What's new articles", async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
rootStore.setVersionCli(currentVersionName);
|
||||||
|
rootStore.setInstanceId(instanceId);
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize(settings);
|
||||||
|
|
||||||
|
await versionsStore.fetchWhatsNew();
|
||||||
|
|
||||||
|
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith(
|
||||||
|
settings.whatsNewEndpoint,
|
||||||
|
currentVersionName,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versionsStore.whatsNewArticles).toEqual([whatsNewArticle]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not fetch What's new articles if version notifications are disabled", async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles');
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize({
|
||||||
|
...settings,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await versionsStore.fetchWhatsNew();
|
||||||
|
|
||||||
|
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled();
|
||||||
|
expect(versionsStore.whatsNewArticles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not fetch What's new articles if not enabled", async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles');
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize({
|
||||||
|
...settings,
|
||||||
|
enabled: true,
|
||||||
|
whatsNewEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await versionsStore.fetchWhatsNew();
|
||||||
|
|
||||||
|
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled();
|
||||||
|
expect(versionsStore.whatsNewArticles).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkForNewVersions()', () => {
|
||||||
|
it('should check for new versions', async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
|
||||||
|
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
rootStore.setVersionCli(currentVersionName);
|
||||||
|
rootStore.setInstanceId(instanceId);
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize(settings);
|
||||||
|
|
||||||
|
await versionsStore.checkForNewVersions();
|
||||||
|
|
||||||
|
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith(
|
||||||
|
settings.whatsNewEndpoint,
|
||||||
|
currentVersionName,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versionsStore.whatsNewArticles).toEqual([whatsNewArticle]);
|
||||||
|
|
||||||
|
expect(versionsApi.getNextVersions).toHaveBeenCalledWith(
|
||||||
|
settings.endpoint,
|
||||||
|
currentVersionName,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versionsStore.nextVersions).toEqual([]);
|
||||||
|
expect(versionsStore.currentVersion).toEqual(currentVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should still initialize versions if what's new articles fail", async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockRejectedValueOnce(new Error('oopsie'));
|
||||||
|
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
rootStore.setVersionCli(currentVersionName);
|
||||||
|
rootStore.setInstanceId(instanceId);
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize({
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'https://api.n8n.io/api/versions/',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
|
||||||
|
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
|
||||||
|
});
|
||||||
|
|
||||||
|
await versionsStore.checkForNewVersions();
|
||||||
|
|
||||||
|
expect(versionsStore.whatsNewArticles).toEqual([]);
|
||||||
|
expect(versionsStore.nextVersions).toEqual([]);
|
||||||
|
expect(versionsStore.currentVersion).toEqual(currentVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should still initialize what's new articles if versions fail", async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
|
||||||
|
vi.spyOn(versionsApi, 'getNextVersions').mockRejectedValueOnce(new Error('oopsie'));
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
rootStore.setVersionCli(currentVersionName);
|
||||||
|
rootStore.setInstanceId(instanceId);
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize({
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'https://api.n8n.io/api/versions/',
|
||||||
|
whatsNewEnabled: true,
|
||||||
|
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
|
||||||
|
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
|
||||||
|
});
|
||||||
|
|
||||||
|
await versionsStore.checkForNewVersions();
|
||||||
|
|
||||||
|
expect(versionsStore.whatsNewArticles).toEqual([whatsNewArticle]);
|
||||||
|
expect(versionsStore.nextVersions).toEqual([]);
|
||||||
|
expect(versionsStore.currentVersion).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show toast if important version updates are available', async () => {
|
||||||
|
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([]);
|
||||||
|
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([
|
||||||
|
{
|
||||||
|
...currentVersion,
|
||||||
|
hasSecurityIssue: true,
|
||||||
|
securityIssueFixVersion: '1.100.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...currentVersion,
|
||||||
|
name: '1.100.1',
|
||||||
|
hasSecurityFix: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
rootStore.setVersionCli(currentVersionName);
|
||||||
|
rootStore.setInstanceId(instanceId);
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.initialize(settings);
|
||||||
|
|
||||||
|
await versionsStore.checkForNewVersions();
|
||||||
|
|
||||||
|
expect(versionsStore.nextVersions).toHaveLength(1);
|
||||||
|
expect(versionsStore.nextVersions[0].name).toBe('1.100.1');
|
||||||
|
expect(toast.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Critical update available',
|
||||||
|
message: expect.stringContaining('Please update to version 1.100.1 or higher.'),
|
||||||
|
type: 'warning',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setWhatsNewArticleRead()', () => {
|
||||||
|
it('should add article ID to read articles', () => {
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
|
||||||
|
expect(versionsStore.isWhatsNewArticleRead(1)).toBe(false);
|
||||||
|
expect(versionsStore.isWhatsNewArticleRead(2)).toBe(false);
|
||||||
|
|
||||||
|
versionsStore.setWhatsNewArticleRead(1);
|
||||||
|
versionsStore.setWhatsNewArticleRead(2);
|
||||||
|
|
||||||
|
expect(versionsStore.isWhatsNewArticleRead(1)).toBe(true);
|
||||||
|
expect(versionsStore.isWhatsNewArticleRead(2)).toBe(true);
|
||||||
|
expect(versionsStore.isWhatsNewArticleRead(404)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasVersionUpdates', () => {
|
||||||
|
it('should return true on stable if there are next versions', () => {
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
settingsStore.settings.releaseChannel = 'stable';
|
||||||
|
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.nextVersions = [
|
||||||
|
{
|
||||||
|
...currentVersion,
|
||||||
|
name: '1.100.1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
versionsStore.currentVersion = currentVersion;
|
||||||
|
|
||||||
|
expect(versionsStore.hasVersionUpdates).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false on stable if there are no next versions', () => {
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.nextVersions = [];
|
||||||
|
versionsStore.currentVersion = currentVersion;
|
||||||
|
|
||||||
|
expect(versionsStore.hasVersionUpdates).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false on beta if there are next versions', () => {
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.nextVersions = [
|
||||||
|
{
|
||||||
|
...currentVersion,
|
||||||
|
name: '1.100.1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
versionsStore.currentVersion = currentVersion;
|
||||||
|
|
||||||
|
expect(versionsStore.hasVersionUpdates).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('latestVersion', () => {
|
||||||
|
it('should return the latest version from next versions', () => {
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.nextVersions = [
|
||||||
|
{
|
||||||
|
...currentVersion,
|
||||||
|
name: '1.100.2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...currentVersion,
|
||||||
|
name: '1.100.1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
versionsStore.currentVersion = currentVersion;
|
||||||
|
|
||||||
|
expect(versionsStore.latestVersion.name).toBe('1.100.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return current version if no next versions', () => {
|
||||||
|
const versionsStore = useVersionsStore();
|
||||||
|
versionsStore.nextVersions = [];
|
||||||
|
versionsStore.currentVersion = currentVersion;
|
||||||
|
|
||||||
|
expect(versionsStore.latestVersion.name).toBe(currentVersionName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,46 @@
|
|||||||
import type { IVersionNotificationSettings } from '@n8n/api-types';
|
import type { IVersionNotificationSettings } from '@n8n/api-types';
|
||||||
import * as versionsApi from '@n8n/rest-api-client/api/versions';
|
import * as versionsApi from '@n8n/rest-api-client/api/versions';
|
||||||
import { VERSIONS_MODAL_KEY } from '@/constants';
|
import { LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES, VERSIONS_MODAL_KEY } from '@/constants';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
import type { Version } from '@n8n/rest-api-client/api/versions';
|
import type { Version, WhatsNewArticle } from '@n8n/rest-api-client/api/versions';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useSettingsStore } from './settings.store';
|
||||||
|
import { useStorage } from '@/composables/useStorage';
|
||||||
|
import { jsonParse } from 'n8n-workflow';
|
||||||
|
|
||||||
type SetVersionParams = { versions: Version[]; currentVersion: string };
|
type SetVersionParams = { versions: Version[]; currentVersion: string };
|
||||||
|
|
||||||
export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||||
const versionNotificationSettings = ref({ enabled: false, endpoint: '', infoUrl: '' });
|
const versionNotificationSettings = ref<IVersionNotificationSettings>({
|
||||||
|
enabled: false,
|
||||||
|
whatsNewEnabled: false,
|
||||||
|
endpoint: '',
|
||||||
|
whatsNewEndpoint: '',
|
||||||
|
infoUrl: '',
|
||||||
|
});
|
||||||
const nextVersions = ref<Version[]>([]);
|
const nextVersions = ref<Version[]>([]);
|
||||||
const currentVersion = ref<Version | undefined>();
|
const currentVersion = ref<Version | undefined>();
|
||||||
|
const whatsNewArticles = ref<WhatsNewArticle[]>([]);
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// #region Computed
|
// #region Computed
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const hasVersionUpdates = computed(() => {
|
const hasVersionUpdates = computed(() => {
|
||||||
return nextVersions.value.length > 0;
|
return settingsStore.settings.releaseChannel === 'stable' && nextVersions.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestVersion = computed(() => {
|
||||||
|
return nextVersions.value[0] ?? currentVersion.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const areNotificationsEnabled = computed(() => {
|
const areNotificationsEnabled = computed(() => {
|
||||||
@@ -35,6 +51,12 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
|||||||
return versionNotificationSettings.value.infoUrl;
|
return versionNotificationSettings.value.infoUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const readWhatsNewArticles = computed((): number[] => {
|
||||||
|
return readWhatsNewArticlesStorage.value
|
||||||
|
? jsonParse(readWhatsNewArticlesStorage.value, { fallbackValue: [] })
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -46,10 +68,10 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
|||||||
const { enabled, endpoint } = versionNotificationSettings.value;
|
const { enabled, endpoint } = versionNotificationSettings.value;
|
||||||
if (enabled && endpoint) {
|
if (enabled && endpoint) {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const currentVersion = rootStore.versionCli;
|
const current = rootStore.versionCli;
|
||||||
const instanceId = rootStore.instanceId;
|
const instanceId = rootStore.instanceId;
|
||||||
const versions = await versionsApi.getNextVersions(endpoint, currentVersion, instanceId);
|
const versions = await versionsApi.getNextVersions(endpoint, current, instanceId);
|
||||||
setVersions({ versions, currentVersion });
|
setVersions({ versions, currentVersion: current });
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
@@ -59,6 +81,40 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
|||||||
currentVersion.value = params.versions.find((v) => v.name === params.currentVersion);
|
currentVersion.value = params.versions.find((v) => v.name === params.currentVersion);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setWhatsNew = (article: WhatsNewArticle[]) => {
|
||||||
|
whatsNewArticles.value = article;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWhatsNew = async () => {
|
||||||
|
try {
|
||||||
|
const { enabled, whatsNewEnabled, whatsNewEndpoint } = versionNotificationSettings.value;
|
||||||
|
if (enabled && whatsNewEnabled && whatsNewEndpoint) {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const current = rootStore.versionCli;
|
||||||
|
const instanceId = rootStore.instanceId;
|
||||||
|
const articles = await versionsApi.getWhatsNewArticles(
|
||||||
|
whatsNewEndpoint,
|
||||||
|
current,
|
||||||
|
instanceId,
|
||||||
|
);
|
||||||
|
setWhatsNew(articles);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setWhatsNewArticleRead = (articleId: number) => {
|
||||||
|
if (!readWhatsNewArticles.value.includes(articleId)) {
|
||||||
|
readWhatsNewArticlesStorage.value = JSON.stringify([
|
||||||
|
...readWhatsNewArticles.value,
|
||||||
|
articleId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWhatsNewArticleRead = (articleId: number): boolean => {
|
||||||
|
return readWhatsNewArticles.value.includes(articleId);
|
||||||
|
};
|
||||||
|
|
||||||
const initialize = (settings: IVersionNotificationSettings) => {
|
const initialize = (settings: IVersionNotificationSettings) => {
|
||||||
versionNotificationSettings.value = settings;
|
versionNotificationSettings.value = settings;
|
||||||
};
|
};
|
||||||
@@ -69,7 +125,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchVersions();
|
await Promise.all([fetchVersions(), fetchWhatsNew()]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentVersion.value &&
|
currentVersion.value &&
|
||||||
@@ -101,6 +157,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
currentVersion,
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
nextVersions,
|
nextVersions,
|
||||||
hasVersionUpdates,
|
hasVersionUpdates,
|
||||||
areNotificationsEnabled,
|
areNotificationsEnabled,
|
||||||
@@ -109,5 +166,9 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
|||||||
setVersions,
|
setVersions,
|
||||||
initialize,
|
initialize,
|
||||||
checkForNewVersions,
|
checkForNewVersions,
|
||||||
|
fetchWhatsNew,
|
||||||
|
whatsNewArticles,
|
||||||
|
isWhatsNewArticleRead,
|
||||||
|
setWhatsNewArticleRead,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user