mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +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 {
|
||||
aspect-ratio: 16/9 auto;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
||||
@@ -3231,7 +3231,6 @@
|
||||
"insights.upgradeModal.perks.1": "Zoom into last 24 hours with hourly granularity",
|
||||
"insights.upgradeModal.perks.2": "Gain deeper visibility into workflow trends over time",
|
||||
"insights.upgradeModal.title": "Upgrade to Enterprise",
|
||||
"whatsNew.modal.title": "What's New in n8n {version}",
|
||||
"whatsNew.versionsBehind": "{count} version behind | {count} versions behind",
|
||||
"whatsNew.update": "Update",
|
||||
"whatsNew.updateAvailable": "You're currently on version {currentVersion}. Update to {latestVersion} to get {count} versions worth of new features, improvements, and fixes. See what changed",
|
||||
|
||||
@@ -29,15 +29,22 @@ export interface Version {
|
||||
securityIssueFixVersion: string;
|
||||
}
|
||||
|
||||
export interface WhatsNewSection {
|
||||
title: string;
|
||||
calloutText: string;
|
||||
footer: string;
|
||||
items: WhatsNewArticle[];
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WhatsNewArticle {
|
||||
id: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
publishedAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
calloutTitle: string;
|
||||
calloutText: string;
|
||||
}
|
||||
|
||||
export async function getNextVersions(
|
||||
@@ -49,11 +56,11 @@ export async function getNextVersions(
|
||||
return await get(endpoint, currentVersion, {}, headers);
|
||||
}
|
||||
|
||||
export async function getWhatsNewArticles(
|
||||
export async function getWhatsNewSection(
|
||||
endpoint: string,
|
||||
currentVersion: string,
|
||||
instanceId: string,
|
||||
): Promise<WhatsNewArticle[]> {
|
||||
): Promise<WhatsNewSection> {
|
||||
const headers = {
|
||||
[INSTANCE_ID_HEADER as string]: instanceId,
|
||||
[INSTANCE_VERSION_HEADER as string]: currentVersion,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. 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',
|
||||
},
|
||||
];
|
||||
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. 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',
|
||||
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'));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -491,10 +491,12 @@ export const LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS =
|
||||
export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
|
||||
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
|
||||
export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES';
|
||||
export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT';
|
||||
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
||||
export const RELEASE_NOTES_URL = 'https://docs.n8n.io/release-notes/';
|
||||
|
||||
export const HIRING_BANNER = `
|
||||
//////
|
||||
|
||||
@@ -174,6 +174,44 @@
|
||||
.el-notification {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
|
||||
&.whats-new-notification {
|
||||
bottom: var(--spacing-xs) !important;
|
||||
left: var(--spacing-s) !important;
|
||||
width: 300px;
|
||||
padding: var(--spacing-xs);
|
||||
border: var(--border-base);
|
||||
|
||||
.el-notification__group {
|
||||
margin-left: 0;
|
||||
margin-right: var(--spacing-l);
|
||||
}
|
||||
|
||||
.el-notification__title {
|
||||
color: var(--color-callout-info-font);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-s);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.el-notification__content {
|
||||
color: var(--color-callout-info-font);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-s);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1.4;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.el-notification__closeBtn {
|
||||
height: 100%;
|
||||
top: 0px;
|
||||
right: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-notification__content {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useVersionsStore } from './versions.store';
|
||||
import * as versionsApi from '@n8n/rest-api-client/api/versions';
|
||||
import type { IVersionNotificationSettings } from '@n8n/api-types';
|
||||
import type { Version, WhatsNewArticle } from '@n8n/rest-api-client/api/versions';
|
||||
import type { Version, WhatsNewArticle, WhatsNewSection } from '@n8n/rest-api-client/api/versions';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useSettingsStore } from './settings.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
@@ -44,13 +44,20 @@ const whatsNewArticle: WhatsNewArticle = {
|
||||
id: 1,
|
||||
title: 'Test article',
|
||||
content: 'Some markdown content here',
|
||||
calloutTitle: 'Callout title',
|
||||
calloutText: 'Callout text.',
|
||||
createdAt: '2025-06-19T12:37:54.885Z',
|
||||
updatedAt: '2025-06-19T12:41:44.919Z',
|
||||
publishedAt: '2025-06-19T12:41:44.914Z',
|
||||
};
|
||||
|
||||
const whatsNew: WhatsNewSection = {
|
||||
title: "What's New title",
|
||||
calloutText: 'Callout text.',
|
||||
footer: "What's new footer",
|
||||
items: [whatsNewArticle],
|
||||
createdAt: '2025-06-19T12:37:54.885Z',
|
||||
updatedAt: '2025-06-19T12:41:44.919Z',
|
||||
};
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
describe('versions.store', () => {
|
||||
@@ -102,7 +109,7 @@ describe('versions.store', () => {
|
||||
|
||||
describe('fetchWhatsNew()', () => {
|
||||
it("should fetch What's new articles", async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
rootStore.setVersionCli(currentVersionName);
|
||||
@@ -113,7 +120,7 @@ describe('versions.store', () => {
|
||||
|
||||
await versionsStore.fetchWhatsNew();
|
||||
|
||||
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith(
|
||||
expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
|
||||
settings.whatsNewEndpoint,
|
||||
currentVersionName,
|
||||
instanceId,
|
||||
@@ -123,7 +130,7 @@ describe('versions.store', () => {
|
||||
});
|
||||
|
||||
it("should not fetch What's new articles if version notifications are disabled", async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles');
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection');
|
||||
|
||||
const versionsStore = useVersionsStore();
|
||||
versionsStore.initialize({
|
||||
@@ -133,12 +140,12 @@ describe('versions.store', () => {
|
||||
|
||||
await versionsStore.fetchWhatsNew();
|
||||
|
||||
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled();
|
||||
expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
|
||||
expect(versionsStore.whatsNewArticles).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not fetch What's new articles if not enabled", async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles');
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection');
|
||||
|
||||
const versionsStore = useVersionsStore();
|
||||
versionsStore.initialize({
|
||||
@@ -149,14 +156,14 @@ describe('versions.store', () => {
|
||||
|
||||
await versionsStore.fetchWhatsNew();
|
||||
|
||||
expect(versionsApi.getWhatsNewArticles).not.toHaveBeenCalled();
|
||||
expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
|
||||
expect(versionsStore.whatsNewArticles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForNewVersions()', () => {
|
||||
it('should check for new versions', async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
|
||||
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
@@ -168,7 +175,7 @@ describe('versions.store', () => {
|
||||
|
||||
await versionsStore.checkForNewVersions();
|
||||
|
||||
expect(versionsApi.getWhatsNewArticles).toHaveBeenCalledWith(
|
||||
expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
|
||||
settings.whatsNewEndpoint,
|
||||
currentVersionName,
|
||||
instanceId,
|
||||
@@ -187,7 +194,7 @@ describe('versions.store', () => {
|
||||
});
|
||||
|
||||
it("should still initialize versions if what's new articles fail", async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockRejectedValueOnce(new Error('oopsie'));
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection').mockRejectedValueOnce(new Error('oopsie'));
|
||||
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
@@ -211,7 +218,7 @@ describe('versions.store', () => {
|
||||
});
|
||||
|
||||
it("should still initialize what's new articles if versions fail", async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([whatsNewArticle]);
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
|
||||
vi.spyOn(versionsApi, 'getNextVersions').mockRejectedValueOnce(new Error('oopsie'));
|
||||
|
||||
const rootStore = useRootStore();
|
||||
@@ -235,7 +242,7 @@ describe('versions.store', () => {
|
||||
});
|
||||
|
||||
it('should show toast if important version updates are available', async () => {
|
||||
vi.spyOn(versionsApi, 'getWhatsNewArticles').mockResolvedValue([]);
|
||||
vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue({ ...whatsNew, items: [] });
|
||||
vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([
|
||||
{
|
||||
...currentVersion,
|
||||
@@ -351,4 +358,150 @@ describe('versions.store', () => {
|
||||
expect(versionsStore.latestVersion.name).toBe(currentVersionName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSignificantUpdates', () => {
|
||||
beforeEach(() => {
|
||||
const settingsStore = useSettingsStore();
|
||||
settingsStore.settings.releaseChannel = 'stable';
|
||||
});
|
||||
|
||||
it('should return true if current version is behind by at least two minor versions', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
|
||||
versionsStore.currentVersion = currentVersion;
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.102.0',
|
||||
},
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.101.0',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if current version is behind by at least two minor versions, more exotic versions', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
|
||||
versionsStore.currentVersion = {
|
||||
...currentVersion,
|
||||
name: '1.100.1+rc.1',
|
||||
};
|
||||
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.102.0-alpha+20180301',
|
||||
},
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.101.0',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if current version has security issue', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
|
||||
versionsStore.currentVersion = {
|
||||
...currentVersion,
|
||||
hasSecurityIssue: true,
|
||||
};
|
||||
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.101.0',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if current version is not behind by at least two minor versions', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
versionsStore.currentVersion = {
|
||||
...currentVersion,
|
||||
name: '1.101.0',
|
||||
};
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.102.0',
|
||||
},
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.101.1',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if current version is only behind by patch versions', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
versionsStore.currentVersion = currentVersion;
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.100.9',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if current version is behind by a major', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
versionsStore.currentVersion = {
|
||||
...currentVersion,
|
||||
name: '1.100.0',
|
||||
};
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '2.0.0',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if current version is not semver', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
|
||||
versionsStore.currentVersion = {
|
||||
...currentVersion,
|
||||
name: 'alpha-1',
|
||||
};
|
||||
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: '1.100.2',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if latest version is not semver', () => {
|
||||
const versionsStore = useVersionsStore();
|
||||
|
||||
versionsStore.currentVersion = currentVersion;
|
||||
versionsStore.nextVersions = [
|
||||
{
|
||||
...currentVersion,
|
||||
name: 'alpha-2',
|
||||
},
|
||||
];
|
||||
|
||||
expect(versionsStore.hasSignificantUpdates).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { IVersionNotificationSettings } from '@n8n/api-types';
|
||||
import * as versionsApi from '@n8n/rest-api-client/api/versions';
|
||||
import { LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES, VERSIONS_MODAL_KEY } from '@/constants';
|
||||
import {
|
||||
LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT,
|
||||
LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES,
|
||||
VERSIONS_MODAL_KEY,
|
||||
WHATS_NEW_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import type { Version, WhatsNewArticle } from '@n8n/rest-api-client/api/versions';
|
||||
import type { Version, WhatsNewSection } from '@n8n/rest-api-client/api/versions';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { NotificationHandle } from 'element-plus';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
@@ -11,9 +17,17 @@ import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from './settings.store';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
type SetVersionParams = { versions: Version[]; currentVersion: string };
|
||||
|
||||
/**
|
||||
* Semantic versioning 2.0.0, Regex from https://semver.org/
|
||||
* Capture groups: major, minor, patch, prerelease, buildmetadata
|
||||
*/
|
||||
export const SEMVER_REGEX =
|
||||
/^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
|
||||
export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
const versionNotificationSettings = ref<IVersionNotificationSettings>({
|
||||
enabled: false,
|
||||
@@ -24,12 +38,22 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
});
|
||||
const nextVersions = ref<Version[]>([]);
|
||||
const currentVersion = ref<Version | undefined>();
|
||||
const whatsNewArticles = ref<WhatsNewArticle[]>([]);
|
||||
const whatsNew = ref<WhatsNewSection>({
|
||||
title: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
calloutText: '',
|
||||
footer: '',
|
||||
items: [],
|
||||
});
|
||||
const whatsNewCallout = ref<NotificationHandle | undefined>();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const telemetry = useTelemetry();
|
||||
const { showToast, showMessage } = useToast();
|
||||
const uiStore = useUIStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES);
|
||||
const lastDismissedWhatsNewCalloutStorage = useStorage(LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #region Computed
|
||||
@@ -39,6 +63,29 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
return settingsStore.settings.releaseChannel === 'stable' && nextVersions.value.length > 0;
|
||||
});
|
||||
|
||||
const hasSignificantUpdates = computed(() => {
|
||||
if (!hasVersionUpdates.value || !currentVersion.value || !latestVersion.value) return false;
|
||||
|
||||
// Always consider security issues as significant updates
|
||||
if (currentVersion.value.hasSecurityIssue) return true;
|
||||
|
||||
const current = currentVersion.value.name.match(SEMVER_REGEX);
|
||||
const latest = latestVersion.value.name.match(SEMVER_REGEX);
|
||||
|
||||
if (!current?.groups || !latest?.groups) return false;
|
||||
|
||||
// Major change is always significant
|
||||
if (Number(current.groups.major) !== Number(latest.groups.major)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentMinor = Number(current.groups.minor);
|
||||
const latestMinor = Number(latest.groups.minor);
|
||||
|
||||
// Otherwise two minor versions is enough to be considered significant
|
||||
return latestMinor - currentMinor >= 2;
|
||||
});
|
||||
|
||||
const latestVersion = computed(() => {
|
||||
return nextVersions.value[0] ?? currentVersion.value;
|
||||
});
|
||||
@@ -57,6 +104,16 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
: [];
|
||||
});
|
||||
|
||||
const lastDismissedWhatsNewCallout = computed((): number[] => {
|
||||
return lastDismissedWhatsNewCalloutStorage.value
|
||||
? jsonParse(lastDismissedWhatsNewCalloutStorage.value, { fallbackValue: [] })
|
||||
: [];
|
||||
});
|
||||
|
||||
const whatsNewArticles = computed(() => {
|
||||
return whatsNew.value.items;
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -81,25 +138,8 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
currentVersion.value = params.versions.find((v) => v.name === params.currentVersion);
|
||||
};
|
||||
|
||||
const setWhatsNew = (article: WhatsNewArticle[]) => {
|
||||
whatsNewArticles.value = article;
|
||||
};
|
||||
|
||||
const fetchWhatsNew = async () => {
|
||||
try {
|
||||
const { enabled, whatsNewEnabled, whatsNewEndpoint } = versionNotificationSettings.value;
|
||||
if (enabled && whatsNewEnabled && whatsNewEndpoint) {
|
||||
const rootStore = useRootStore();
|
||||
const current = rootStore.versionCli;
|
||||
const instanceId = rootStore.instanceId;
|
||||
const articles = await versionsApi.getWhatsNewArticles(
|
||||
whatsNewEndpoint,
|
||||
current,
|
||||
instanceId,
|
||||
);
|
||||
setWhatsNew(articles);
|
||||
}
|
||||
} catch (e) {}
|
||||
const setWhatsNew = (section: WhatsNewSection) => {
|
||||
whatsNew.value = section;
|
||||
};
|
||||
|
||||
const setWhatsNewArticleRead = (articleId: number) => {
|
||||
@@ -115,6 +155,62 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
return readWhatsNewArticles.value.includes(articleId);
|
||||
};
|
||||
|
||||
const closeWhatsNewCallout = () => {
|
||||
whatsNewCallout.value?.close();
|
||||
whatsNewCallout.value = undefined;
|
||||
};
|
||||
|
||||
const dismissWhatsNewCallout = () => {
|
||||
lastDismissedWhatsNewCalloutStorage.value = JSON.stringify(
|
||||
whatsNewArticles.value.map((item) => item.id),
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowWhatsNewCallout = (): boolean => {
|
||||
return !whatsNewArticles.value.every((item) =>
|
||||
lastDismissedWhatsNewCallout.value.includes(item.id),
|
||||
);
|
||||
};
|
||||
|
||||
const fetchWhatsNew = async () => {
|
||||
try {
|
||||
const { enabled, whatsNewEnabled, whatsNewEndpoint } = versionNotificationSettings.value;
|
||||
if (enabled && whatsNewEnabled && whatsNewEndpoint) {
|
||||
const rootStore = useRootStore();
|
||||
const current = rootStore.versionCli;
|
||||
const instanceId = rootStore.instanceId;
|
||||
const section = await versionsApi.getWhatsNewSection(whatsNewEndpoint, current, instanceId);
|
||||
|
||||
if (section.items?.length > 0) {
|
||||
setWhatsNew(section);
|
||||
|
||||
if (shouldShowWhatsNewCallout()) {
|
||||
whatsNewCallout.value = showMessage({
|
||||
title: whatsNew.value.title,
|
||||
message: whatsNew.value.calloutText,
|
||||
duration: 0,
|
||||
position: 'bottom-left',
|
||||
customClass: 'clickable whats-new-notification',
|
||||
onClick: () => {
|
||||
const articleId = whatsNew.value.items[0]?.id ?? 0;
|
||||
telemetry.track("User clicked on what's new notification", {
|
||||
article_id: articleId,
|
||||
});
|
||||
uiStore.openModalWithData({
|
||||
name: WHATS_NEW_MODAL_KEY,
|
||||
data: { articleId },
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
dismissWhatsNewCallout();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const initialize = (settings: IVersionNotificationSettings) => {
|
||||
versionNotificationSettings.value = settings;
|
||||
};
|
||||
@@ -160,6 +256,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
latestVersion,
|
||||
nextVersions,
|
||||
hasVersionUpdates,
|
||||
hasSignificantUpdates,
|
||||
areNotificationsEnabled,
|
||||
infoUrl,
|
||||
fetchVersions,
|
||||
@@ -167,8 +264,10 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => {
|
||||
initialize,
|
||||
checkForNewVersions,
|
||||
fetchWhatsNew,
|
||||
whatsNew,
|
||||
whatsNewArticles,
|
||||
isWhatsNewArticleRead,
|
||||
setWhatsNewArticleRead,
|
||||
closeWhatsNewCallout,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user