feat(editor): Add N8nTimeAgo and N8nSelectedItemsInfo to @n8n/design-system (no-changelog) (#39230)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Grozav 2026-09-22 15:31:49 +00:00 committed by GitHub
parent 5ed678ae54
commit 25c730223f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 447 additions and 153 deletions

View File

@ -62,6 +62,7 @@
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@types/dateformat": "^3.0.0",
"@types/lodash": "catalog:",
"@types/markdown-it": "catalog:",
"@types/markdown-it-emoji": "^2.0.2",
@ -110,6 +111,7 @@
"@tiptap/vue-3": "catalog:",
"@vueuse/core": "catalog:frontend",
"clsx": "^2.1.1",
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",
"highlight.js": "catalog:frontend",
"is-emoji-supported": "^0.0.5",
@ -121,6 +123,7 @@
"parse-diff": "^0.11.1",
"reka-ui": "^2.5.0",
"sanitize-html": "catalog:",
"timeago.js": "^4.0.2",
"vue-boring-avatars": "^1.3.0",
"xss": "catalog:"
},

View File

@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { action } from 'storybook/actions';
import N8nSelectedItemsInfo from './SelectedItemsInfo.vue';
import N8nButton from '../N8nButton';
const meta = {
title: 'Core/SelectedItemsInfo',
component: N8nSelectedItemsInfo,
argTypes: {
selectedCount: { control: 'number' },
},
parameters: {
docs: {
description: {
component:
'Floating bar that shows how many list rows are selected, with bulk actions and a clear-selection button. It is positioned absolutely at the bottom of its closest positioned ancestor.',
},
},
},
} satisfies Meta<typeof N8nSelectedItemsInfo>;
export default meta;
type Story = StoryObj<typeof meta>;
const methods = {
onDeleteSelected: action('deleteSelected'),
onClearSelection: action('clearSelection'),
};
export const Default: Story = {
render: (args) => ({
components: { N8nSelectedItemsInfo },
setup() {
return { args, ...methods };
},
template: `
<div style="position: relative; height: 200px;">
<N8nSelectedItemsInfo
v-bind="args"
@delete-selected="onDeleteSelected"
@clear-selection="onClearSelection"
/>
</div>
`,
}),
args: {
selectedCount: 3,
},
};
export const CustomActions: Story = {
render: (args) => ({
components: { N8nSelectedItemsInfo, N8nButton },
setup() {
return { args, ...methods };
},
template: `
<div style="position: relative; height: 200px;">
<N8nSelectedItemsInfo v-bind="args" @clear-selection="onClearSelection">
<template #actions>
<N8nButton variant="subtle" label="Archive" />
<N8nButton variant="subtle" label="Export" />
</template>
</N8nSelectedItemsInfo>
</div>
`,
}),
args: {
selectedCount: 1,
},
};

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/vue';
import N8nSelectedItemsInfo from './SelectedItemsInfo.vue';
describe('N8nSelectedItemsInfo', () => {
it('matches snapshot', () => {
const { html } = render(N8nSelectedItemsInfo, {
props: { selectedCount: 2 },
global: { stubs: ['N8nButton'] },
});
expect(html()).toMatchSnapshot();
});
it('renders nothing when selectedCount is 0', () => {
render(N8nSelectedItemsInfo, { props: { selectedCount: 0 } });
expect(screen.queryByTestId('selected-items-info')).not.toBeInTheDocument();
});
it('renders the count and both buttons when items are selected', () => {
render(N8nSelectedItemsInfo, { props: { selectedCount: 3 } });
expect(screen.getByTestId('selected-items-info')).toHaveTextContent('3 rows selected');
expect(screen.getByTestId('delete-selected-button')).toHaveTextContent('Delete');
expect(screen.getByTestId('clear-selection-button')).toHaveTextContent('Clear selection');
});
it('uses the singular text for one selected item', () => {
render(N8nSelectedItemsInfo, { props: { selectedCount: 1 } });
expect(screen.getByTestId('selected-items-info')).toHaveTextContent('1 row selected');
});
it('emits deleteSelected when the delete button is clicked', async () => {
const { emitted } = render(N8nSelectedItemsInfo, { props: { selectedCount: 1 } });
await fireEvent.click(screen.getByTestId('delete-selected-button'));
expect(emitted().deleteSelected).toHaveLength(1);
});
it('emits clearSelection when the clear button is clicked', async () => {
const { emitted } = render(N8nSelectedItemsInfo, { props: { selectedCount: 5 } });
await fireEvent.click(screen.getByTestId('clear-selection-button'));
expect(emitted().clearSelection).toHaveLength(1);
});
it('renders the actions slot instead of the default delete button', () => {
render(N8nSelectedItemsInfo, {
props: { selectedCount: 2 },
slots: { actions: '<button data-test-id="custom-action">Archive</button>' },
});
expect(screen.getByTestId('custom-action')).toBeInTheDocument();
expect(screen.queryByTestId('delete-selected-button')).not.toBeInTheDocument();
expect(screen.getByTestId('clear-selection-button')).toBeInTheDocument();
});
});

