mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(editor): Add What's New notification callout (#16718)
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -77,52 +77,58 @@ 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',
|
||||||
id: 1,
|
updatedAt: null,
|
||||||
title: 'Convert to sub-workflow',
|
title: "What's New in n8n 1.100.0",
|
||||||
content:
|
calloutText:
|
||||||
'Large, monolithic workflows can slow things down. They’re harder to maintain, ' +
|
'Convert large workflows into sub-workflows for better modularity and performance.',
|
||||||
'tougher to debug, and more difficult to scale. With sub-workflows, you can take a ' +
|
footer: 'This release contains performance improvements and bug fixes.',
|
||||||
'more modular approach, breaking up big workflows into smaller, manageable parts that ' +
|
items: [
|
||||||
'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 ' +
|
id: 1,
|
||||||
'reconnecting everything by hand. **Convert to sub-workflow** allows you to simplify this ' +
|
title: 'Convert to sub-workflow',
|
||||||
'process into a single action, so you can spend more time building and less time restructuring.\n\n' +
|
content:
|
||||||
'### How it works\n\n1. Highlight the nodes you want to convert to a sub-workflow. These must:\n' +
|
'Large, monolithic workflows can slow things down. They’re harder to maintain, ' +
|
||||||
' - Be fully connected, meaning no missing steps in between them\n' +
|
'tougher to debug, and more difficult to scale. With sub-workflows, you can take a ' +
|
||||||
' - Start from a single starting node\n' +
|
'more modular approach, breaking up big workflows into smaller, manageable parts that ' +
|
||||||
' - End with a single node\n' +
|
'are easier to reuse, test, understand, and explain.\n\nUntil now, creating sub-workflows ' +
|
||||||
'2. Right-click to open the context menu and select ' +
|
'required copying and pasting nodes manually, setting up a new workflow from scratch, and ' +
|
||||||
'**Convert to sub-workflow**\n' +
|
'reconnecting everything by hand. **Convert to sub-workflow** allows you to simplify this ' +
|
||||||
' - Or use the shortcut: `Alt + X`\n' +
|
'process into a single action, so you can spend more time building and less time restructuring.\n\n' +
|
||||||
'3. n8n will:\n' +
|
'### How it works\n\n1. Highlight the nodes you want to convert to a sub-workflow. These must:\n' +
|
||||||
' - Open a new tab containing the selected nodes\n' +
|
' - Be fully connected, meaning no missing steps in between them\n' +
|
||||||
' - Preserve all node parameters as-is\n' +
|
' - Start from a single starting node\n' +
|
||||||
' - Replace the selected nodes in the original workflow with a **Call My Sub-workflow** node\n\n' +
|
' - End with a single node\n' +
|
||||||
'_Note:_ You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.\n\n' +
|
'2. Right-click to open the context menu and select ' +
|
||||||
'This makes it easier to keep workflows modular, performant, and easier to maintain.\n\n' +
|
'**Convert to sub-workflow**\n' +
|
||||||
'Learn more about [sub-workflows](https://docs.n8n.io/flow-logic/subworkflows/).\n\n' +
|
' - Or use the shortcut: `Alt + X`\n' +
|
||||||
'This release contains performance improvements and bug fixes.\n\n' +
|
'3. n8n will:\n' +
|
||||||
'@[youtube](ZCuL2e4zC_4)\n\n' +
|
' - Open a new tab containing the selected nodes\n' +
|
||||||
'Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.\n\n' +
|
' - Preserve all node parameters as-is\n' +
|
||||||
'## Second level title\n\n### Third level title\n\nThis **is bold**, this _in italics_.\n' +
|
' - Replace the selected nodes in the original workflow with a **Call My Sub-workflow** node\n\n' +
|
||||||
"~~Strikethrough is also something we support~~.\n\nHere's a peace of code:\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' +
|
||||||
'```typescript\nconst props = defineProps<{\n\tmodalName: string;\n\tdata: {\n\t\tarticleId: number;\n\t};\n}>();\n```\n\n' +
|
'This makes it easier to keep workflows modular, performant, and easier to maintain.\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' +
|
'Learn more about [sub-workflows](https://docs.n8n.io/flow-logic/subworkflows/).\n\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' +
|
'This release contains performance improvements and bug fixes.\n\n' +
|
||||||
'<details>\n<summary>Fixes (4)</summary>\n\n' +
|
'@[youtube](ZCuL2e4zC_4)\n\n' +
|
||||||
'- **Credential Storage Issue** Resolved an issue where credentials would occasionally become inaccessible after server restarts\n' +
|
'Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.\n\n' +
|
||||||
'- **Webhook Timeout Handling** Fixed timeout issues with long-running webhook requests\n' +
|
'## Second level title\n\n### Third level title\n\nThis **is bold**, this _in italics_.\n' +
|
||||||
'- **Node Connection Validation** Improved validation for node connections to prevent invalid workflow configurations\n' +
|
"~~Strikethrough is also something we support~~.\n\nHere's a peace of code:\n\n" +
|
||||||
'- **Memory Leak in Execution Engine** Fixed memory leak that could occur during long-running workflow executions\n\n</details>\n\n',
|
'```typescript\nconst props = defineProps<{\n\tmodalName: string;\n\tdata: {\n\t\tarticleId: number;\n\t};\n}>();\n```\n\n' +
|
||||||
calloutTitle: 'Convert to sub-workflow',
|
'Inline `code also works` withing text.\n\nThis is a list:\n- first\n- second\n- third\n\nAnd this list is ordered\n' +
|
||||||
calloutText: 'Simplify process of extracting nodes into a single action',
|
'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' +
|
||||||
createdAt: '2025-06-19T12:35:14.454Z',
|
'<details>\n<summary>Fixes (4)</summary>\n\n' +
|
||||||
updatedAt: '2025-06-19T12:41:53.220Z',
|
'- **Credential Storage Issue** Resolved an issue where credentials would occasionally become inaccessible after server restarts\n' +
|
||||||
publishedAt: '2025-06-19T12:41:53.216Z',
|
'- **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(() => {
|
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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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,83 +123,99 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container">
|
||||||
<DynamicScroller
|
<N8nCallout
|
||||||
ref="scroller"
|
v-if="versionsStore.hasSignificantUpdates"
|
||||||
:min-item-size="10"
|
:class="$style.callout"
|
||||||
:items="versionsStore.whatsNewArticles"
|
theme="warning"
|
||||||
class="full-height scroller"
|
|
||||||
style="max-height: 80vh"
|
|
||||||
>
|
>
|
||||||
<template #before>
|
<slot name="callout-message">
|
||||||
<N8nCallout
|
<N8nText size="small">
|
||||||
v-if="versionsStore.hasVersionUpdates"
|
{{
|
||||||
:class="$style.callout"
|
i18n.baseText('whatsNew.updateAvailable', {
|
||||||
theme="warning"
|
interpolate: {
|
||||||
>
|
currentVersion: versionsStore.currentVersion?.name ?? 'unknown',
|
||||||
<slot name="callout-message">
|
latestVersion: versionsStore.latestVersion?.name,
|
||||||
<N8nText size="small">
|
count: nextVersions.length,
|
||||||
{{
|
},
|
||||||
i18n.baseText('whatsNew.updateAvailable', {
|
})
|
||||||
interpolate: {
|
}}
|
||||||
currentVersion: versionsStore.currentVersion?.name ?? 'unknown',
|
<N8nLink
|
||||||
latestVersion: versionsStore.latestVersion?.name,
|
:size="'small'"
|
||||||
count: nextVersions.length,
|
:underline="true"
|
||||||
},
|
theme="primary"
|
||||||
})
|
:to="RELEASE_NOTES_URL"
|
||||||
}}
|
target="_blank"
|
||||||
<N8nLink
|
>
|
||||||
:size="'small'"
|
{{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }}
|
||||||
:underline="true"
|
</N8nLink>
|
||||||
theme="primary"
|
</N8nText>
|
||||||
to="https://docs.n8n.io/release-notes/"
|
</slot>
|
||||||
target="_blank"
|
</N8nCallout>
|
||||||
>
|
<div
|
||||||
{{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }}
|
v-for="item in versionsStore.whatsNewArticles"
|
||||||
</N8nLink>
|
:ref="
|
||||||
</N8nText>
|
(el: any) => {
|
||||||
</slot>
|
if (el) articleRefs[item.id] = el as HTMLElement;
|
||||||
</N8nCallout>
|
}
|
||||||
</template>
|
"
|
||||||
<template #default="{ item, index, active }">
|
:key="item.id"
|
||||||
<DynamicScrollerItem
|
:class="$style.article"
|
||||||
:item="item"
|
:data-test-id="`whats-new-item-${item.id}`"
|
||||||
:active="active"
|
>
|
||||||
:size-dependencies="[item.content]"
|
<N8nHeading bold tag="h2" size="xlarge">
|
||||||
:data-index="index"
|
{{ item.title }}
|
||||||
>
|
</N8nHeading>
|
||||||
<div :class="$style.article" :data-test-id="`whats-new-article-${item.id}`">
|
<N8nMarkdown
|
||||||
<N8nHeading bold tag="h2" size="xlarge">
|
:content="item.content"
|
||||||
{{ item.title }}
|
:class="$style.markdown"
|
||||||
</N8nHeading>
|
:options="{
|
||||||
<N8nMarkdown
|
markdown: {
|
||||||
:content="item.content"
|
html: true,
|
||||||
:class="$style.markdown"
|
linkify: true,
|
||||||
:options="{
|
typographer: true,
|
||||||
markdown: {
|
breaks: true,
|
||||||
html: true,
|
},
|
||||||
linkify: true,
|
tasklists: {
|
||||||
typographer: true,
|
enabled: false,
|
||||||
breaks: true,
|
},
|
||||||
},
|
linkAttributes: {
|
||||||
tasklists: {
|
attrs: {
|
||||||
enabled: false,
|
target: '_blank',
|
||||||
},
|
rel: 'noopener',
|
||||||
linkAttributes: {
|
},
|
||||||
attrs: {
|
},
|
||||||
target: '_blank',
|
youtube: {
|
||||||
rel: 'noopener',
|
width: '100%',
|
||||||
},
|
height: '315',
|
||||||
},
|
},
|
||||||
youtube: {
|
}"
|
||||||
width: '100%',
|
/>
|
||||||
height: '315',
|
</div>
|
||||||
},
|
<N8nMarkdown
|
||||||
}"
|
:content="versionsStore.whatsNew.footer"
|
||||||
/>
|
:class="$style.markdown"
|
||||||
</div>
|
:options="{
|
||||||
</DynamicScrollerItem>
|
markdown: {
|
||||||
</template>
|
html: true,
|
||||||
</DynamicScroller>
|
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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = `
|
||||||
//////
|
//////
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user