mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
5ed678ae54
commit
25c730223f
|
|
@ -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:"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
@ -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>"
|
||||
`;
|
||||
|
|
@ -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>
|
||||
```
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import N8nSelectedItemsInfo from './SelectedItemsInfo.vue';
|
||||
|
||||
export default N8nSelectedItemsInfo;
|
||||
export type { SelectedItemsInfoProps } from './SelectedItemsInfo.vue';
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
```
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import N8nTimeAgo from './TimeAgo.vue';
|
||||
|
||||
export default N8nTimeAgo;
|
||||
export type { TimeAgoProps } from './TimeAgo.vue';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
27
packages/frontend/editor-ui/src/app/utils/timeAgoLocale.ts
Normal file
27
packages/frontend/editor-ui/src/app/utils/timeAgoLocale.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user