View File

@ -1,28 +1,27 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { N8nButton } from '@n8n/design-system';
interface Props {
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
export interface SelectedItemsInfoProps {
/** Number of selected items. The component renders nothing when it is 0 */
selectedCount: number;
}
const props = withDefaults(defineProps<Props>(), {});
defineOptions({ name: 'N8nSelectedItemsInfo' });
const props = defineProps<SelectedItemsInfoProps>();
const emit = defineEmits<{
deleteSelected: [];
clearSelection: [];
}>();
const i18n = useI18n();
const { t } = useI18n();
const getSelectedText = () => {
return i18n.baseText('generic.list.selected', {
adjustToNumber: props.selectedCount,
interpolate: { count: `${props.selectedCount}` },
});
};
const key =
props.selectedCount === 1 ? 'selectedItemsInfo.selectedOne' : 'selectedItemsInfo.selectedMany';
const getClearSelectionText = () => {
return i18n.baseText('generic.list.clearSelection');
return t(key, { count: `${props.selectedCount}` });
};
const handleDeleteSelected = () => {
@ -35,11 +34,7 @@ const handleClearSelection = () => {
</script>
<template>
<div
v-if="selectedCount > 0"
:class="$style.selectionOptions"
:data-test-id="`selected-items-info`"
>
<div v-if="selectedCount > 0" :class="$style.selectionOptions" data-test-id="selected-items-info">
<span>
{{ getSelectedText() }}
</span>
@ -48,7 +43,7 @@ const handleClearSelection = () => {
<N8nButton
variant="subtle"
data-test-id="delete-selected-button"
:label="i18n.baseText('generic.delete')"
:label="t('generic.delete')"
:class="$style.button"
@click="handleDeleteSelected"
/>
@ -56,7 +51,7 @@ const handleClearSelection = () => {
<N8nButton
variant="subtle"
data-test-id="clear-selection-button"
:label="getClearSelectionText()"
:label="t('selectedItemsInfo.clearSelection')"
:class="$style.button"
@click="handleClearSelection"
/>

View File

@ -0,0 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`N8nSelectedItemsInfo > matches snapshot 1`] = `
"<div class="selectionOptions" data-test-id="selected-items-info"><span>2 rows selected</span><!-- Custom bulk actions; defaults to the delete button for existing consumers -->
<n8n-button-stub label="Delete" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="button" data-test-id="delete-selected-button"></n8n-button-stub>
<n8n-button-stub label="Clear selection" variant="subtle" size="medium" loading="false" icononly="false" disabled="false" class="button" data-test-id="clear-selection-button"></n8n-button-stub>
</div>"
`;

View File

@ -0,0 +1,45 @@
# N8nSelectedItemsInfo
Floating bar that shows how many list rows are selected, with bulk actions and a clear-selection button.
- **Component name:** `N8nSelectedItemsInfo`
- **W3C APG patterns:** [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/)
## Why?
Lists with row selection (executions, data tables, MCP access tables) show the same selection summary and bulk actions. One component keeps the layout, tokens, and test ids the same everywhere.
The component is positioned absolutely at the bottom of its closest positioned ancestor. A consumer can pass a class to change the positioning.
## Public API
### Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selectedCount` | `number` | Required | Number of selected items. The component renders nothing when it is 0. |
### Events
- `deleteSelected()` when the default delete button is clicked.
- `clearSelection()` when the clear-selection button is clicked.
### Slots
- `actions` replaces the default delete button with custom bulk actions. The clear-selection button always renders after the actions.
## Examples
```vue
<N8nSelectedItemsInfo
:selected-count="selectedCount"
@delete-selected="handleDeleteSelected"
@clear-selection="handleClearSelection"
/>
<N8nSelectedItemsInfo :selected-count="selectedIds.length" @clear-selection="clearSelection">
<template #actions>
<N8nButton variant="subtle" label="Remove access" @click="removeAccess" />
</template>
</N8nSelectedItemsInfo>
```

View File

@ -0,0 +1,4 @@
import N8nSelectedItemsInfo from './SelectedItemsInfo.vue';
export default N8nSelectedItemsInfo;
export type { SelectedItemsInfoProps } from './SelectedItemsInfo.vue';

View File

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import N8nTimeAgo from './TimeAgo.vue';
const minutesFromNow = (minutes: number) =>
new Date(Date.now() + minutes * 60 * 1000).toISOString();
const meta = {
title: 'Core/TimeAgo',
component: N8nTimeAgo,
argTypes: {
date: { control: 'text' },
capitalize: { control: 'boolean' },
locale: { control: 'text' },
},
parameters: {
docs: {
description: {
component:
'Renders a date as a relative time, for example "3 minutes ago". The absolute date is available as the title attribute.',
},
},
},
} satisfies Meta<typeof N8nTimeAgo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
date: minutesFromNow(-3),
},
};
export const Capitalized: Story = {
args: {
date: minutesFromNow(-3),
capitalize: true,
},
};
export const FutureDate: Story = {
args: {
date: minutesFromNow(90),
},
};

View File

@ -0,0 +1,57 @@
import { render } from '@testing-library/vue';
import { register } from 'timeago.js';
import N8nTimeAgo from './TimeAgo.vue';
const NOW = new Date('2026-09-22T12:00:00.000Z');
const THREE_MINUTES_AGO = new Date(NOW.getTime() - 3 * 60 * 1000).toISOString();
describe('N8nTimeAgo', () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW });
});
afterEach(() => {
vi.useRealTimers();
});
it('renders a lowercase relative time with the default locale', () => {
const { container } = render(N8nTimeAgo, { props: { date: THREE_MINUTES_AGO } });
expect(container.textContent?.trim()).toBe('3 minutes ago');
});
it('lowercases the locale text unless capitalize is set', () => {
register('test-capitalized', () => ['Moments ago', 'In moments']);
const lowercased = render(N8nTimeAgo, {
props: { date: THREE_MINUTES_AGO, locale: 'test-capitalized' },
});
expect(lowercased.container.textContent?.trim()).toBe('moments ago');
const capitalized = render(N8nTimeAgo, {
props: { date: THREE_MINUTES_AGO, locale: 'test-capitalized', capitalize: true },
});
expect(capitalized.container.textContent?.trim()).toBe('Moments ago');
});
it('formats with the locale passed through the locale prop', () => {
register('test-indexed', (_number, index) => [`past ${index}`, `future ${index}`]);
const { container } = render(N8nTimeAgo, {
props: { date: THREE_MINUTES_AGO, locale: 'test-indexed' },
});
// index 3 is the "%s minutes ago" row of the timeago.js locale table
expect(container.textContent?.trim()).toBe('past 3');
});
it('sets the absolute date as the title', () => {
const { container } = render(N8nTimeAgo, { props: { date: THREE_MINUTES_AGO } });
// the test harness pins TZ to UTC
expect(container.querySelector('span')?.getAttribute('title')).toContain(
'22 September, 2026 @ 11:57',
);
});
});

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import dateformat from 'dateformat';
import { format } from 'timeago.js';
import { computed } from 'vue';
export interface TimeAgoProps {
/** Date to render, as an ISO 8601 string */
date: string;
/** Keep the casing of the locale text instead of lowercasing it */
capitalize?: boolean;
/** Name of a timeago.js locale that the app registered. Omit to use the timeago.js default */
locale?: string;
}
defineOptions({ name: 'N8nTimeAgo' });
const props = withDefaults(defineProps<TimeAgoProps>(), {
capitalize: false,
});
const formatted = computed(() => {
const text = format(props.date, props.locale);
if (!props.capitalize) {
return text.toLowerCase();
}
return text;
});
const title = computed(() => dateformat(new Date(props.date).getTime(), 'd mmmm, yyyy @ HH:MM Z'));
</script>
<template>
<span :title="title">
{{ formatted }}
</span>
</template>

