feat(editor): Add What's New notification callout (#16718)

This commit is contained in:
Jaakko Husso
2025-06-27 17:42:05 +03:00
committed by GitHub
parent 5fe68f38df
commit 1934e6fc0f
11 changed files with 518 additions and 198 deletions

View File

@@ -424,6 +424,10 @@ input[type='checkbox'] + label {
iframe {
aspect-ratio: 16/9 auto;
}
summary {
cursor: pointer;
}
}
.spacer {

View File

@@ -3231,7 +3231,6 @@
"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.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",

View File

@@ -29,15 +29,22 @@ export interface Version {
securityIssueFixVersion: string;
}
export interface WhatsNewSection {
title: string;
calloutText: string;
footer: string;
items: WhatsNewArticle[];
createdAt: string;
updatedAt: string | null;
}
export interface WhatsNewArticle {
id: number;
title: string;
createdAt: string;
updatedAt: string | null;
publishedAt: string;
title: string;
content: string;
calloutTitle: string;
calloutText: string;
}
export async function getNextVersions(
@@ -49,11 +56,11 @@ export async function getNextVersions(
return await get(endpoint, currentVersion, {}, headers);
}
export async function getWhatsNewArticles(
export async function getWhatsNewSection(
endpoint: string,
currentVersion: string,
instanceId: string,
): Promise<WhatsNewArticle[]> {
): Promise<WhatsNewSection> {
const headers = {
[INSTANCE_ID_HEADER as string]: instanceId,
[INSTANCE_VERSION_HEADER as string]: currentVersion,

View File

@@ -6,7 +6,7 @@ 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 { ABOUT_MODAL_KEY, RELEASE_NOTES_URL, VIEWS, WHATS_NEW_MODAL_KEY } from '@/constants';
import { hasPermission } from '@/utils/rbac/permissions';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useRootStore } from '@n8n/stores/useRootStore';
@@ -209,7 +209,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
icon: 'external-link-alt',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: {
href: 'https://docs.n8n.io/release-notes/',
href: RELEASE_NOTES_URL,
target: '_blank',
},
size: 'small',

View File

@@ -77,52 +77,58 @@ describe('WhatsNewModal', () => {
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',
},
];
versionsStore.whatsNew = {
createdAt: '2025-06-19T12:35:14.454Z',
updatedAt: null,
title: "What's New in n8n 1.100.0",
calloutText:
'Convert large workflows into sub-workflows for better modularity and performance.',
footer: 'This release contains performance improvements and bug fixes.',
items: [
{
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',
createdAt: '2025-06-19T12:35:14.454Z',
updatedAt: '2025-06-19T12:41:53.220Z',
publishedAt: '2025-06-19T12:41:53.216Z',
},
],
};
});
afterEach(() => {
@@ -140,10 +146,10 @@ describe('WhatsNewModal', () => {
});
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-item-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-item-1')).toMatchSnapshot();
expect(getByTestId('whats-new-modal-update-button')).toBeDisabled();
expect(queryByTestId('whats-new-modal-next-versions-link')).not.toBeInTheDocument();
});
@@ -173,7 +179,7 @@ describe('WhatsNewModal', () => {
});
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-item-1')).toBeInTheDocument());
expect(getByTestId('whats-new-modal-update-button')).toBeEnabled();
expect(getByTestId('whats-new-modal-next-versions-link')).toBeInTheDocument();
@@ -192,7 +198,7 @@ describe('WhatsNewModal', () => {
});
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-item-1')).toBeInTheDocument());
await userEvent.click(getByTestId('whats-new-modal-update-button'));
@@ -214,7 +220,7 @@ describe('WhatsNewModal', () => {
});
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
await waitFor(() => expect(getByTestId('whats-new-item-1')).toBeInTheDocument());
await userEvent.click(getByTestId('whats-new-modal-next-versions-link'));

View File

@@ -1,17 +1,11 @@
<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 { RELEASE_NOTES_URL, 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 { computed, nextTick, ref } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useUIStore } from '@/stores/ui.store';
@@ -23,10 +17,9 @@ const props = defineProps<{
};
}>();
const articleRefs = ref<Record<number, HTMLElement>>({});
const pageRedirectionHelper = usePageRedirectionHelper();
const scroller = ref<RecycleScrollerInstance>();
const i18n = useI18n();
const modalBus = createEventBus();
const versionsStore = useVersionsStore();
@@ -47,23 +40,32 @@ const onUpdateClick = async () => {
await pageRedirectionHelper.goToVersions();
};
const scrollToItem = async (articleId: number) => {
await nextTick(() => {
const target = articleRefs.value[articleId];
if (!target) return;
target.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
});
};
modalBus.on('opened', () => {
const articleIndex = versionsStore.whatsNewArticles.findIndex(
(article) => article.id === props.data.articleId,
);
versionsStore.closeWhatsNewCallout();
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,
// What's new articles on later weeks 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);
}
}
void scrollToItem(props.data.articleId);
});
</script>
@@ -82,13 +84,7 @@ onMounted(() => {
<N8nIcon :icon="'bell'" :color="'primary'" :size="'large'" />
<div :class="$style.column">
<N8nHeading size="xlarge">
{{
i18n.baseText('whatsNew.modal.title', {
interpolate: {
version: versionsStore.latestVersion.name,
},
})
}}
{{ versionsStore.whatsNew.title }}
</N8nHeading>
<div :class="$style.row">
@@ -127,83 +123,99 @@ onMounted(() => {
</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"
<N8nCallout
v-if="versionsStore.hasSignificantUpdates"
:class="$style.callout"
theme="warning"
>
<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>
<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="RELEASE_NOTES_URL"
target="_blank"
>
{{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }}
</N8nLink>
</N8nText>
</slot>
</N8nCallout>
<div
v-for="item in versionsStore.whatsNewArticles"
:ref="
(el: any) => {
if (el) articleRefs[item.id] = el as HTMLElement;
}
"
:key="item.id"
:class="$style.article"
:data-test-id="`whats-new-item-${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>
<N8nMarkdown
:content="versionsStore.whatsNew.footer"
: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>
</template>
</Modal>

View File

@@ -3,7 +3,7 @@
exports[`WhatsNewModal > should render with update button disabled 1`] = `
<div
class="article"
data-test-id="whats-new-article-1"
data-test-id="whats-new-item-1"
>
<h2
class="n8n-heading size-xlarge bold"

View File

@@ -491,10 +491,12 @@ export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES';
export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL =
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
export const RELEASE_NOTES_URL = 'https://docs.n8n.io/release-notes/';
export const HIRING_BANNER = `
//////

View File

@@ -174,6 +174,44 @@
.el-notification {
border-radius: 4px;
border: none;
&.whats-new-notification {
bottom: var(--spacing-xs) !important;
left: var(--spacing-s) !important;
width: 300px;
padding: var(--spacing-xs);
border: var(--border-base);
.el-notification__group {
margin-left: 0;
margin-right: var(--spacing-l);
}
.el-notification__title {
color: var(--color-callout-info-font);
font-family: var(--font-family);
font-size: var(--font-size-s);
font-style: normal;
font-weight: var(--font-weight-bold);
line-height: 1.4;
}
.el-notification__content {
color: var(--color-callout-info-font);
font-family: var(--font-family);
font-size: var(--font-size-s);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: 1.4;
margin-top: 0;
}
.el-notification__closeBtn {
height: 100%;
top: 0px;
right: var(--spacing-xs);
}
}
}
.el-notification__content {

View File

@@ -2,7 +2,7 @@ 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 type { Version, WhatsNewArticle, WhatsNewSection } from '@n8n/rest-api-client/api/versions';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from './settings.store';
import { useToast } from '@/composables/useToast';
@@ -44,13 +44,20 @@ 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 whatsNew: WhatsNewSection = {
title: "What's New title",
calloutText: 'Callout text.',
footer: "What's new footer",
items: [whatsNewArticle],
createdAt: '2025-06-19T12:37:54.885Z',
updatedAt: '2025-06-19T12:41:44.919Z',
};
const toast = useToast();
describe('versions.store', () => {
@@ -102,7 +109,7 @@ describe('versions.store', () => {
describe('fetchWhatsNew()', () => {
it("should fetch What's new articles", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
const rootStore = useRootStore();
rootStore.setVersionCli(currentVersionName);
@@ -113,7 +120,7 @@ describe('versions.store', () => {
await versionsStore.fetchWhatsNew();
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith(
expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
settings.whatsNewEndpoint,
currentVersionName,
instanceId,
@@ -123,7 +130,7 @@ describe('versions.store', () => {
});
it("should not fetch What's new articles if version notifications are disabled", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles');
vi.spyOn(versionsApi, 'getWhatsNewSection');
const versionsStore = useVersionsStore();
versionsStore.initialize({
@@ -133,12 +140,12 @@ describe('versions.store', () => {
await versionsStore.fetchWhatsNew();
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled();
expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
expect(versionsStore.whatsNewArticles).toEqual([]);
});
it("should not fetch What's new articles if not enabled", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles');
vi.spyOn(versionsApi, 'getWhatsNewSection');
const versionsStore = useVersionsStore();
versionsStore.initialize({
@@ -149,14 +156,14 @@ describe('versions.store', () => {
await versionsStore.fetchWhatsNew();
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled();
expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
expect(versionsStore.whatsNewArticles).toEqual([]);
});
});
describe('checkForNewVersions()', () => {
it('should check for new versions', async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
const rootStore = useRootStore();
@@ -168,7 +175,7 @@ describe('versions.store', () => {
await versionsStore.checkForNewVersions();
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith(
expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
settings.whatsNewEndpoint,
currentVersionName,
instanceId,
@@ -187,7 +194,7 @@ describe('versions.store', () => {
});
it("should still initialize versions if what's new articles fail", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockRejectedValueOnce(new Error('oopsie'));
vi.spyOn(versionsApi, 'getWhatsNewSection').mockRejectedValueOnce(new Error('oopsie'));
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
const rootStore = useRootStore();
@@ -211,7 +218,7 @@ describe('versions.store', () => {
});
it("should still initialize what's new articles if versions fail", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
vi.spyOn(versionsApi, 'getNextVersions').mockRejectedValueOnce(new Error('oopsie'));
const rootStore = useRootStore();
@@ -235,7 +242,7 @@ describe('versions.store', () => {
});
it('should show toast if important version updates are available', async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([]);
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue({ ...whatsNew, items: [] });
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([
{
...currentVersion,
@@ -351,4 +358,150 @@ describe('versions.store', () => {
expect(versionsStore.latestVersion.name).toBe(currentVersionName);
});
});
describe('hasSignificantUpdates', () => {
beforeEach(() => {
const settingsStore = useSettingsStore();
settingsStore.settings.releaseChannel = 'stable';
});
it('should return true if current version is behind by at least two minor versions', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = currentVersion;
versionsStore.nextVersions = [
{
...currentVersion,
name: '1.102.0',
},
{
...currentVersion,
name: '1.101.0',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(true);
});
it('should return true if current version is behind by at least two minor versions, more exotic versions', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = {
...currentVersion,
name: '1.100.1+rc.1',
};
versionsStore.nextVersions = [
{
...currentVersion,
name: '1.102.0-alpha+20180301',
},
{
...currentVersion,
name: '1.101.0',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(true);
});
it('should return true if current version has security issue', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = {
...currentVersion,
hasSecurityIssue: true,
};
versionsStore.nextVersions = [
{
...currentVersion,
name: '1.101.0',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(true);
});
it('should return false if current version is not behind by at least two minor versions', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = {
...currentVersion,
name: '1.101.0',
};
versionsStore.nextVersions = [
{
...currentVersion,
name: '1.102.0',
},
{
...currentVersion,
name: '1.101.1',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(false);
});
it('should return false if current version is only behind by patch versions', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = currentVersion;
versionsStore.nextVersions = [
{
...currentVersion,
name: '1.100.9',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(false);
});
it('should return true if current version is behind by a major', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = {
...currentVersion,
name: '1.100.0',
};
versionsStore.nextVersions = [
{
...currentVersion,
name: '2.0.0',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(true);
});
it('should return false if current version is not semver', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = {
...currentVersion,
name: 'alpha-1',
};
versionsStore.nextVersions = [
{
...currentVersion,
name: '1.100.2',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(false);
});
it('should return false if latest version is not semver', () => {
const versionsStore = useVersionsStore();
versionsStore.currentVersion = currentVersion;
versionsStore.nextVersions = [
{
...currentVersion,
name: 'alpha-2',
},
];
expect(versionsStore.hasSignificantUpdates).toBe(false);
});
});
});

View File

@@ -1,9 +1,15 @@
import type { IVersionNotificationSettings } from '@n8n/api-types';
import * as versionsApi from '@n8n/rest-api-client/api/versions';
import { LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES, VERSIONS_MODAL_KEY } from '@/constants';
import {
LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT,
LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES,
VERSIONS_MODAL_KEY,
WHATS_NEW_MODAL_KEY,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type { Version, WhatsNewArticle } from '@n8n/rest-api-client/api/versions';
import type { Version, WhatsNewSection } from '@n8n/rest-api-client/api/versions';
import { defineStore } from 'pinia';
import type { NotificationHandle } from 'element-plus';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
@@ -11,9 +17,17 @@ import { computed, ref } from 'vue';
import { useSettingsStore } from './settings.store';
import { useStorage } from '@/composables/useStorage';
import { jsonParse } from 'n8n-workflow';
import { useTelemetry } from '@/composables/useTelemetry';
type SetVersionParams = { versions: Version[]; currentVersion: string };
/**
* Semantic versioning 2.0.0, Regex from https://semver.org/
* Capture groups: major, minor, patch, prerelease, buildmetadata
*/
export const SEMVER_REGEX =
/^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
const versionNotificationSettings = ref<IVersionNotificationSettings>({
enabled: false,
@@ -24,12 +38,22 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
});
const nextVersions = ref<Version[]>([]);
const currentVersion = ref<Version | undefined>();
const whatsNewArticles = ref<WhatsNewArticle[]>([]);
const whatsNew = ref<WhatsNewSection>({
title: '',
createdAt: new Date().toISOString(),
updatedAt: null,
calloutText: '',
footer: '',
items: [],
});
const whatsNewCallout = ref<NotificationHandle | undefined>();
const { showToast } = useToast();
const telemetry = useTelemetry();
const { showToast, showMessage } = useToast();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES);
const lastDismissedWhatsNewCalloutStorage = useStorage(LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT);
// ---------------------------------------------------------------------------
// #region Computed
@@ -39,6 +63,29 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
return settingsStore.settings.releaseChannel === 'stable' && nextVersions.value.length > 0;
});
const hasSignificantUpdates = computed(() => {
if (!hasVersionUpdates.value || !currentVersion.value || !latestVersion.value) return false;
// Always consider security issues as significant updates
if (currentVersion.value.hasSecurityIssue) return true;
const current = currentVersion.value.name.match(SEMVER_REGEX);
const latest = latestVersion.value.name.match(SEMVER_REGEX);
if (!current?.groups || !latest?.groups) return false;
// Major change is always significant
if (Number(current.groups.major) !== Number(latest.groups.major)) {
return true;
}
const currentMinor = Number(current.groups.minor);
const latestMinor = Number(latest.groups.minor);
// Otherwise two minor versions is enough to be considered significant
return latestMinor - currentMinor >= 2;
});
const latestVersion = computed(() => {
return nextVersions.value[0] ?? currentVersion.value;
});
@@ -57,6 +104,16 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
: [];
});
const lastDismissedWhatsNewCallout = computed((): number[] => {
return lastDismissedWhatsNewCalloutStorage.value
? jsonParse(lastDismissedWhatsNewCalloutStorage.value, { fallbackValue: [] })
: [];
});
const whatsNewArticles = computed(() => {
return whatsNew.value.items;
});
// #endregion
// ---------------------------------------------------------------------------
@@ -81,25 +138,8 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
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 setWhatsNew = (section: WhatsNewSection) => {
whatsNew.value = section;
};
const setWhatsNewArticleRead = (articleId: number) => {
@@ -115,6 +155,62 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
return readWhatsNewArticles.value.includes(articleId);
};
const closeWhatsNewCallout = () => {
whatsNewCallout.value?.close();
whatsNewCallout.value = undefined;
};
const dismissWhatsNewCallout = () => {
lastDismissedWhatsNewCalloutStorage.value = JSON.stringify(
whatsNewArticles.value.map((item) => item.id),
);
};
const shouldShowWhatsNewCallout = (): boolean => {
return !whatsNewArticles.value.every((item) =>
lastDismissedWhatsNewCallout.value.includes(item.id),
);
};
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 section = await versionsApi.getWhatsNewSection(whatsNewEndpoint, current, instanceId);
if (section.items?.length > 0) {
setWhatsNew(section);
if (shouldShowWhatsNewCallout()) {
whatsNewCallout.value = showMessage({
title: whatsNew.value.title,
message: whatsNew.value.calloutText,
duration: 0,
position: 'bottom-left',
customClass: 'clickable whats-new-notification',
onClick: () => {
const articleId = whatsNew.value.items[0]?.id ?? 0;
telemetry.track("User clicked on what's new notification", {
article_id: articleId,
});
uiStore.openModalWithData({
name: WHATS_NEW_MODAL_KEY,
data: { articleId },
});
},
onClose: () => {
dismissWhatsNewCallout();
},
});
}
}
}
} catch (e) {}
};
const initialize = (settings: IVersionNotificationSettings) => {
versionNotificationSettings.value = settings;
};
@@ -160,6 +256,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
latestVersion,
nextVersions,
hasVersionUpdates,
hasSignificantUpdates,
areNotificationsEnabled,
infoUrl,
fetchVersions,
@@ -167,8 +264,10 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
initialize,
checkForNewVersions,
fetchWhatsNew,
whatsNew,
whatsNewArticles,
isWhatsNewArticleRead,
setWhatsNewArticleRead,
closeWhatsNewCallout,
};
});