feat(editor): Add a last refreshed timestamp to the promotion changes view (no-changelog) (#39320)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Nour Alhadi Mahmoud 2026-09-23 08:52:42 +00:00 committed by GitHub
parent ed4e1ce391
commit bc779f2216
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 126 additions and 3 deletions

View File

@ -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');
});
});

View File

@ -1,7 +1,11 @@
<script lang="ts" setup>
import { useTimestamp } from '@vueuse/core';
import dateformat from 'dateformat';
import { format } from 'timeago.js';
import { computed } from 'vue';
import { computed, watch } from 'vue';
/** Keeps a live label close to the truth without a per-second timer. */
const LIVE_REFRESH_INTERVAL = 30_000;
export interface TimeAgoProps {
/** Date to render, as an ISO 8601 string */
@ -10,15 +14,31 @@ export interface TimeAgoProps {
capitalize?: boolean;
/** Name of a timeago.js locale that the app registered. Omit to use the timeago.js default */
locale?: string;
/** Re-render as the date ages. Off by default, so long lists cost no timers */
live?: boolean;
}
defineOptions({ name: 'N8nTimeAgo' });
const props = withDefaults(defineProps<TimeAgoProps>(), {
capitalize: false,
live: false,
});
// `format()` reads the clock outside reactivity, so only a ticking date ages the label.
const now = useTimestamp({ interval: LIVE_REFRESH_INTERVAL, immediate: props.live });
// The tick trails the clock, so a newer date would read as the future until the next tick.
if (props.live) {
watch(
() => props.date,
() => (now.value = Date.now()),
);
}
const formatted = computed(() => {
const text = format(props.date, props.locale);
const text = props.live
? format(props.date, props.locale, { relativeDate: now.value })
: format(props.date, props.locale);
if (!props.capitalize) {
return text.toLowerCase();

View File

@ -18,6 +18,7 @@ Cards, tables, and detail views show when a resource changed. One component keep
| `date` | `string` | Required | Date to render, as an ISO 8601 string. |
| `capitalize` | `boolean` | `false` | Keep the casing of the locale text instead of lowercasing it. |
| `locale` | `string` | `undefined` | Name of a timeago.js locale that the app registered. Omit to use the timeago.js default. |
| `live` | `boolean` | `false` | Re-render as the date ages, on a 30 second tick. Off by default, so long lists cost no timers. |
### Events

View File

@ -8838,6 +8838,7 @@
"promotions.modal.title": "Promote changes",
"promotions.modal.search.placeholder": "Search changes...",
"promotions.modal.refresh": "Refresh",
"promotions.modal.lastRefreshed": "Last refreshed",
"promotions.modal.status.new": "New",
"promotions.modal.status.modified": "Modified",
"promotions.modal.status.renamed": "Moved / renamed",

View File

@ -5,15 +5,22 @@ import { useRootStore } from '@n8n/stores/useRootStore';
type Props = {
date: string;
capitalize?: boolean;
live?: boolean;
};
withDefaults(defineProps<Props>(), {
capitalize: false,
live: false,
});
const rootStore = useRootStore();
</script>
<template>
<N8nTimeAgo :date="date" :capitalize="capitalize" :locale="rootStore.defaultLocale" />
<N8nTimeAgo
:date="date"
:capitalize="capitalize"
:locale="rootStore.defaultLocale"
:live="live"
/>
</template>

View File

@ -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,

View File

@ -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"
/>
<N8nText
v-if="lastRefreshedAt"
size="small"
color="text-light"
:class="$style.lastRefreshed"
data-test-id="promotion-last-refreshed"
>
{{ i18n.baseText('promotions.modal.lastRefreshed') }}
<TimeAgo :date="lastRefreshedAt" live />
</N8nText>
<N8nButton
variant="subtle"
size="small"
@ -453,6 +464,11 @@ onMounted(async () => {
margin-inline: calc(var(--input--padding) * -1) 0 0;
}
.lastRefreshed {
flex-shrink: 0;
white-space: nowrap;
}
.loading {
display: flex;
justify-content: center;

View File

@ -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,

View File

@ -11,6 +11,7 @@ export function usePromotionChanges(projectId: string, direction: PromotionDirec
const isLoading = ref(false);
const error = ref<Error | null>(null);
const searchQuery = ref('');
const lastRefreshedAt = ref<string | null>(null);
const selectedIds = ref<Set<string>>(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,