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

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