From cd4384e879e4486dedbe1ed6582aa597e9affe55 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 29 Jul 2026 09:41:40 +0300 Subject: [PATCH] refactor(editor): Move versions.store into @n8n/stores (no-changelog) (#35142) Co-authored-by: Claude --- .../@n8n/frontend-constants/src/versions.ts | 13 + .../stores/src}/versions.store.test.ts | 71 ++-- .../@n8n/stores/src/versions.store.ts | 313 +++++++++++++++++ .../src/app/constants/localStorage.ts | 2 - .../editor-ui/src/app/constants/modals.ts | 5 +- .../src/app/stores/versions.store.ts | 315 +----------------- 6 files changed, 366 insertions(+), 353 deletions(-) create mode 100644 packages/frontend/@n8n/frontend-constants/src/versions.ts rename packages/frontend/{editor-ui/src/app/stores => @n8n/stores/src}/versions.store.test.ts (91%) create mode 100644 packages/frontend/@n8n/stores/src/versions.store.ts diff --git a/packages/frontend/@n8n/frontend-constants/src/versions.ts b/packages/frontend/@n8n/frontend-constants/src/versions.ts new file mode 100644 index 00000000000..c3d984430a7 --- /dev/null +++ b/packages/frontend/@n8n/frontend-constants/src/versions.ts @@ -0,0 +1,13 @@ +/** + * Constants consumed by `versions.store` (`@n8n/stores`). Relocated per-symbol + * from `editor-ui`'s `@/app/constants` when the store moved into a package + * (N8N-70). They live here because the modal keys have two consumers on + * opposite sides of that boundary — the store, and the app-side registration in + * `@/app/constants/modals` — and this package is a leaf both already depend on. + */ + +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 VERSIONS_MODAL_KEY = 'versions'; +export const WHATS_NEW_MODAL_KEY = 'whatsNew'; diff --git a/packages/frontend/editor-ui/src/app/stores/versions.store.test.ts b/packages/frontend/@n8n/stores/src/versions.store.test.ts similarity index 91% rename from packages/frontend/editor-ui/src/app/stores/versions.store.test.ts rename to packages/frontend/@n8n/stores/src/versions.store.test.ts index 21c09304fbc..141bf868806 100644 --- a/packages/frontend/editor-ui/src/app/stores/versions.store.test.ts +++ b/packages/frontend/@n8n/stores/src/versions.store.test.ts @@ -1,22 +1,19 @@ -import { createPinia, setActivePinia } from 'pinia'; -import { useVersionsStore } from './versions.store'; -import type { ModalOpeners } from '@/Interface'; -import { useUsersStore } from '@/features/settings/users/users.store'; -import * as versionsApi from '@n8n/rest-api-client/api/versions'; import type { IVersionNotificationSettings } from '@n8n/api-types'; +import { useToast } from '@n8n/composables/useToast'; +import { VERSIONS_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@n8n/frontend-constants/versions'; +import type { IUser } from '@n8n/rest-api-client/api/users'; +import * as versionsApi 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 { createPinia, setActivePinia } from 'pinia'; +import { mock } from 'vitest-mock-extended'; + +import type { ModalOpeners } from './modalOpeners'; import { useSettingsStore } from './settings.store'; -import { useToast } from '@/app/composables/useToast'; -import { reactive } from 'vue'; -import { VERSIONS_MODAL_KEY, VIEWS, WHATS_NEW_MODAL_KEY } from '@/app/constants'; +import { useRootStore } from './useRootStore'; +import { useUsersStore } from './users.store'; +import { useVersionsStore } from './versions.store'; -vi.mock('vue-router', async (importOriginal) => ({ - ...(await importOriginal()), - useRoute: () => reactive({ name: VIEWS.HOMEPAGE }), -})); - -vi.mock('@/app/composables/useToast', () => { +vi.mock('@n8n/composables/useToast', () => { const showToast = vi.fn(); const showMessage = vi.fn(); return { @@ -29,7 +26,19 @@ vi.mock('@/app/composables/useToast', () => { }; }); -vi.mock('@/features/settings/users/users.store'); +vi.mock('./users.store'); + +/** + * Stub `useUsersStore` with just the `currentUser` this store reads — + * `shouldShowWhatsNewCallout` only looks at `createdAt`. + */ +const mockCurrentUser = (currentUser: Partial | null) => { + vi.mocked(useUsersStore).mockReturnValue( + mock>({ + currentUser: currentUser && mock(currentUser), + }), + ); +}; const settings: IVersionNotificationSettings = { enabled: true, @@ -156,8 +165,7 @@ describe('versions.store', () => { it('should dismiss the callout as soon as it is shown', async () => { vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as any); + mockCurrentUser(null); const rootStore = useRootStore(); rootStore.setVersionCli(currentVersionName); @@ -178,8 +186,7 @@ describe('versions.store', () => { it("should open the what's new modal via the registered opener when the callout is clicked", async () => { vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as any); + mockCurrentUser(null); const rootStore = useRootStore(); rootStore.setVersionCli(currentVersionName); @@ -629,8 +636,7 @@ describe('shouldShowWhatsNewCallout', () => { }); it('returns false if there are no articles', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as any); + mockCurrentUser(null); versionsStore = useVersionsStore(); Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); versionsStore.whatsNew.items = []; @@ -638,8 +644,7 @@ describe('shouldShowWhatsNewCallout', () => { }); it('returns true if user has no createdAt and not all articles are dismissed', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as any); + mockCurrentUser(null); versionsStore = useVersionsStore(); Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); versionsStore.whatsNew.items = [makeArticle()]; @@ -647,8 +652,7 @@ describe('shouldShowWhatsNewCallout', () => { }); it('returns false if all articles are dismissed', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as any); + mockCurrentUser(null); versionsStore = useVersionsStore(); versionsStore.whatsNew.items = [makeArticle()]; versionsStore.dismissWhatsNewCallout(); @@ -657,10 +661,7 @@ describe('shouldShowWhatsNewCallout', () => { it('returns true if user createdAt is before article updatedAt', () => { const now = Date.now(); - vi.mocked(useUsersStore).mockReturnValue({ - currentUser: { createdAt: new Date(now - 10000).toISOString() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + mockCurrentUser({ createdAt: new Date(now - 10000).toISOString() }); versionsStore = useVersionsStore(); Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); versionsStore.whatsNew.items = [makeArticle({ updatedAt: new Date(now).toISOString() })]; @@ -669,10 +670,7 @@ describe('shouldShowWhatsNewCallout', () => { it('returns false if user createdAt is after article updatedAt', () => { const now = Date.now(); - vi.mocked(useUsersStore).mockReturnValue({ - currentUser: { createdAt: new Date(now).toISOString() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + mockCurrentUser({ createdAt: new Date(now).toISOString() }); versionsStore = useVersionsStore(); Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); versionsStore.whatsNew.items = [ @@ -682,10 +680,7 @@ describe('shouldShowWhatsNewCallout', () => { }); it('handles missing updatedAt on article', () => { - vi.mocked(useUsersStore).mockReturnValue({ - currentUser: { createdAt: new Date().toISOString() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + mockCurrentUser({ createdAt: new Date().toISOString() }); versionsStore = useVersionsStore(); Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); versionsStore.whatsNew.items = [makeArticle({ updatedAt: undefined })]; diff --git a/packages/frontend/@n8n/stores/src/versions.store.ts b/packages/frontend/@n8n/stores/src/versions.store.ts new file mode 100644 index 00000000000..a547cf62121 --- /dev/null +++ b/packages/frontend/@n8n/stores/src/versions.store.ts @@ -0,0 +1,313 @@ +import type { IVersionNotificationSettings } from '@n8n/api-types'; +import { useStorage } from '@n8n/composables/useStorage'; +import { useTelemetry } from '@n8n/composables/useTelemetry'; +import { useToast, type NotificationHandle } from '@n8n/composables/useToast'; +import { + LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT, + LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES, + VERSIONS_MODAL_KEY, + WHATS_NEW_MODAL_KEY, +} from '@n8n/frontend-constants/versions'; +import * as versionsApi from '@n8n/rest-api-client/api/versions'; +import type { Version, WhatsNewSection } from '@n8n/rest-api-client/api/versions'; +import { jsonParse } from 'n8n-workflow'; +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; + +import { STORES } from './constants'; +import type { ModalOpeners } from './modalOpeners'; +import { useSettingsStore } from './settings.store'; +import { useRootStore } from './useRootStore'; +import { useUsersStore } from './users.store'; + +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, + whatsNewEnabled: false, + endpoint: '', + whatsNewEndpoint: '', + infoUrl: '', + }); + const nextVersions = ref([]); + const currentVersion = ref(); + const whatsNew = ref({ + title: '', + createdAt: new Date().toISOString(), + updatedAt: null, + calloutText: '', + footer: '', + items: [], + }); + const whatsNewCallout = ref(); + + const telemetry = useTelemetry(); + const { showToast, showMessage } = useToast(); + const settingsStore = useSettingsStore(); + const usersStore = useUsersStore(); + + // Modal-open actions, registered at app bootstrap (see app/init.ts). Until then + // they no-op — warning in dev — so the store never reaches into `ui.store`. + const warnModalOpenerMissing = (action: string) => { + if (import.meta.env.DEV) { + console.warn( + `[versions.store] ${action} called before modal openers were registered; ignoring. Call registerModalOpeners() at app bootstrap.`, + ); + } + }; + const modalOpeners = ref({ + openModal: (name) => warnModalOpenerMissing(`openModal(${String(name)})`), + openModalWithData: (payload) => + warnModalOpenerMissing(`openModalWithData(${String(payload.name)})`), + }); + const registerModalOpeners = (openers: ModalOpeners) => { + modalOpeners.value = openers; + }; + const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES); + const lastDismissedWhatsNewCalloutStorage = useStorage(LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT); + + // --------------------------------------------------------------------------- + // #region Computed + // --------------------------------------------------------------------------- + + const hasVersionUpdates = computed(() => { + 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; + }); + + const areNotificationsEnabled = computed(() => { + return versionNotificationSettings.value.enabled; + }); + + const infoUrl = computed(() => { + return versionNotificationSettings.value.infoUrl; + }); + + const readWhatsNewArticles = computed((): number[] => { + return readWhatsNewArticlesStorage.value + ? jsonParse(readWhatsNewArticlesStorage.value, { fallbackValue: [] }) + : []; + }); + + const lastDismissedWhatsNewCallout = computed((): number[] => { + return lastDismissedWhatsNewCalloutStorage.value + ? jsonParse(lastDismissedWhatsNewCalloutStorage.value, { fallbackValue: [] }) + : []; + }); + + const whatsNewArticles = computed(() => { + return whatsNew.value.items; + }); + + // #endregion + + // --------------------------------------------------------------------------- + // #region Methods + // --------------------------------------------------------------------------- + + const fetchVersions = async () => { + try { + const { enabled, endpoint } = versionNotificationSettings.value; + if (enabled && endpoint) { + const rootStore = useRootStore(); + const current = rootStore.versionCli; + const instanceId = rootStore.instanceId; + const versions = await versionsApi.getNextVersions(endpoint, current, instanceId); + setVersions({ versions, currentVersion: current }); + } + } catch (e) { + console.error('Failed to fetch versions:', e); + } + }; + + const setVersions = (params: SetVersionParams) => { + nextVersions.value = params.versions.filter((v) => v.name !== params.currentVersion); + currentVersion.value = params.versions.find((v) => v.name === params.currentVersion); + }; + + const setWhatsNew = (section: WhatsNewSection) => { + whatsNew.value = section; + }; + + const setWhatsNewArticleRead = (articleId: number) => { + if (!readWhatsNewArticles.value.includes(articleId)) { + readWhatsNewArticlesStorage.value = JSON.stringify([ + ...readWhatsNewArticles.value, + articleId, + ]); + } + }; + + const isWhatsNewArticleRead = (articleId: number): boolean => { + 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 => { + const createdAt = usersStore.currentUser?.createdAt; + let hasNewArticle = false; + if (createdAt) { + const userCreatedAt = new Date(createdAt).getTime(); + hasNewArticle = whatsNewArticles.value.some((item) => { + const updatedAt = item.updatedAt ? new Date(item.updatedAt).getTime() : 0; + return updatedAt > userCreatedAt; + }); + } else { + hasNewArticle = true; + } + const allArticlesDismissed = whatsNewArticles.value.every((item) => + lastDismissedWhatsNewCallout.value.includes(item.id), + ); + + return hasNewArticle && !allArticlesDismissed; + }; + + 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, + }); + modalOpeners.value.openModalWithData({ + name: WHATS_NEW_MODAL_KEY, + data: { articleId }, + }); + }, + }); + + // Mark the callout as dismissed as soon as it is shown, so it does not + // keep reappearing on subsequent loads for users who simply ignore it. + dismissWhatsNewCallout(); + } + } + } + } catch (e) { + console.error('Failed to fetch Whats New section:', e); + } + }; + + const initialize = (settings: IVersionNotificationSettings) => { + versionNotificationSettings.value = settings; + }; + + const checkForNewVersions = async () => { + const enabled = areNotificationsEnabled.value; + if (!enabled) { + return; + } + + await Promise.all([fetchVersions(), fetchWhatsNew()]); + + if ( + currentVersion.value && + currentVersion.value.hasSecurityIssue && + nextVersions.value.length + ) { + const fixVersion = currentVersion.value.securityIssueFixVersion; + let message = 'Please update to latest version.'; + if (fixVersion) { + message = `Please update to version ${fixVersion} or higher.`; + } + + message = `${message} More info`; + showToast({ + title: 'Critical update available', + message, + onClick: () => { + modalOpeners.value.openModal(VERSIONS_MODAL_KEY); + }, + closeOnClick: true, + customClass: 'clickable', + type: 'warning', + duration: 0, + }); + } + }; + + // #endregion + + return { + currentVersion, + latestVersion, + nextVersions, + hasVersionUpdates, + hasSignificantUpdates, + areNotificationsEnabled, + infoUrl, + fetchVersions, + setVersions, + initialize, + registerModalOpeners, + checkForNewVersions, + fetchWhatsNew, + whatsNew, + whatsNewArticles, + isWhatsNewArticleRead, + setWhatsNewArticleRead, + closeWhatsNewCallout, + shouldShowWhatsNewCallout, + dismissWhatsNewCallout, + }; +}); diff --git a/packages/frontend/editor-ui/src/app/constants/localStorage.ts b/packages/frontend/editor-ui/src/app/constants/localStorage.ts index 499b7ef97b7..1db5bd4d408 100644 --- a/packages/frontend/editor-ui/src/app/constants/localStorage.ts +++ b/packages/frontend/editor-ui/src/app/constants/localStorage.ts @@ -13,8 +13,6 @@ export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLE export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL'; export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE'; export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES'; -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 LOCAL_STORAGE_FOCUS_PANEL = 'N8N_FOCUS_PANEL'; export const LOCAL_STORAGE_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS = 'N8N_EXPERIMENTAL_DISMISSED_SUGGESTED_WORKFLOWS'; diff --git a/packages/frontend/editor-ui/src/app/constants/modals.ts b/packages/frontend/editor-ui/src/app/constants/modals.ts index a275c1d3108..fa238afc6ce 100644 --- a/packages/frontend/editor-ui/src/app/constants/modals.ts +++ b/packages/frontend/editor-ui/src/app/constants/modals.ts @@ -8,7 +8,6 @@ export const CHANGE_PASSWORD_MODAL_KEY = 'changePassword'; export const CONFIRM_PASSWORD_MODAL_KEY = 'confirmPassword'; export const DUPLICATE_MODAL_KEY = 'duplicate'; export const IMPORT_WORKFLOW_URL_MODAL_KEY = 'importWorkflowUrl'; -export const VERSIONS_MODAL_KEY = 'versions'; export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings'; export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare'; export const NPS_SURVEY_MODAL_KEY = 'npsSurvey'; @@ -30,7 +29,9 @@ export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY = export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters'; export const STOP_MANY_EXECUTIONS_MODAL_KEY = 'stopManyExecutions'; export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName'; -export const WHATS_NEW_MODAL_KEY = 'whatsNew'; +// Shared with `versions.store` in `@n8n/stores`; re-exported here so app-side +// modal registration and openers keep resolving from `@/app/constants`. (N8N-70) +export { VERSIONS_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@n8n/frontend-constants/versions'; export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff'; export const AI_GATEWAY_TOP_UP_MODAL_KEY = 'aiGatewayTopUp'; export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2'; diff --git a/packages/frontend/editor-ui/src/app/stores/versions.store.ts b/packages/frontend/editor-ui/src/app/stores/versions.store.ts index 0d67e49a3d9..c7fb13acf11 100644 --- a/packages/frontend/editor-ui/src/app/stores/versions.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/versions.store.ts @@ -1,313 +1,6 @@ -import type { IVersionNotificationSettings } from '@n8n/api-types'; -import * as versionsApi from '@n8n/rest-api-client/api/versions'; -import { - LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT, - LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES, - VERSIONS_MODAL_KEY, - WHATS_NEW_MODAL_KEY, -} from '@/app/constants'; -import { STORES } from '@n8n/stores'; -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 '@/app/composables/useToast'; -import type { ModalOpeners } from '@/Interface'; -import { computed, ref } from 'vue'; -import { useSettingsStore } from './settings.store'; -import { useUsersStore } from '@/features/settings/users/users.store'; -import { useStorage } from '@/app/composables/useStorage'; -import { jsonParse } from 'n8n-workflow'; -import { useTelemetry } from '@/app/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 + * @deprecated Import from `@n8n/stores/versions.store` instead. This store moved to + * `@n8n/stores`; this re-export is a temporary shim kept so existing importers + * keep working and will be removed once call sites migrate. (N8N-70) */ -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, - whatsNewEnabled: false, - endpoint: '', - whatsNewEndpoint: '', - infoUrl: '', - }); - const nextVersions = ref([]); - const currentVersion = ref(); - const whatsNew = ref({ - title: '', - createdAt: new Date().toISOString(), - updatedAt: null, - calloutText: '', - footer: '', - items: [], - }); - const whatsNewCallout = ref(); - - const telemetry = useTelemetry(); - const { showToast, showMessage } = useToast(); - const settingsStore = useSettingsStore(); - const usersStore = useUsersStore(); - - // Modal-open actions, registered at app bootstrap (see app/init.ts). Until then - // they no-op — warning in dev — so the store never reaches into `ui.store`. - const warnModalOpenerMissing = (action: string) => { - if (import.meta.env.DEV) { - console.warn( - `[versions.store] ${action} called before modal openers were registered; ignoring. Call registerModalOpeners() at app bootstrap.`, - ); - } - }; - const modalOpeners = ref({ - openModal: (name) => warnModalOpenerMissing(`openModal(${String(name)})`), - openModalWithData: (payload) => - warnModalOpenerMissing(`openModalWithData(${String(payload.name)})`), - }); - const registerModalOpeners = (openers: ModalOpeners) => { - modalOpeners.value = openers; - }; - const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES); - const lastDismissedWhatsNewCalloutStorage = useStorage(LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT); - - // --------------------------------------------------------------------------- - // #region Computed - // --------------------------------------------------------------------------- - - const hasVersionUpdates = computed(() => { - 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; - }); - - const areNotificationsEnabled = computed(() => { - return versionNotificationSettings.value.enabled; - }); - - const infoUrl = computed(() => { - return versionNotificationSettings.value.infoUrl; - }); - - const readWhatsNewArticles = computed((): number[] => { - return readWhatsNewArticlesStorage.value - ? jsonParse(readWhatsNewArticlesStorage.value, { fallbackValue: [] }) - : []; - }); - - const lastDismissedWhatsNewCallout = computed((): number[] => { - return lastDismissedWhatsNewCalloutStorage.value - ? jsonParse(lastDismissedWhatsNewCalloutStorage.value, { fallbackValue: [] }) - : []; - }); - - const whatsNewArticles = computed(() => { - return whatsNew.value.items; - }); - - // #endregion - - // --------------------------------------------------------------------------- - // #region Methods - // --------------------------------------------------------------------------- - - const fetchVersions = async () => { - try { - const { enabled, endpoint } = versionNotificationSettings.value; - if (enabled && endpoint) { - const rootStore = useRootStore(); - const current = rootStore.versionCli; - const instanceId = rootStore.instanceId; - const versions = await versionsApi.getNextVersions(endpoint, current, instanceId); - setVersions({ versions, currentVersion: current }); - } - } catch (e) { - console.error('Failed to fetch versions:', e); - } - }; - - const setVersions = (params: SetVersionParams) => { - nextVersions.value = params.versions.filter((v) => v.name !== params.currentVersion); - currentVersion.value = params.versions.find((v) => v.name === params.currentVersion); - }; - - const setWhatsNew = (section: WhatsNewSection) => { - whatsNew.value = section; - }; - - const setWhatsNewArticleRead = (articleId: number) => { - if (!readWhatsNewArticles.value.includes(articleId)) { - readWhatsNewArticlesStorage.value = JSON.stringify([ - ...readWhatsNewArticles.value, - articleId, - ]); - } - }; - - const isWhatsNewArticleRead = (articleId: number): boolean => { - 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 => { - const createdAt = usersStore.currentUser?.createdAt; - let hasNewArticle = false; - if (createdAt) { - const userCreatedAt = new Date(createdAt).getTime(); - hasNewArticle = whatsNewArticles.value.some((item) => { - const updatedAt = item.updatedAt ? new Date(item.updatedAt).getTime() : 0; - return updatedAt > userCreatedAt; - }); - } else { - hasNewArticle = true; - } - const allArticlesDismissed = whatsNewArticles.value.every((item) => - lastDismissedWhatsNewCallout.value.includes(item.id), - ); - - return hasNewArticle && !allArticlesDismissed; - }; - - 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, - }); - modalOpeners.value.openModalWithData({ - name: WHATS_NEW_MODAL_KEY, - data: { articleId }, - }); - }, - }); - - // Mark the callout as dismissed as soon as it is shown, so it does not - // keep reappearing on subsequent loads for users who simply ignore it. - dismissWhatsNewCallout(); - } - } - } - } catch (e) { - console.error('Failed to fetch Whats New section:', e); - } - }; - - const initialize = (settings: IVersionNotificationSettings) => { - versionNotificationSettings.value = settings; - }; - - const checkForNewVersions = async () => { - const enabled = areNotificationsEnabled.value; - if (!enabled) { - return; - } - - await Promise.all([fetchVersions(), fetchWhatsNew()]); - - if ( - currentVersion.value && - currentVersion.value.hasSecurityIssue && - nextVersions.value.length - ) { - const fixVersion = currentVersion.value.securityIssueFixVersion; - let message = 'Please update to latest version.'; - if (fixVersion) { - message = `Please update to version ${fixVersion} or higher.`; - } - - message = `${message} More info`; - showToast({ - title: 'Critical update available', - message, - onClick: () => { - modalOpeners.value.openModal(VERSIONS_MODAL_KEY); - }, - closeOnClick: true, - customClass: 'clickable', - type: 'warning', - duration: 0, - }); - } - }; - - // #endregion - - return { - currentVersion, - latestVersion, - nextVersions, - hasVersionUpdates, - hasSignificantUpdates, - areNotificationsEnabled, - infoUrl, - fetchVersions, - setVersions, - initialize, - registerModalOpeners, - checkForNewVersions, - fetchWhatsNew, - whatsNew, - whatsNewArticles, - isWhatsNewArticleRead, - setWhatsNewArticleRead, - closeWhatsNewCallout, - shouldShowWhatsNewCallout, - dismissWhatsNewCallout, - }; -}); +export { useVersionsStore, SEMVER_REGEX } from '@n8n/stores/versions.store';