View File

@ -0,0 +1,35 @@
# N8nTimeAgo
Renders a date as a relative time, for example "3 minutes ago". The absolute date is available as the `title` attribute.
- **Component name:** `N8nTimeAgo`
- **Reference:** [timeago.js](https://github.com/hustcc/timeago.js)
## Why?
Cards, tables, and detail views show when a resource changed. One component keeps the wording and the tooltip format the same everywhere. The component only formats. The app registers its timeago.js locale once and passes the locale name through the `locale` prop, so the strings stay in the app's translation files.
## Public API
### Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |
### Events
None.
### Slots
None.
## Examples
```vue
<N8nTimeAgo :date="workflow.updatedAt" />
<N8nTimeAgo :date="client.grantedAt" capitalize :locale="defaultLocale" />
```

View File

@ -0,0 +1,4 @@
import N8nTimeAgo from './TimeAgo.vue';
export default N8nTimeAgo;
export type { TimeAgoProps } from './TimeAgo.vue';

View File

@ -110,6 +110,8 @@ export { default as N8nOption } from './N8nOption';
export { default as N8nPagination } from './N8nPagination';
export { default as N8nSectionHeader } from './N8nSectionHeader';
export { default as N8nSelectableList } from './N8nSelectableList';
export { default as N8nSelectedItemsInfo } from './N8nSelectedItemsInfo';
export type { SelectedItemsInfoProps } from './N8nSelectedItemsInfo';
export { default as N8nSettingsLayout } from './N8nSettingsLayout';
export type { SettingsLayoutProps } from './N8nSettingsLayout';
export { default as N8nSettingsPageHeader } from './N8nSettingsPageHeader';
@ -167,6 +169,8 @@ export { default as N8nTabs } from './N8nTabs';
export { default as N8nTag } from './N8nTag';
export { default as N8nTags } from './N8nTags';
export { default as N8nText } from './N8nText';
export { default as N8nTimeAgo } from './N8nTimeAgo';
export type { TimeAgoProps } from './N8nTimeAgo';
export { default as N8nTooltip } from './N8nTooltip';
export type { N8nTooltipProps, N8nTooltipSlots } from './N8nTooltip';
export { default as N8nAiActivityStepButton } from './N8nAiActivityStepButton';

View File

@ -17,6 +17,7 @@ export default {
'generic.cancel': 'Cancel',
'generic.close': 'Close',
'generic.moreInfo': 'More info',
'generic.delete': 'Delete',
'aiModelSelector.credentialsMissing': 'Credentials missing',
'nds.auth.roles.owner': 'Owner',
'nds.userInfo.you': '(you)',
@ -53,6 +54,9 @@ export default {
'pagination.pageSizeOption': '{size}/page',
'pagination.goTo': 'Go to',
'pagination.goToPage': 'Go to page',
'selectedItemsInfo.selectedOne': '{count} row selected',
'selectedItemsInfo.selectedMany': '{count} rows selected',
'selectedItemsInfo.clearSelection': 'Clear selection',
'contextMenu.noItems': 'No items',
'codeDiff.couldNotReplace': 'Could not replace code',
'codeDiff.codeReplaced': 'Code replaced',

View File

@ -152,8 +152,6 @@
"generic.unknownError": "An unknown error occurred",
"generic.upgradeToEnterprise": "Upgrade to Enterprise",
"generic.never": "Never",
"generic.list.clearSelection": "Clear selection",
"generic.list.selected": "{count} row selected | {count} rows selected",
"generic.project": "Project",
"generic.projects": "Projects",
"generic.your": "Your",

View File

@ -19,6 +19,7 @@ import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import LoadingView from '@/app/views/LoadingView.vue';
import { locale } from '@n8n/design-system';
import { registerTimeAgoLocale } from '@/app/utils/timeAgoLocale';
import { setLanguage } from '@n8n/i18n';
// Note: no need to import en.json here; default 'en' is handled via setLanguage
import { useRootStore } from '@n8n/stores/useRootStore';
@ -112,6 +113,7 @@ watch(
axios.defaults.headers.common['Accept-Language'] = newLocale;
void locale.use(newLocale);
registerTimeAgoLocale(newLocale);
},
{ immediate: true },
);

View File

@ -1,67 +1,19 @@
<script lang="ts" setup>
import { format, register } from 'timeago.js';
import { convertToHumanReadableDate } from '@/app/utils/typesUtils';
import { computed, onBeforeMount } from 'vue';
import { N8nTimeAgo } from '@n8n/design-system';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useI18n } from '@n8n/i18n';
type Props = {
date: string;
capitalize?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
capitalize: false,
});
const rootStore = useRootStore();
const i18n = useI18n();
const defaultLocale = computed(() => rootStore.defaultLocale);
const formatted = computed(() => {
const text = format(props.date, defaultLocale.value);
if (!props.capitalize) {
return text.toLowerCase();
}
return text;
});
const convertDate = computed(() => {
const date = new Date(props.date);
const epoch = date.getTime();
return convertToHumanReadableDate(epoch);
});
onBeforeMount(() => {
register(defaultLocale.value, localeFunc);
});
function localeFunc(_: number, index: number): [string, string] {
// number: the timeago / timein number;
// index: the index of array below;
return [
[i18n.baseText('timeAgo.justNow'), i18n.baseText('timeAgo.rightNow')],
[i18n.baseText('timeAgo.justNow'), i18n.baseText('timeAgo.rightNow')], // ['%s seconds ago', 'in %s seconds'],
[i18n.baseText('timeAgo.oneMinuteAgo'), i18n.baseText('timeAgo.inOneMinute')],
[i18n.baseText('timeAgo.minutesAgo'), i18n.baseText('timeAgo.inMinutes')],
[i18n.baseText('timeAgo.oneHourAgo'), i18n.baseText('timeAgo.inOneHour')],
[i18n.baseText('timeAgo.hoursAgo'), i18n.baseText('timeAgo.inHours')],
[i18n.baseText('timeAgo.oneDayAgo'), i18n.baseText('timeAgo.inOneDay')],
[i18n.baseText('timeAgo.daysAgo'), i18n.baseText('timeAgo.inDays')],
[i18n.baseText('timeAgo.oneWeekAgo'), i18n.baseText('timeAgo.inOneWeek')],
[i18n.baseText('timeAgo.weeksAgo'), i18n.baseText('timeAgo.inWeeks')],
[i18n.baseText('timeAgo.oneMonthAgo'), i18n.baseText('timeAgo.inOneMonth')],
[i18n.baseText('timeAgo.monthsAgo'), i18n.baseText('timeAgo.inMonths')],
[i18n.baseText('timeAgo.oneYearAgo'), i18n.baseText('timeAgo.inOneYear')],
[i18n.baseText('timeAgo.yearsAgo'), i18n.baseText('timeAgo.inYears')],
][index] as [string, string];
}
</script>
<template>
<span :title="convertDate">
{{ formatted }}
</span>
<N8nTimeAgo :date="date" :capitalize="capitalize" :locale="rootStore.defaultLocale" />
</template>

