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 { iframe {
aspect-ratio: 16/9 auto; aspect-ratio: 16/9 auto;
} }
summary {
cursor: pointer;
}
} }
.spacer { .spacer {

View File

@@ -3231,7 +3231,6 @@
"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.versionsBehind": "{count} version behind | {count} versions behind",
"whatsNew.update": "Update", "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": "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; securityIssueFixVersion: string;
} }
export interface WhatsNewSection {
title: string;
calloutText: string;
footer: string;
items: WhatsNewArticle[];
createdAt: string;
updatedAt: string | null;
}
export interface WhatsNewArticle { export interface WhatsNewArticle {
id: number; id: number;
title: string;
createdAt: string; createdAt: string;
updatedAt: string | null; updatedAt: string | null;
publishedAt: string; publishedAt: string;
title: string;
content: string; content: string;
calloutTitle: string;
calloutText: string;
} }
export async function getNextVersions( export async function getNextVersions(
@@ -49,11 +56,11 @@ export async function getNextVersions(
return await get(endpoint, currentVersion, {}, headers); return await get(endpoint, currentVersion, {}, headers);
} }
export async function getWhatsNewArticles( export async function getWhatsNewSection(
endpoint: string, endpoint: string,
currentVersion: string, currentVersion: string,
instanceId: string, instanceId: string,
): Promise<WhatsNewArticle[]> { ): Promise<WhatsNewSection> {
const headers = { const headers = {
[INSTANCE_ID_HEADER as string]: instanceId, [INSTANCE_ID_HEADER as string]: instanceId,
[INSTANCE_VERSION_HEADER as string]: currentVersion, [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 { useI18n } from '@n8n/i18n';
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system'; import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system';
import type { IMenuItem } 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 { 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';
@@ -209,7 +209,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
icon: 'external-link-alt', icon: 'external-link-alt',
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'), label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
link: { link: {
href: 'https://docs.n8n.io/release-notes/', href: RELEASE_NOTES_URL,
target: '_blank', target: '_blank',
}, },
size: 'small', size: 'small',

View File

@@ -77,7 +77,14 @@ describe('WhatsNewModal', () => {
versionsStore.currentVersion = currentVersion; versionsStore.currentVersion = currentVersion;
versionsStore.latestVersion = currentVersion; versionsStore.latestVersion = currentVersion;
versionsStore.nextVersions = []; versionsStore.nextVersions = [];
versionsStore.whatsNewArticles = [ 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, id: 1,
title: 'Convert to sub-workflow', title: 'Convert to sub-workflow',
@@ -116,13 +123,12 @@ describe('WhatsNewModal', () => {
'- **Webhook Timeout Handling** Fixed timeout issues with long-running webhook requests\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' + '- **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', '- **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', createdAt: '2025-06-19T12:35:14.454Z',
updatedAt: '2025-06-19T12:41:53.220Z', updatedAt: '2025-06-19T12:41:53.220Z',
publishedAt: '2025-06-19T12:41:53.216Z', publishedAt: '2025-06-19T12:41:53.216Z',
}, },
]; ],
};
}); });
afterEach(() => { afterEach(() => {
@@ -140,10 +146,10 @@ describe('WhatsNewModal', () => {
}); });
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument()); 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(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(getByTestId('whats-new-modal-update-button')).toBeDisabled();
expect(queryByTestId('whats-new-modal-next-versions-link')).not.toBeInTheDocument(); 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('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-update-button')).toBeEnabled();
expect(getByTestId('whats-new-modal-next-versions-link')).toBeInTheDocument(); 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('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')); 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('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')); await userEvent.click(getByTestId('whats-new-modal-next-versions-link'));

View File

@@ -1,17 +1,11 @@
<script setup lang="ts"> <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 dateformat from 'dateformat';
import { useI18n } from '@n8n/i18n'; 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 { N8nCallout, N8nHeading, N8nIcon, N8nLink, N8nMarkdown, N8nText } from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { useVersionsStore } from '@/stores/versions.store'; import { useVersionsStore } from '@/stores/versions.store';
import { computed, onMounted, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@@ -23,10 +17,9 @@ const props = defineProps<{
}; };
}>(); }>();
const articleRefs = ref<Record<number, HTMLElement>>({});
const pageRedirectionHelper = usePageRedirectionHelper(); const pageRedirectionHelper = usePageRedirectionHelper();
const scroller = ref<RecycleScrollerInstance>();
const i18n = useI18n(); const i18n = useI18n();
const modalBus = createEventBus(); const modalBus = createEventBus();
const versionsStore = useVersionsStore(); const versionsStore = useVersionsStore();
@@ -47,23 +40,32 @@ const onUpdateClick = async () => {
await pageRedirectionHelper.goToVersions(); 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', () => { modalBus.on('opened', () => {
const articleIndex = versionsStore.whatsNewArticles.findIndex( versionsStore.closeWhatsNewCallout();
(article) => article.id === props.data.articleId,
);
scroller.value?.scrollToItem(articleIndex);
});
onMounted(() => {
// Mark all items as read when the modal is opened. // 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. // but we only want to show the new ones as unread on the main sidebar.
for (const item of versionsStore.whatsNewArticles) { for (const item of versionsStore.whatsNewArticles) {
if (!versionsStore.isWhatsNewArticleRead(item.id)) { if (!versionsStore.isWhatsNewArticleRead(item.id)) {
versionsStore.setWhatsNewArticleRead(item.id); versionsStore.setWhatsNewArticleRead(item.id);
} }
} }
void scrollToItem(props.data.articleId);
}); });
</script> </script>
@@ -82,13 +84,7 @@ onMounted(() => {
<N8nIcon :icon="'bell'" :color="'primary'" :size="'large'" /> <N8nIcon :icon="'bell'" :color="'primary'" :size="'large'" />
<div :class="$style.column"> <div :class="$style.column">
<N8nHeading size="xlarge"> <N8nHeading size="xlarge">
{{ {{ versionsStore.whatsNew.title }}
i18n.baseText('whatsNew.modal.title', {
interpolate: {
version: versionsStore.latestVersion.name,
},
})
}}
</N8nHeading> </N8nHeading>
<div :class="$style.row"> <div :class="$style.row">
@@ -127,16 +123,8 @@ onMounted(() => {
</template> </template>
<template #content> <template #content>
<div :class="$style.container"> <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 <N8nCallout
v-if="versionsStore.hasVersionUpdates" v-if="versionsStore.hasSignificantUpdates"
:class="$style.callout" :class="$style.callout"
theme="warning" theme="warning"
> >
@@ -155,7 +143,7 @@ onMounted(() => {
:size="'small'" :size="'small'"
:underline="true" :underline="true"
theme="primary" theme="primary"
to="https://docs.n8n.io/release-notes/" :to="RELEASE_NOTES_URL"
target="_blank" target="_blank"
> >
{{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }} {{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }}
@@ -163,15 +151,17 @@ onMounted(() => {
</N8nText> </N8nText>
</slot> </slot>
</N8nCallout> </N8nCallout>
</template> <div
<template #default="{ item, index, active }"> v-for="item in versionsStore.whatsNewArticles"
<DynamicScrollerItem :ref="
:item="item" (el: any) => {
:active="active" if (el) articleRefs[item.id] = el as HTMLElement;
:size-dependencies="[item.content]" }
:data-index="index" "
:key="item.id"
:class="$style.article"
:data-test-id="`whats-new-item-${item.id}`"
> >
<div :class="$style.article" :data-test-id="`whats-new-article-${item.id}`">
<N8nHeading bold tag="h2" size="xlarge"> <N8nHeading bold tag="h2" size="xlarge">
{{ item.title }} {{ item.title }}
</N8nHeading> </N8nHeading>
@@ -201,9 +191,31 @@ onMounted(() => {
}" }"
/> />
</div> </div>
</DynamicScrollerItem> <N8nMarkdown
</template> :content="versionsStore.whatsNew.footer"
</DynamicScroller> :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> </div>
</template> </template>
</Modal> </Modal>

View File

@@ -3,7 +3,7 @@
exports[`WhatsNewModal > should render with update button disabled 1`] = ` exports[`WhatsNewModal > should render with update button disabled 1`] = `
<div <div
class="article" class="article"
data-test-id="whats-new-article-1" data-test-id="whats-new-item-1"
> >
<h2 <h2
class="n8n-heading size-xlarge bold" 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 = 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 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 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';
export const RELEASE_NOTES_URL = 'https://docs.n8n.io/release-notes/';
export const HIRING_BANNER = ` export const HIRING_BANNER = `
////// //////

View File

@@ -174,6 +174,44 @@
.el-notification { .el-notification {
border-radius: 4px; border-radius: 4px;
border: none; 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 { .el-notification__content {

View File

@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia';
import { useVersionsStore } from './versions.store'; import { useVersionsStore } from './versions.store';
import * as versionsApi from '@n8n/rest-api-client/api/versions'; import * as versionsApi from '@n8n/rest-api-client/api/versions';
import type { IVersionNotificationSettings } from '@n8n/api-types'; 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 { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from './settings.store'; import { useSettingsStore } from './settings.store';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@@ -44,13 +44,20 @@ const whatsNewArticle: WhatsNewArticle = {
id: 1, id: 1,
title: 'Test article', title: 'Test article',
content: 'Some markdown content here', content: 'Some markdown content here',
calloutTitle: 'Callout title',
calloutText: 'Callout text.',
createdAt: '2025-06-19T12:37:54.885Z', createdAt: '2025-06-19T12:37:54.885Z',
updatedAt: '2025-06-19T12:41:44.919Z', updatedAt: '2025-06-19T12:41:44.919Z',
publishedAt: '2025-06-19T12:41:44.914Z', 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(); const toast = useToast();
describe('versions.store', () => { describe('versions.store', () => {
@@ -102,7 +109,7 @@ describe('versions.store', () => {
describe('fetchWhatsNew()', () => { describe('fetchWhatsNew()', () => {
it("should fetch What's new articles", async () => { it("should fetch What's new articles", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]); vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
const rootStore = useRootStore(); const rootStore = useRootStore();
rootStore.setVersionCli(currentVersionName); rootStore.setVersionCli(currentVersionName);
@@ -113,7 +120,7 @@ describe('versions.store', () => {
await versionsStore.fetchWhatsNew(); await versionsStore.fetchWhatsNew();
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith( expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
settings.whatsNewEndpoint, settings.whatsNewEndpoint,
currentVersionName, currentVersionName,
instanceId, instanceId,
@@ -123,7 +130,7 @@ describe('versions.store', () => {
}); });
it("should not fetch What's new articles if version notifications are disabled", async () => { 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(); const versionsStore = useVersionsStore();
versionsStore.initialize({ versionsStore.initialize({
@@ -133,12 +140,12 @@ describe('versions.store', () => {
await versionsStore.fetchWhatsNew(); await versionsStore.fetchWhatsNew();
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled(); expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
expect(versionsStore.whatsNewArticles).toEqual([]); expect(versionsStore.whatsNewArticles).toEqual([]);
}); });
it("should not fetch What's new articles if not enabled", async () => { it("should not fetch What's new articles if not enabled", async () => {
vi.spyOn(versionsApi, 'getWhatsNewArticles'); vi.spyOn(versionsApi, 'getWhatsNewSection');
const versionsStore = useVersionsStore(); const versionsStore = useVersionsStore();
versionsStore.initialize({ versionsStore.initialize({
@@ -149,14 +156,14 @@ describe('versions.store', () => {
await versionsStore.fetchWhatsNew(); await versionsStore.fetchWhatsNew();
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled(); expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
expect(versionsStore.whatsNewArticles).toEqual([]); expect(versionsStore.whatsNewArticles).toEqual([]);
}); });
}); });
describe('checkForNewVersions()', () => { describe('checkForNewVersions()', () => {
it('should check for new versions', async () => { 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]); vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
const rootStore = useRootStore(); const rootStore = useRootStore();
@@ -168,7 +175,7 @@ describe('versions.store', () => {
await versionsStore.checkForNewVersions(); await versionsStore.checkForNewVersions();
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith( expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
settings.whatsNewEndpoint, settings.whatsNewEndpoint,
currentVersionName, currentVersionName,
instanceId, instanceId,
@@ -187,7 +194,7 @@ describe('versions.store', () => {
}); });
it("should still initialize versions if what's new articles fail", async () => { 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]); vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
const rootStore = useRootStore(); const rootStore = useRootStore();
@@ -211,7 +218,7 @@ describe('versions.store', () => {
}); });
it("should still initialize what's new articles if versions fail", async () => { 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')); vi.spyOn(versionsApi, 'getNextVersions').mockRejectedValueOnce(new Error('oopsie'));
const rootStore = useRootStore(); const rootStore = useRootStore();
@@ -235,7 +242,7 @@ describe('versions.store', () => {
}); });
it('should show toast if important version updates are available', async () => { 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([ vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([
{ {
...currentVersion, ...currentVersion,
@@ -351,4 +358,150 @@ describe('versions.store', () => {
expect(versionsStore.latestVersion.name).toBe(currentVersionName); 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 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 { 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 { 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 { defineStore } from 'pinia';
import type { NotificationHandle } from 'element-plus';
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';
@@ -11,9 +17,17 @@ import { computed, ref } from 'vue';
import { useSettingsStore } from './settings.store'; import { useSettingsStore } from './settings.store';
import { useStorage } from '@/composables/useStorage'; import { useStorage } from '@/composables/useStorage';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
import { useTelemetry } from '@/composables/useTelemetry';
type SetVersionParams = { versions: Version[]; currentVersion: string }; 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, () => { export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
const versionNotificationSettings = ref<IVersionNotificationSettings>({ const versionNotificationSettings = ref<IVersionNotificationSettings>({
enabled: false, enabled: false,
@@ -24,12 +38,22 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
}); });
const nextVersions = ref<Version[]>([]); const nextVersions = ref<Version[]>([]);
const currentVersion = ref<Version | undefined>(); 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 uiStore = useUIStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES); const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES);
const lastDismissedWhatsNewCalloutStorage = useStorage(LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// #region Computed // #region Computed
@@ -39,6 +63,29 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
return settingsStore.settings.releaseChannel === 'stable' && nextVersions.value.length > 0; 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(() => { const latestVersion = computed(() => {
return nextVersions.value[0] ?? currentVersion.value; 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 // #endregion
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -81,25 +138,8 @@ 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[]) => { const setWhatsNew = (section: WhatsNewSection) => {
whatsNewArticles.value = article; whatsNew.value = section;
};
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) => { const setWhatsNewArticleRead = (articleId: number) => {
@@ -115,6 +155,62 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
return readWhatsNewArticles.value.includes(articleId); 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) => { const initialize = (settings: IVersionNotificationSettings) => {
versionNotificationSettings.value = settings; versionNotificationSettings.value = settings;
}; };
@@ -160,6 +256,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
latestVersion, latestVersion,
nextVersions, nextVersions,
hasVersionUpdates, hasVersionUpdates,
hasSignificantUpdates,
areNotificationsEnabled, areNotificationsEnabled,
infoUrl, infoUrl,
fetchVersions, fetchVersions,
@@ -167,8 +264,10 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
initialize, initialize,
checkForNewVersions, checkForNewVersions,
fetchWhatsNew, fetchWhatsNew,
whatsNew,
whatsNewArticles, whatsNewArticles,
isWhatsNewArticleRead, isWhatsNewArticleRead,
setWhatsNewArticleRead, setWhatsNewArticleRead,
closeWhatsNewCallout,
}; };
}); });