diff --git a/packages/frontend/editor-ui/src/stores/versions.store.test.ts b/packages/frontend/editor-ui/src/stores/versions.store.test.ts index 94bcc3baea..dd9e1cd554 100644 --- a/packages/frontend/editor-ui/src/stores/versions.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/versions.store.test.ts @@ -1,5 +1,6 @@ import { createPinia, setActivePinia } from 'pinia'; import { useVersionsStore } from './versions.store'; +import { useUsersStore } from './users.store'; import * as versionsApi from '@n8n/rest-api-client/api/versions'; import type { IVersionNotificationSettings } from '@n8n/api-types'; import type { Version, WhatsNewArticle, WhatsNewSection } from '@n8n/rest-api-client/api/versions'; @@ -18,6 +19,8 @@ vi.mock('@/composables/useToast', () => { }; }); +vi.mock('./users.store'); + const settings: IVersionNotificationSettings = { enabled: true, endpoint: 'https://test.api.n8n.io/api/versions/', @@ -505,3 +508,86 @@ describe('versions.store', () => { }); }); }); + +describe('shouldShowWhatsNewCallout', () => { + let versionsStore: ReturnType; + + const makeArticle = (overrides: Partial = {}): WhatsNewArticle => ({ + id: 1, + title: 'Test', + content: 'Content', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + publishedAt: new Date().toISOString(), + ...overrides, + }); + + beforeEach(() => { + localStorage.clear(); + setActivePinia(createPinia()); + }); + + 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); + versionsStore = useVersionsStore(); + Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); + versionsStore.whatsNew.items = []; + expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false); + }); + + 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); + versionsStore = useVersionsStore(); + Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); + versionsStore.whatsNew.items = [makeArticle()]; + expect(versionsStore.shouldShowWhatsNewCallout()).toBe(true); + }); + + 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); + versionsStore = useVersionsStore(); + versionsStore.whatsNew.items = [makeArticle()]; + versionsStore.dismissWhatsNewCallout(); + expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false); + }); + + 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); + versionsStore = useVersionsStore(); + Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); + versionsStore.whatsNew.items = [makeArticle({ updatedAt: new Date(now).toISOString() })]; + expect(versionsStore.shouldShowWhatsNewCallout()).toBe(true); + }); + + 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); + versionsStore = useVersionsStore(); + Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); + versionsStore.whatsNew.items = [ + makeArticle({ updatedAt: new Date(now - 10000).toISOString() }), + ]; + expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false); + }); + + 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); + versionsStore = useVersionsStore(); + Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] }); + versionsStore.whatsNew.items = [makeArticle({ updatedAt: undefined })]; + expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/stores/versions.store.ts b/packages/frontend/editor-ui/src/stores/versions.store.ts index affffcfde4..e5a84a287f 100644 --- a/packages/frontend/editor-ui/src/stores/versions.store.ts +++ b/packages/frontend/editor-ui/src/stores/versions.store.ts @@ -15,6 +15,7 @@ import { useToast } from '@/composables/useToast'; import { useUIStore } from '@/stores/ui.store'; import { computed, ref } from 'vue'; import { useSettingsStore } from './settings.store'; +import { useUsersStore } from './users.store'; import { useStorage } from '@/composables/useStorage'; import { jsonParse } from 'n8n-workflow'; import { useTelemetry } from '@/composables/useTelemetry'; @@ -52,6 +53,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => { const { showToast, showMessage } = useToast(); const uiStore = useUIStore(); const settingsStore = useSettingsStore(); + const usersStore = useUsersStore(); const readWhatsNewArticlesStorage = useStorage(LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES); const lastDismissedWhatsNewCalloutStorage = useStorage(LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT); @@ -169,9 +171,22 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => { }; const shouldShowWhatsNewCallout = (): boolean => { - return !whatsNewArticles.value.every((item) => + 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 () => { @@ -273,5 +288,7 @@ export const useVersionsStore = defineStore(STORES.VERSIONS, () => { isWhatsNewArticleRead, setWhatsNewArticleRead, closeWhatsNewCallout, + shouldShowWhatsNewCallout, + dismissWhatsNewCallout, }; });