View File

@ -1,61 +0,0 @@
import { createComponentRenderer } from '@/__tests__/render';
import SelectedItemsInfo from '@/app/components/common/SelectedItemsInfo.vue';
const renderComponent = createComponentRenderer(SelectedItemsInfo);
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
describe('SelectedItemsInfo', () => {
it('should not render when selectedCount is 0', () => {
const { queryByTestId } = renderComponent({
props: {
selectedCount: 0,
},
});
expect(queryByTestId('selected-items-info')).not.toBeInTheDocument();
});
it('should render when selectedCount is greater than 0', () => {
const { getByTestId } = renderComponent({
props: {
selectedCount: 3,
},
});
expect(getByTestId('selected-items-info')).toBeInTheDocument();
expect(getByTestId('delete-selected-button')).toBeInTheDocument();
expect(getByTestId('clear-selection-button')).toBeInTheDocument();
});
it('should emit deleteSelected event when delete button is clicked', () => {
const { getByTestId, emitted } = renderComponent({
props: {
selectedCount: 1,
},
});
getByTestId('delete-selected-button').click();
expect(emitted().deleteSelected).toBeTruthy();
expect(emitted().deleteSelected).toHaveLength(1);
});
it('should emit clearSelection event when clear button is clicked', () => {
const { getByTestId, emitted } = renderComponent({
props: {
selectedCount: 5,
},
});
getByTestId('clear-selection-button').click();
expect(emitted().clearSelection).toBeTruthy();
expect(emitted().clearSelection).toHaveLength(1);
});
});

