mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
ed4e1ce391
commit
bc779f2216
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user