From bc779f22160c7a9d7c60cbb128c8bc96e7ee89ba Mon Sep 17 00:00:00 2001 From: Nour Alhadi Mahmoud Date: Wed, 23 Sep 2026 08:52:42 +0000 Subject: [PATCH] feat(editor): Add a last refreshed timestamp to the promotion changes view (no-changelog) (#39320) Co-authored-by: Claude Fable 5.1 --- .../src/components/N8nTimeAgo/TimeAgo.test.ts | 35 +++++++++++++++++++ .../src/components/N8nTimeAgo/TimeAgo.vue | 24 +++++++++++-- .../N8nTimeAgo/component-time-ago.md | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../editor-ui/src/app/components/TimeAgo.vue | 9 ++++- .../components/PromotionSelectModal.test.ts | 15 ++++++++ .../components/PromotionSelectModal.vue | 16 +++++++++ .../composables/usePromotionChanges.test.ts | 25 +++++++++++++ .../composables/usePromotionChanges.ts | 3 ++ 9 files changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts index e61d0498ea3..39f18dd1358 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts @@ -1,5 +1,6 @@ import { render } from '@testing-library/vue'; import { register } from 'timeago.js'; +import { nextTick } from 'vue'; import N8nTimeAgo from './TimeAgo.vue'; @@ -54,4 +55,38 @@ describe('N8nTimeAgo', () => { '22 September, 2026 @ 11:57', ); }); + + it('ages a live label once per interval', async () => { + const { container } = render(N8nTimeAgo, { props: { date: NOW.toISOString(), live: true } }); + expect(container.textContent?.trim()).toBe('just now'); + + vi.advanceTimersByTime(29 * 1000); + await nextTick(); + expect(container.textContent?.trim()).toBe('just now'); + + vi.advanceTimersByTime(5 * 60 * 1000); + await nextTick(); + expect(container.textContent?.trim()).toBe('5 minutes ago'); + }); + + it('keeps a static label at its first value', async () => { + const { container } = render(N8nTimeAgo, { props: { date: NOW.toISOString() } }); + + vi.advanceTimersByTime(5 * 60 * 1000); + await nextTick(); + + expect(container.textContent?.trim()).toBe('just now'); + }); + + it('reads a date set between two ticks as the past', async () => { + const { container, rerender } = render(N8nTimeAgo, { + props: { date: NOW.toISOString(), live: true }, + }); + + // The reference date trails the clock by up to one tick, so a newer date must not read as the future. + vi.advanceTimersByTime(10 * 1000); + await rerender({ date: new Date().toISOString(), live: true }); + + expect(container.textContent?.trim()).toBe('just now'); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue index d516dad3977..683db2cce14 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue @@ -1,7 +1,11 @@ diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts index 8f7df8d7495..a049483fe7f 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.test.ts @@ -114,6 +114,21 @@ describe('PromotionSelectModal', () => { }); }); + it('should show when the changes were last refreshed', async () => { + const { findByTestId, findByText } = renderComponent({ + pinia, + props: { + modalName: PROMOTION_SELECT_MODAL_KEY, + data: { projectId: 'project-1', direction: 'promote' }, + }, + }); + await findByText('Payment Handler'); + + expect(await findByTestId('promotion-last-refreshed')).toHaveTextContent( + 'Last refreshed just now', + ); + }); + it('should disable promote button when nothing selected', async () => { const { findByTestId } = renderComponent({ pinia, diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue index 55262d6ee08..94036e5f0b0 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/components/PromotionSelectModal.vue @@ -50,6 +50,7 @@ const { isLoading, error, searchQuery, + lastRefreshedAt, selectedIds, selectedCount, allSelected, @@ -240,6 +241,16 @@ onMounted(async () => { data-test-id="promotion-search" :class="$style.searchInput" /> + + {{ i18n.baseText('promotions.modal.lastRefreshed') }} + + { margin-inline: calc(var(--input--padding) * -1) 0 0; } +.lastRefreshed { + flex-shrink: 0; + white-space: nowrap; +} + .loading { display: flex; justify-content: center; diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts index 0eda6019831..ba2c56303a2 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.test.ts @@ -101,6 +101,31 @@ describe('usePromotionChanges', () => { expect(isLoading.value).toBe(false); }); + it('should stamp the last refresh on success only', async () => { + vi.useFakeTimers(); + try { + const { fetchChanges, lastRefreshedAt } = usePromotionChanges('project-1'); + expect(lastRefreshedAt.value).toBeNull(); + + await fetchChanges(); + const firstRefresh = lastRefreshedAt.value; + expect(firstRefresh).not.toBeNull(); + + vi.mocked(promotionsApi.getPromotableChanges).mockRejectedValueOnce( + new Error('Network error'), + ); + await fetchChanges(); + expect(lastRefreshedAt.value).toBe(firstRefresh); + + // Two stamps in the same millisecond would compare equal, so move the clock first. + vi.advanceTimersByTime(60_000); + await fetchChanges(); + expect(lastRefreshedAt.value).not.toBe(firstRefresh); + } finally { + vi.useRealTimers(); + } + }); + it('should select only the visible rows when a search filter is active', async () => { const { fetchChanges, diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts index 3819df97a64..0feea08e3c7 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChanges.ts @@ -11,6 +11,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec const isLoading = ref(false); const error = ref(null); const searchQuery = ref(''); + const lastRefreshedAt = ref(null); const selectedIds = ref>(new Set()); @@ -49,6 +50,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec changes.value = result.changes; commitSha.value = result.commitSha; reconcileSelection(); + lastRefreshedAt.value = new Date().toISOString(); } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)); } finally { @@ -84,6 +86,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec isLoading, error, searchQuery, + lastRefreshedAt, selectedIds, selectedCount, allSelected,