View File

@ -0,0 +1,27 @@
import { i18n } from '@n8n/i18n';
import { register } from 'timeago.js';
// index: position in the timeago.js locale table; each row is [past, future]
function localeFunc(_number: number, index: number): [string, string] {
return [
[i18n.baseText('timeAgo.justNow'), i18n.baseText('timeAgo.rightNow')],
[i18n.baseText('timeAgo.justNow'), i18n.baseText('timeAgo.rightNow')], // ['%s seconds ago', 'in %s seconds'],
[i18n.baseText('timeAgo.oneMinuteAgo'), i18n.baseText('timeAgo.inOneMinute')],
[i18n.baseText('timeAgo.minutesAgo'), i18n.baseText('timeAgo.inMinutes')],
[i18n.baseText('timeAgo.oneHourAgo'), i18n.baseText('timeAgo.inOneHour')],
[i18n.baseText('timeAgo.hoursAgo'), i18n.baseText('timeAgo.inHours')],
[i18n.baseText('timeAgo.oneDayAgo'), i18n.baseText('timeAgo.inOneDay')],
[i18n.baseText('timeAgo.daysAgo'), i18n.baseText('timeAgo.inDays')],
[i18n.baseText('timeAgo.oneWeekAgo'), i18n.baseText('timeAgo.inOneWeek')],
[i18n.baseText('timeAgo.weeksAgo'), i18n.baseText('timeAgo.inWeeks')],
[i18n.baseText('timeAgo.oneMonthAgo'), i18n.baseText('timeAgo.inOneMonth')],
[i18n.baseText('timeAgo.monthsAgo'), i18n.baseText('timeAgo.inMonths')],
[i18n.baseText('timeAgo.oneYearAgo'), i18n.baseText('timeAgo.inOneYear')],
[i18n.baseText('timeAgo.yearsAgo'), i18n.baseText('timeAgo.inYears')],
][index] as [string, string];
}
/** Register the app's translated strings as the timeago.js locale that `N8nTimeAgo` formats with. */
export function registerTimeAgoLocale(locale: string) {
register(locale, localeFunc);
}

