mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
refactor(editor): Move versions.store into @n8n/stores (no-changelog) (#35142)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
+33
-38
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user