feat(editor): Add 'Whats new' section and modal (#16664)

This commit is contained in:
Jaakko Husso
2025-06-26 16:41:49 +03:00
committed by GitHub
parent fcf559b93d
commit 0b7bca29f8
31 changed files with 1907 additions and 134 deletions

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

@@ -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&nbsp;\n'); contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\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') {

View File

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

View File

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

View File

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

View File

@@ -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)) {

View File

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

View File

@@ -1,17 +1,3 @@
export const escapeMarkdown = (html: string | undefined): string => {
if (!html) {
return '';
}
const escaped = html.replace(/</g, '&lt;').replace(/>/g, '&gt;');
// unescape greater than quotes at start of line
const withQuotes = escaped.replace(/^((\s)*(&gt;)+)+\s*/gm, (matches) => {
return matches.replace(/&gt;/g, '>');
});
return withQuotes;
};
const checkedRegEx = /(\*|-) \[x\]/; const checkedRegEx = /(\*|-) \[x\]/;
const uncheckedRegEx = /(\*|-) \[\s\]/; const uncheckedRegEx = /(\*|-) \[\s\]/;

View File

@@ -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": "Whats 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"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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. Theyre 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);
});
});

View 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>

View File

@@ -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. Theyre 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>
Heres a peace of code:
</p>
<pre>
<code>
const props = defineProps&lt;{
modalName: string;
data: {
articleId: number;
};
}&gt;();
</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>
`;

View File

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

View File

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

View File

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

View File

@@ -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[]>([]);

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

View File

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