View File

@ -60,10 +60,6 @@ export function convertToDisplayDate(epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
}
export function convertToHumanReadableDate(epochTime: number) {
return dateformat(epochTime, 'd mmmm, yyyy @ HH:MM Z');
}
export function stringSizeInBytes(input: string | IDataObject | IDataObject[] | undefined): number {
if (input === undefined) return 0;

View File

@ -10,9 +10,9 @@ import {
N8nDataTableServer,
N8nLink,
N8nLoading,
N8nSelectedItemsInfo,
N8nText,
} from '@n8n/design-system';
import SelectedItemsInfo from '@/app/components/common/SelectedItemsInfo.vue';
import { AGENT_VIEW, PROJECT_AGENTS } from '@/features/agents/constants';
import router from '@/app/router';
@ -230,7 +230,7 @@ const projectLink = (agent: Agent) =>
/>
</template>
</N8nDataTableServer>
<SelectedItemsInfo
<N8nSelectedItemsInfo
:class="$style['selection-bar']"
:selected-count="selectedAgentIds.length"
@clear-selection="clearSelection"
@ -243,7 +243,7 @@ const projectLink = (agent: Agent) =>
@click="onBulkRemoveMcpAccess"
/>
</template>
</SelectedItemsInfo>
</N8nSelectedItemsInfo>
</div>
</div>
</template>

View File

@ -10,12 +10,12 @@ import {
N8nIcon,
N8nLink,
N8nLoading,
N8nSelectedItemsInfo,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { VIEWS } from '@/app/constants';
import router from '@/app/router';
import SelectedItemsInfo from '@/app/components/common/SelectedItemsInfo.vue';
import WorkflowLocation from '@/features/ai/mcpAccess/components/WorkflowLocation.vue';
import { MCP_TOOLTIP_DELAY } from '@/features/ai/mcpAccess/mcp.constants';
import { getResourcePermissions } from '@n8n/permissions';
@ -283,7 +283,7 @@ const onConnectClick = () => {
/>
</template>
</N8nDataTableServer>
<SelectedItemsInfo
<N8nSelectedItemsInfo
:class="$style['selection-bar']"
:selected-count="selectedWorkflowIds.length"
@clear-selection="clearSelection"
@ -296,7 +296,7 @@ const onConnectClick = () => {
@click="onBulkRemoveMcpAccess"
/>
</template>
</SelectedItemsInfo>
</N8nSelectedItemsInfo>
</div>
</div>
</template>

View File

@ -9,7 +9,6 @@ import { AgGridVue } from 'ag-grid-vue3';
import type { GetRowIdParams, GridReadyEvent, SortChangedEvent } from 'ag-grid-community';
import { n8nTheme } from '@/features/core/dataTable/components/dataGrid/n8nTheme';
import { registerAgGridModulesOnce } from '@/features/core/dataTable/components/dataGrid/registerAgGridModulesOnce';
import SelectedItemsInfo from '@/app/components/common/SelectedItemsInfo.vue';
import {
DATA_TABLE_HEADER_HEIGHT,
DATA_TABLE_ROW_HEIGHT,
@ -29,7 +28,7 @@ import { useI18n } from '@n8n/i18n';
import { GRID_FILTER_CONFIG } from '@/features/core/dataTable/utils/filterMappings';
import { useDebounce } from '@n8n/composables/useDebounce';
import { N8nPagination } from '@n8n/design-system';
import { N8nPagination, N8nSelectedItemsInfo } from '@n8n/design-system';
registerAgGridModulesOnce();
type Props = {
@ -224,7 +223,7 @@ defineExpose({
/>
</div>
</div>
<SelectedItemsInfo
<N8nSelectedItemsInfo
:selected-count="selection.selectedCount.value"
@delete-selected="dataTableOperations.handleDeleteSelected"
@clear-selection="selection.handleClearSelection"

View File

@ -32,10 +32,8 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
displayTimer: (timer: number) => timer,
baseText: (key: string, options: { interpolate: { count: string } }) => {
if (key === 'generic.list.selected') {
return `${options.interpolate.count} executions selected`;
} else if (key === 'executionsList.retryOf') {
baseText: (key: string) => {
if (key === 'executionsList.retryOf') {
return 'Retry of';
} else if (key === 'executionsList.successRetry') {
return 'Success retry';

View File

@ -1,5 +1,4 @@
<script lang="ts" setup>
import SelectedItemsInfo from '@/app/components/common/SelectedItemsInfo.vue';
import { useMessage } from '@/app/composables/useMessage';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useTelemetry } from '@n8n/composables/useTelemetry';
@ -22,7 +21,7 @@ import ExecutionsFilter from '../ExecutionsFilter.vue';
import ExecutionStopAllText from '../ExecutionStopAllText.vue';
import GlobalExecutionsListItem from './GlobalExecutionsListItem.vue';
import { N8nButton, N8nCheckbox, N8nTableBase } from '@n8n/design-system';
import { N8nButton, N8nCheckbox, N8nSelectedItemsInfo, N8nTableBase } from '@n8n/design-system';
import { ElSkeletonItem } from 'element-plus';
const props = withDefaults(
@ -479,7 +478,7 @@ const goToUpgrade = () => {
</tbody>
</N8nTableBase>
</div>
<SelectedItemsInfo
<N8nSelectedItemsInfo
:selected-count="selectedCount"
@delete-selected="handleDeleteSelected"
@clear-selection="handleClearSelection"

View File

@ -5382,6 +5382,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
dateformat:
specifier: ^3.0.3
version: 3.0.3
element-plus:
specifier: catalog:frontend
version: 2.4.3(patch_hash=9547bca88969f416e6132b09a48e71a969dce25ee6669c7f1f78d51ce9b80e7c)(vue@3.5.26(typescript@6.0.2))
@ -5415,6 +5418,9 @@ importers:
sanitize-html:
specifier: 'catalog:'
version: 2.17.7
timeago.js:
specifier: ^4.0.2
version: 4.0.2
vue-boring-avatars:
specifier: ^1.3.0
version: 1.3.0(vue@3.5.26(typescript@6.0.2))
@ -5455,6 +5461,9 @@ importers:
'@testing-library/vue':
specifier: catalog:frontend
version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@6.0.2))
'@types/dateformat':
specifier: ^3.0.0
version: 3.0.1
'@types/lodash':
specifier: 'catalog:'
version: 4.17.17