refactor(editor): Move versions.store into @n8n/stores (no-changelog) (#35142)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alex Grozav
2026-07-29 09:41:40 +03:00
committed by GitHub
parent c92347371a
commit cd4384e879
6 changed files with 366 additions and 353 deletions
@@ -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';
@@ -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<IUser> | null) => {
vi.mocked(useUsersStore).mockReturnValue(
mock<ReturnType<typeof useUsersStore>>({
currentUser: currentUser && mock<IUser>(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 })];
@@ -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 =
/^(?<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,
whatsNewEnabled: false,
endpoint: '',
whatsNewEndpoint: '',
infoUrl: '',
});
const nextVersions = ref<Version[]>([]);
const currentVersion = ref<Version | undefined>();
const whatsNew = ref<WhatsNewSection>({
title: '',
createdAt: new Date().toISOString(),
updatedAt: null,
calloutText: '',
footer: '',
items: [],
});
const whatsNewCallout = ref<NotificationHandle | undefined>();
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<ModalOpeners>({
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} <a class="primary-color">More info</a>`;
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,
};
});
@@ -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';
@@ -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';
@@ -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 =
/^(?<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,
whatsNewEnabled: false,
endpoint: '',
whatsNewEndpoint: '',
infoUrl: '',
});
const nextVersions = ref<Version[]>([]);
const currentVersion = ref<Version | undefined>();
const whatsNew = ref<WhatsNewSection>({
title: '',
createdAt: new Date().toISOString(),
updatedAt: null,
calloutText: '',
footer: '',
items: [],
});
const whatsNewCallout = ref<NotificationHandle | undefined>();
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<ModalOpeners>({
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} <a class="primary-color">More info</a>`;
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';