mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Add 'Whats new' section and modal (#16664)
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
||||
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system';
|
||||
import type { IMenuItem } from '@n8n/design-system';
|
||||
import { ABOUT_MODAL_KEY, VIEWS, WHATS_NEW_MODAL_KEY } from '@/constants';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@@ -11,23 +17,16 @@ import { useUsersStore } from '@/stores/users.store';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useUserHelpers } from '@/composables/useUserHelpers';
|
||||
|
||||
import { ABOUT_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS } from '@/constants';
|
||||
import { useBugReporting } from '@/composables/useBugReporting';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system';
|
||||
import type { IMenuItem } from '@n8n/design-system';
|
||||
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||
import Logo from './Logo/Logo.vue';
|
||||
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
||||
import Logo from '@/components/Logo/Logo.vue';
|
||||
import VersionUpdateCTA from '@/components/VersionUpdateCTA.vue';
|
||||
|
||||
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
||||
const cloudPlanStore = useCloudPlanStore();
|
||||
@@ -68,6 +67,14 @@ const userMenuItems = ref([
|
||||
},
|
||||
]);
|
||||
|
||||
const showWhatsNewNotification = computed(
|
||||
() =>
|
||||
versionsStore.hasVersionUpdates ||
|
||||
versionsStore.whatsNewArticles.some(
|
||||
(article) => !versionsStore.isWhatsNewArticleRead(article.id),
|
||||
),
|
||||
);
|
||||
|
||||
const mainMenuItems = computed<IMenuItem[]>(() => [
|
||||
{
|
||||
id: 'cloud-admin',
|
||||
@@ -175,16 +182,52 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'whats-new',
|
||||
icon: 'bell',
|
||||
notification: showWhatsNewNotification.value,
|
||||
label: i18n.baseText('mainSidebar.whatsNew'),
|
||||
position: 'bottom',
|
||||
available: versionsStore.hasVersionUpdates || versionsStore.whatsNewArticles.length > 0,
|
||||
children: [
|
||||
...versionsStore.whatsNewArticles.map(
|
||||
(article) =>
|
||||
({
|
||||
id: `whats-new-article-${article.id}`,
|
||||
label: article.title,
|
||||
size: 'small',
|
||||
customIconSize: 'small',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
value: '•',
|
||||
color: !versionsStore.isWhatsNewArticleRead(article.id) ? 'primary' : 'text-light',
|
||||
},
|
||||
}) satisfies IMenuItem,
|
||||
),
|
||||
{
|
||||
id: 'full-changelog',
|
||||
icon: 'external-link-alt',
|
||||
label: i18n.baseText('mainSidebar.whatsNew.fullChangelog'),
|
||||
link: {
|
||||
href: 'https://docs.n8n.io/release-notes/',
|
||||
target: '_blank',
|
||||
},
|
||||
size: 'small',
|
||||
customIconSize: 'small',
|
||||
},
|
||||
{
|
||||
id: 'version-upgrade-cta',
|
||||
component: VersionUpdateCTA,
|
||||
available: versionsStore.hasVersionUpdates,
|
||||
props: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
|
||||
|
||||
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
|
||||
|
||||
const hasVersionUpdates = computed(
|
||||
() => settingsStore.settings.releaseChannel === 'stable' && versionsStore.hasVersionUpdates,
|
||||
);
|
||||
|
||||
const nextVersions = computed(() => versionsStore.nextVersions);
|
||||
const showUserArea = computed(() => hasPermission(['authenticated']));
|
||||
const userIsTrialing = computed(() => cloudPlanStore.userIsTrialing);
|
||||
|
||||
@@ -250,10 +293,6 @@ const toggleCollapse = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openUpdatesPanel = () => {
|
||||
uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||
};
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
switch (key) {
|
||||
case 'templates':
|
||||
@@ -280,6 +319,20 @@ const handleSelect = (key: string) => {
|
||||
case 'insights':
|
||||
telemetry.track('User clicked insights link from side menu');
|
||||
default:
|
||||
if (key.startsWith('whats-new-article-')) {
|
||||
const articleId = Number(key.replace('whats-new-article-', ''));
|
||||
|
||||
telemetry.track("User clicked on what's new section", {
|
||||
article_id: articleId,
|
||||
});
|
||||
uiStore.openModalWithData({
|
||||
name: WHATS_NEW_MODAL_KEY,
|
||||
data: {
|
||||
articleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -432,24 +485,6 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||
</template>
|
||||
<template #menuSuffix>
|
||||
<div>
|
||||
<div
|
||||
v-if="hasVersionUpdates"
|
||||
data-test-id="version-updates-panel-button"
|
||||
:class="$style.updates"
|
||||
@click="openUpdatesPanel"
|
||||
>
|
||||
<div :class="$style.giftContainer">
|
||||
<GiftNotificationIcon />
|
||||
</div>
|
||||
<N8nText
|
||||
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
||||
color="text-base"
|
||||
>
|
||||
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
||||
nextVersions.length > 1 ? 's' : ''
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
||||
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||
WHATS_NEW_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from '@/components/AboutModal.vue';
|
||||
@@ -323,5 +324,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
<WorkflowExtractionNameModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="WHATS_NEW_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<WhatsNewModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { WHATS_NEW_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import type { Version } from '@n8n/rest-api-client/api/versions';
|
||||
|
||||
import VersionUpdateCTA from './VersionUpdateCTA.vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||
const goToVersions = vi.fn();
|
||||
return {
|
||||
usePageRedirectionHelper: vi.fn().mockReturnValue({
|
||||
goToVersions,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => {
|
||||
const track = vi.fn();
|
||||
return {
|
||||
useTelemetry: () => {
|
||||
return {
|
||||
track,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(VersionUpdateCTA, {
|
||||
props: {},
|
||||
});
|
||||
|
||||
let uiStore: MockedStore<typeof useUIStore>;
|
||||
let versionsStore: MockedStore<typeof useVersionsStore>;
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const version: Version = {
|
||||
name: '1.100.0',
|
||||
nodes: [],
|
||||
createdAt: '2025-06-24T00:00:00Z',
|
||||
description: 'Latest version description',
|
||||
documentationUrl: 'https://docs.n8n.io',
|
||||
hasBreakingChange: false,
|
||||
hasSecurityFix: false,
|
||||
hasSecurityIssue: false,
|
||||
securityIssueFixVersion: '',
|
||||
};
|
||||
|
||||
describe('VersionUpdateCTA', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.modalsById = {
|
||||
[WHATS_NEW_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
versionsStore = mockedStore(useVersionsStore);
|
||||
versionsStore.nextVersions = [version];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('version-update-cta-button')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should take user to update page when Update is clicked', async () => {
|
||||
versionsStore.hasVersionUpdates = true;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('version-update-cta-button')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(getByTestId('version-update-cta-button'));
|
||||
|
||||
expect(pageRedirectionHelper.goToVersions).toHaveBeenCalled();
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User clicked on update button', {
|
||||
source: 'main-sidebar',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open the next versions drawer when clicking on the next versions link', async () => {
|
||||
versionsStore.hasVersionUpdates = true;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('version-update-next-versions-link')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(getByTestId('version-update-next-versions-link'));
|
||||
|
||||
expect(uiStore.openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { N8nButton, N8nLink } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { VERSIONS_MODAL_KEY } from '@/constants';
|
||||
|
||||
const i18n = useI18n();
|
||||
const versionsStore = useVersionsStore();
|
||||
const uiStore = useUIStore();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const openUpdatesPanel = () => {
|
||||
uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||
};
|
||||
|
||||
const onUpdateClick = async () => {
|
||||
telemetry.track('User clicked on update button', {
|
||||
source: 'main-sidebar',
|
||||
});
|
||||
|
||||
await pageRedirectionHelper.goToVersions();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nLink
|
||||
size="small"
|
||||
theme="text"
|
||||
data-test-id="version-update-next-versions-link"
|
||||
@click="openUpdatesPanel"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('whatsNew.versionsBehind', {
|
||||
interpolate: {
|
||||
count:
|
||||
versionsStore.nextVersions.length > 99 ? '99+' : versionsStore.nextVersions.length,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</N8nLink>
|
||||
|
||||
<N8nButton
|
||||
:class="$style.button"
|
||||
:label="i18n.baseText('whatsNew.update')"
|
||||
data-test-id="version-update-cta-button"
|
||||
size="mini"
|
||||
@click="onUpdateClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2xs);
|
||||
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||
margin-left: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-3xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
border: var(--border-base);
|
||||
background: var(--color-background-light-base);
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
223
packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts
Normal file
223
packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor, screen } from '@testing-library/vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import {
|
||||
cleanupAppModals,
|
||||
createAppModals,
|
||||
mockedStore,
|
||||
type MockedStore,
|
||||
} from '@/__tests__/utils';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { WHATS_NEW_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import type { Version } from '@n8n/rest-api-client/api/versions';
|
||||
|
||||
import WhatsNewModal from './WhatsNewModal.vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||
const goToVersions = vi.fn();
|
||||
return {
|
||||
usePageRedirectionHelper: vi.fn().mockReturnValue({
|
||||
goToVersions,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => {
|
||||
const track = vi.fn();
|
||||
return {
|
||||
useTelemetry: () => {
|
||||
return {
|
||||
track,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(WhatsNewModal, {
|
||||
props: {
|
||||
modalName: WHATS_NEW_MODAL_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
let uiStore: MockedStore<typeof useUIStore>;
|
||||
let versionsStore: MockedStore<typeof useVersionsStore>;
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const currentVersion: Version = {
|
||||
name: '1.100.0',
|
||||
nodes: [],
|
||||
createdAt: '2025-06-24T00:00:00Z',
|
||||
description: 'Latest version description',
|
||||
documentationUrl: 'https://docs.n8n.io',
|
||||
hasBreakingChange: false,
|
||||
hasSecurityFix: false,
|
||||
hasSecurityIssue: false,
|
||||
securityIssueFixVersion: '',
|
||||
};
|
||||
|
||||
describe('WhatsNewModal', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
createTestingPinia();
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.modalsById = {
|
||||
[WHATS_NEW_MODAL_KEY]: {
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
versionsStore = mockedStore(useVersionsStore);
|
||||
versionsStore.hasVersionUpdates = false;
|
||||
versionsStore.currentVersion = currentVersion;
|
||||
versionsStore.latestVersion = currentVersion;
|
||||
versionsStore.nextVersions = [];
|
||||
versionsStore.whatsNewArticles = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Convert to sub-workflow',
|
||||
content:
|
||||
'Large, monolithic workflows can slow things down. They’re harder to maintain, ' +
|
||||
'tougher to debug, and more difficult to scale. With sub-workflows, you can take a ' +
|
||||
'more modular approach, breaking up big workflows into smaller, manageable parts that ' +
|
||||
'are easier to reuse, test, understand, and explain.\n\nUntil now, creating sub-workflows ' +
|
||||
'required copying and pasting nodes manually, setting up a new workflow from scratch, and ' +
|
||||
'reconnecting everything by hand. **Convert to sub-workflow** allows you to simplify this ' +
|
||||
'process into a single action, so you can spend more time building and less time restructuring.\n\n' +
|
||||
'### How it works\n\n1. Highlight the nodes you want to convert to a sub-workflow. These must:\n' +
|
||||
' - Be fully connected, meaning no missing steps in between them\n' +
|
||||
' - Start from a single starting node\n' +
|
||||
' - End with a single node\n' +
|
||||
'2. Right-click to open the context menu and select ' +
|
||||
'**Convert to sub-workflow**\n' +
|
||||
' - Or use the shortcut: `Alt + X`\n' +
|
||||
'3. n8n will:\n' +
|
||||
' - Open a new tab containing the selected nodes\n' +
|
||||
' - Preserve all node parameters as-is\n' +
|
||||
' - Replace the selected nodes in the original workflow with a **Call My Sub-workflow** node\n\n' +
|
||||
'_Note:_ You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.\n\n' +
|
||||
'This makes it easier to keep workflows modular, performant, and easier to maintain.\n\n' +
|
||||
'Learn more about [sub-workflows](https://docs.n8n.io/flow-logic/subworkflows/).\n\n' +
|
||||
'This release contains performance improvements and bug fixes.\n\n' +
|
||||
'@[youtube](ZCuL2e4zC_4)\n\n' +
|
||||
'Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.\n\n' +
|
||||
'## Second level title\n\n### Third level title\n\nThis **is bold**, this _in italics_.\n' +
|
||||
"~~Strikethrough is also something we support~~.\n\nHere's a peace of code:\n\n" +
|
||||
'```typescript\nconst props = defineProps<{\n\tmodalName: string;\n\tdata: {\n\t\tarticleId: number;\n\t};\n}>();\n```\n\n' +
|
||||
'Inline `code also works` withing text.\n\nThis is a list:\n- first\n- second\n- third\n\nAnd this list is ordered\n' +
|
||||
'1. foo\n2. bar\n3. qux\n\nDividers:\n\nThree or more...\n\n---\n\nHyphens\n\n***\n\nAsterisks\n\n___\n\nUnderscores\n\n---\n\n' +
|
||||
'<details>\n<summary>Fixes (4)</summary>\n\n' +
|
||||
'- **Credential Storage Issue** Resolved an issue where credentials would occasionally become inaccessible after server restarts\n' +
|
||||
'- **Webhook Timeout Handling** Fixed timeout issues with long-running webhook requests\n' +
|
||||
'- **Node Connection Validation** Improved validation for node connections to prevent invalid workflow configurations\n' +
|
||||
'- **Memory Leak in Execution Engine** Fixed memory leak that could occur during long-running workflow executions\n\n</details>\n\n',
|
||||
calloutTitle: 'Convert to sub-workflow',
|
||||
calloutText: 'Simplify process of extracting nodes into a single action',
|
||||
createdAt: '2025-06-19T12:35:14.454Z',
|
||||
updatedAt: '2025-06-19T12:41:53.220Z',
|
||||
publishedAt: '2025-06-19T12:41:53.216Z',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with update button disabled', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByText("What's New in n8n 1.100.0")).toBeInTheDocument();
|
||||
expect(getByTestId('whats-new-article-1')).toMatchSnapshot();
|
||||
expect(getByTestId('whats-new-modal-update-button')).toBeDisabled();
|
||||
expect(queryByTestId('whats-new-modal-next-versions-link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with update button enabled', async () => {
|
||||
versionsStore.hasVersionUpdates = true;
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
name: '1.100.1',
|
||||
nodes: [],
|
||||
createdAt: '2025-06-24T00:00:00Z',
|
||||
description: 'Next version description',
|
||||
documentationUrl: 'https://docs.n8n.io',
|
||||
hasBreakingChange: false,
|
||||
hasSecurityFix: false,
|
||||
hasSecurityIssue: false,
|
||||
securityIssueFixVersion: '',
|
||||
},
|
||||
];
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||
|
||||
expect(getByTestId('whats-new-modal-update-button')).toBeEnabled();
|
||||
expect(getByTestId('whats-new-modal-next-versions-link')).toBeInTheDocument();
|
||||
expect(getByTestId('whats-new-modal-next-versions-link')).toHaveTextContent('1 version behind');
|
||||
});
|
||||
|
||||
it('should take user to update page when Update is clicked', async () => {
|
||||
versionsStore.hasVersionUpdates = true;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(getByTestId('whats-new-modal-update-button'));
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User clicked on update button', {
|
||||
source: 'whats-new-modal',
|
||||
});
|
||||
expect(pageRedirectionHelper.goToVersions).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should open the next versions drawer when clicking on the next versions link', async () => {
|
||||
versionsStore.hasVersionUpdates = true;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
articleId: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
|
||||
await waitFor(() => expect(getByTestId('whats-new-article-1')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(getByTestId('whats-new-modal-next-versions-link'));
|
||||
|
||||
expect(uiStore.openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
|
||||
});
|
||||
});
|
||||
263
packages/frontend/editor-ui/src/components/WhatsNewModal.vue
Normal file
263
packages/frontend/editor-ui/src/components/WhatsNewModal.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DynamicScroller,
|
||||
DynamicScrollerItem,
|
||||
type RecycleScrollerInstance,
|
||||
} from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
import dateformat from 'dateformat';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { VERSIONS_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/constants';
|
||||
import { N8nCallout, N8nHeading, N8nIcon, N8nLink, N8nMarkdown, N8nText } from '@n8n/design-system';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
articleId: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const scroller = ref<RecycleScrollerInstance>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const modalBus = createEventBus();
|
||||
const versionsStore = useVersionsStore();
|
||||
const uiStore = useUIStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const nextVersions = computed(() => versionsStore.nextVersions);
|
||||
|
||||
const openUpdatesPanel = () => {
|
||||
uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||
};
|
||||
|
||||
const onUpdateClick = async () => {
|
||||
telemetry.track('User clicked on update button', {
|
||||
source: 'whats-new-modal',
|
||||
});
|
||||
|
||||
await pageRedirectionHelper.goToVersions();
|
||||
};
|
||||
|
||||
modalBus.on('opened', () => {
|
||||
const articleIndex = versionsStore.whatsNewArticles.findIndex(
|
||||
(article) => article.id === props.data.articleId,
|
||||
);
|
||||
|
||||
scroller.value?.scrollToItem(articleIndex);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Mark all items as read when the modal is opened.
|
||||
// Later versions of the What's new articles might contain partially same items,
|
||||
// but we only want to show the new ones as unread on the main sidebar.
|
||||
for (const item of versionsStore.whatsNewArticles) {
|
||||
if (!versionsStore.isWhatsNewArticleRead(item.id)) {
|
||||
versionsStore.setWhatsNewArticleRead(item.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
max-width="860px"
|
||||
max-height="85vh"
|
||||
:event-bus="modalBus"
|
||||
:name="WHATS_NEW_MODAL_KEY"
|
||||
:center="true"
|
||||
:show-close="false"
|
||||
>
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.row">
|
||||
<N8nIcon :icon="'bell'" :color="'primary'" :size="'large'" />
|
||||
<div :class="$style.column">
|
||||
<N8nHeading size="xlarge">
|
||||
{{
|
||||
i18n.baseText('whatsNew.modal.title', {
|
||||
interpolate: {
|
||||
version: versionsStore.latestVersion.name,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</N8nHeading>
|
||||
|
||||
<div :class="$style.row">
|
||||
<N8nHeading size="medium" color="text-light">{{
|
||||
dateformat(versionsStore.latestVersion.createdAt, `d mmmm, yyyy`)
|
||||
}}</N8nHeading>
|
||||
<template v-if="versionsStore.hasVersionUpdates">
|
||||
<N8nText :size="'medium'" :class="$style.text" :color="'text-base'">•</N8nText>
|
||||
<N8nLink
|
||||
size="medium"
|
||||
theme="primary"
|
||||
data-test-id="whats-new-modal-next-versions-link"
|
||||
@click="openUpdatesPanel"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('whatsNew.versionsBehind', {
|
||||
interpolate: {
|
||||
count: nextVersions.length > 99 ? '99+' : nextVersions.length,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n8n-button
|
||||
:size="'large'"
|
||||
:label="i18n.baseText('whatsNew.update')"
|
||||
:disabled="!versionsStore.hasVersionUpdates"
|
||||
data-test-id="whats-new-modal-update-button"
|
||||
@click="onUpdateClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<DynamicScroller
|
||||
ref="scroller"
|
||||
:min-item-size="10"
|
||||
:items="versionsStore.whatsNewArticles"
|
||||
class="full-height scroller"
|
||||
style="max-height: 80vh"
|
||||
>
|
||||
<template #before>
|
||||
<N8nCallout
|
||||
v-if="versionsStore.hasVersionUpdates"
|
||||
:class="$style.callout"
|
||||
theme="warning"
|
||||
>
|
||||
<slot name="callout-message">
|
||||
<N8nText size="small">
|
||||
{{
|
||||
i18n.baseText('whatsNew.updateAvailable', {
|
||||
interpolate: {
|
||||
currentVersion: versionsStore.currentVersion?.name ?? 'unknown',
|
||||
latestVersion: versionsStore.latestVersion?.name,
|
||||
count: nextVersions.length,
|
||||
},
|
||||
})
|
||||
}}
|
||||
<N8nLink
|
||||
:size="'small'"
|
||||
:underline="true"
|
||||
theme="primary"
|
||||
to="https://docs.n8n.io/release-notes/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ i18n.baseText('whatsNew.updateAvailable.changelogLink') }}
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
</slot>
|
||||
</N8nCallout>
|
||||
</template>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:size-dependencies="[item.content]"
|
||||
:data-index="index"
|
||||
>
|
||||
<div :class="$style.article" :data-test-id="`whats-new-article-${item.id}`">
|
||||
<N8nHeading bold tag="h2" size="xlarge">
|
||||
{{ item.title }}
|
||||
</N8nHeading>
|
||||
<N8nMarkdown
|
||||
:content="item.content"
|
||||
:class="$style.markdown"
|
||||
:options="{
|
||||
markdown: {
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true,
|
||||
},
|
||||
tasklists: {
|
||||
enabled: false,
|
||||
},
|
||||
linkAttributes: {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
},
|
||||
youtube: {
|
||||
width: '100%',
|
||||
height: '315',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-base);
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
:global(.el-dialog__header) {
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
|
||||
.article {
|
||||
padding: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
margin: var(--spacing-s) 0;
|
||||
|
||||
p,
|
||||
strong,
|
||||
em,
|
||||
s,
|
||||
code,
|
||||
a,
|
||||
li {
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`WhatsNewModal > should render with update button disabled 1`] = `
|
||||
<div
|
||||
class="article"
|
||||
data-test-id="whats-new-article-1"
|
||||
>
|
||||
<h2
|
||||
class="n8n-heading size-xlarge bold"
|
||||
>
|
||||
|
||||
Convert to sub-workflow
|
||||
|
||||
</h2>
|
||||
<div
|
||||
class="n8n-markdown markdown"
|
||||
>
|
||||
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="markdown"
|
||||
>
|
||||
<p>
|
||||
Large, monolithic workflows can slow things down. They’re harder to maintain, tougher to debug, and more difficult to scale. With sub-workflows, you can take a more modular approach, breaking up big workflows into smaller, manageable parts that are easier to reuse, test, understand, and explain.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Until now, creating sub-workflows required copying and pasting nodes manually, setting up a new workflow from scratch, and reconnecting everything by hand.
|
||||
<strong>
|
||||
Convert to sub-workflow
|
||||
</strong>
|
||||
allows you to simplify this process into a single action, so you can spend more time building and less time restructuring.
|
||||
</p>
|
||||
|
||||
|
||||
<h3>
|
||||
How it works
|
||||
</h3>
|
||||
|
||||
|
||||
<ol>
|
||||
|
||||
|
||||
<li>
|
||||
Highlight the nodes you want to convert to a sub-workflow. These must:
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
Be fully connected, meaning no missing steps in between them
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Start from a single starting node
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
End with a single node
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Right-click to open the context menu and select
|
||||
<strong>
|
||||
Convert to sub-workflow
|
||||
</strong>
|
||||
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
Or use the shortcut:
|
||||
<code>
|
||||
Alt + X
|
||||
</code>
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
n8n will:
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
Open a new tab containing the selected nodes
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Preserve all node parameters as-is
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Replace the selected nodes in the original workflow with a
|
||||
<strong>
|
||||
Call My Sub-workflow
|
||||
</strong>
|
||||
node
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
</ol>
|
||||
|
||||
|
||||
<p>
|
||||
<em>
|
||||
Note:
|
||||
</em>
|
||||
You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
This makes it easier to keep workflows modular, performant, and easier to maintain.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Learn more about
|
||||
<a
|
||||
href="https://docs.n8n.io/flow-logic/subworkflows/"
|
||||
target="_blank"
|
||||
>
|
||||
sub-workflows
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
This release contains performance improvements and bug fixes.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen=""
|
||||
frameborder="0"
|
||||
height="315"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4"
|
||||
title="YouTube video player"
|
||||
width="100%"
|
||||
/>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.
|
||||
</p>
|
||||
|
||||
|
||||
<h2>
|
||||
Second level title
|
||||
</h2>
|
||||
|
||||
|
||||
<h3>
|
||||
Third level title
|
||||
</h3>
|
||||
|
||||
|
||||
<p>
|
||||
This
|
||||
<strong>
|
||||
is bold
|
||||
</strong>
|
||||
, this
|
||||
<em>
|
||||
in italics
|
||||
</em>
|
||||
.
|
||||
<br />
|
||||
|
||||
|
||||
<s>
|
||||
Strikethrough is also something we support
|
||||
</s>
|
||||
.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Here’s a peace of code:
|
||||
</p>
|
||||
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
articleId: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
|
||||
<p>
|
||||
Inline
|
||||
<code>
|
||||
code also works
|
||||
</code>
|
||||
withing text.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
This is a list:
|
||||
</p>
|
||||
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
first
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
second
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
third
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
<p>
|
||||
And this list is ordered
|
||||
</p>
|
||||
|
||||
|
||||
<ol>
|
||||
|
||||
|
||||
<li>
|
||||
foo
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
bar
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
qux
|
||||
</li>
|
||||
|
||||
|
||||
</ol>
|
||||
|
||||
|
||||
<p>
|
||||
Dividers:
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Three or more…
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<p>
|
||||
Hyphens
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<p>
|
||||
Asterisks
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<p>
|
||||
Underscores
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
|
||||
<summary>
|
||||
Fixes (4)
|
||||
</summary>
|
||||
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Credential Storage Issue
|
||||
</strong>
|
||||
Resolved an issue where credentials would occasionally become inaccessible after server restarts
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Webhook Timeout Handling
|
||||
</strong>
|
||||
Fixed timeout issues with long-running webhook requests
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Node Connection Validation
|
||||
</strong>
|
||||
Improved validation for node connections to prevent invalid workflow configurations
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Memory Leak in Execution Engine
|
||||
</strong>
|
||||
Fixed memory leak that could occur during long-running workflow executions
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
Reference in New Issue
Block a user