diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue index 85f69eab4f..5623b98b20 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue @@ -424,6 +424,10 @@ input[type='checkbox'] + label { iframe { aspect-ratio: 16/9 auto; } + + summary { + cursor: pointer; + } } .spacer { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 99b7a64694..349bf5be10 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/versions.ts b/packages/frontend/@n8n/rest-api-client/src/api/versions.ts index eee9458a35..56232584b3 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/versions.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/versions.ts @@ -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 { +): Promise { const headers = { [INSTANCE_ID_HEADER as string]: instanceId, [INSTANCE_VERSION_HEADER as string]: currentVersion, diff --git a/packages/frontend/editor-ui/src/components/MainSidebar.vue b/packages/frontend/editor-ui/src/components/MainSidebar.vue index dff53c9ee4..538c9dcd9a 100644 --- a/packages/frontend/editor-ui/src/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/components/MainSidebar.vue @@ -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(() => [ 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', diff --git a/packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts b/packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts index 04b0bef59e..032f47d893 100644 --- a/packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts +++ b/packages/frontend/editor-ui/src/components/WhatsNewModal.test.ts @@ -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' + - '
\nFixes (4)\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
\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' + + '
\nFixes (4)\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
\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')); diff --git a/packages/frontend/editor-ui/src/components/WhatsNewModal.vue b/packages/frontend/editor-ui/src/components/WhatsNewModal.vue index 2a950677df..768b271976 100644 --- a/packages/frontend/editor-ui/src/components/WhatsNewModal.vue +++ b/packages/frontend/editor-ui/src/components/WhatsNewModal.vue @@ -1,17 +1,11 @@ @@ -82,13 +84,7 @@ onMounted(() => {
- {{ - i18n.baseText('whatsNew.modal.title', { - interpolate: { - version: versionsStore.latestVersion.name, - }, - }) - }} + {{ versionsStore.whatsNew.title }}
@@ -127,83 +123,99 @@ onMounted(() => { diff --git a/packages/frontend/editor-ui/src/components/__snapshots__/WhatsNewModal.test.ts.snap b/packages/frontend/editor-ui/src/components/__snapshots__/WhatsNewModal.test.ts.snap index 8b37691d54..d34600f04e 100644 --- a/packages/frontend/editor-ui/src/components/__snapshots__/WhatsNewModal.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/__snapshots__/WhatsNewModal.test.ts.snap @@ -3,7 +3,7 @@ exports[`WhatsNewModal > should render with update button disabled 1`] = `

{ @@ -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); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/stores/versions.store.ts b/packages/frontend/editor-ui/src/stores/versions.store.ts index 6611b48997..fc538202b6 100644 --- a/packages/frontend/editor-ui/src/stores/versions.store.ts +++ b/packages/frontend/editor-ui/src/stores/versions.store.ts @@ -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 = + /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + export const useVersionsStore = defineStore(STORES.VERSIONS, () => { const versionNotificationSettings = ref({ enabled: false, @@ -24,12 +38,22 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => { }); const nextVersions = ref([]); const currentVersion = ref(); - const whatsNewArticles = ref([]); + const whatsNew = ref({ + title: '', + createdAt: new Date().toISOString(), + updatedAt: null, + calloutText: '', + footer: '', + items: [], + }); + const whatsNewCallout = ref(); - 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, }; });