From 25c730223fb69cc2ff8da1ff0052aca4ba211f06 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 22 Sep 2026 15:31:49 +0000 Subject: [PATCH] feat(editor): Add `N8nTimeAgo` and `N8nSelectedItemsInfo` to `@n8n/design-system` (no-changelog) (#39230) Co-authored-by: Claude Opus 5 (1M context) --- .../frontend/@n8n/design-system/package.json | 3 + .../SelectedItemsInfo.stories.ts | 72 +++++++++++++++++++ .../SelectedItemsInfo.test.ts | 61 ++++++++++++++++ .../SelectedItemsInfo.vue | 33 ++++----- .../SelectedItemsInfo.test.ts.snap | 8 +++ .../component-selected-items-info.md | 45 ++++++++++++ .../components/N8nSelectedItemsInfo/index.ts | 4 ++ .../components/N8nTimeAgo/TimeAgo.stories.ts | 46 ++++++++++++ .../src/components/N8nTimeAgo/TimeAgo.test.ts | 57 +++++++++++++++ .../src/components/N8nTimeAgo/TimeAgo.vue | 37 ++++++++++ .../N8nTimeAgo/component-time-ago.md | 35 +++++++++ .../src/components/N8nTimeAgo/index.ts | 4 ++ .../design-system/src/components/index.ts | 4 ++ .../@n8n/design-system/src/locale/lang/en.ts | 4 ++ .../frontend/@n8n/i18n/src/locales/en.json | 2 - packages/frontend/editor-ui/src/app/App.vue | 2 + .../editor-ui/src/app/components/TimeAgo.vue | 54 +------------- .../common/SelectedItemsInfo.test.ts | 61 ---------------- .../editor-ui/src/app/utils/timeAgoLocale.ts | 27 +++++++ .../editor-ui/src/app/utils/typesUtils.ts | 4 -- .../mcpAccess/components/tabs/AgentsTable.vue | 6 +- .../components/tabs/WorkflowsTable.vue | 6 +- .../components/dataGrid/DataTableTable.vue | 5 +- .../global/GlobalExecutionsList.test.ts | 6 +- .../global/GlobalExecutionsList.vue | 5 +- pnpm-lock.yaml | 9 +++ 26 files changed, 447 insertions(+), 153 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts rename packages/frontend/{editor-ui/src/app/components/common => @n8n/design-system/src/components/N8nSelectedItemsInfo}/SelectedItemsInfo.vue (64%) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/__snapshots__/SelectedItemsInfo.test.ts.snap create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/component-selected-items-info.md create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/TimeAgo.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/component-time-ago.md create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nTimeAgo/index.ts delete mode 100644 packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.test.ts create mode 100644 packages/frontend/editor-ui/src/app/utils/timeAgoLocale.ts diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 9aaae34a07f..c9735660e0d 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -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:" }, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts new file mode 100644 index 00000000000..04363e13279 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.stories.ts @@ -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; + +export default meta; +type Story = StoryObj; + +const methods = { + onDeleteSelected: action('deleteSelected'), + onClearSelection: action('clearSelection'), +}; + +export const Default: Story = { + render: (args) => ({ + components: { N8nSelectedItemsInfo }, + setup() { + return { args, ...methods }; + }, + template: ` +
+ +
+ `, + }), + args: { + selectedCount: 3, + }, +}; + +export const CustomActions: Story = { + render: (args) => ({ + components: { N8nSelectedItemsInfo, N8nButton }, + setup() { + return { args, ...methods }; + }, + template: ` +
+ + + +
+ `, + }), + args: { + selectedCount: 1, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts new file mode 100644 index 00000000000..51f3b0ce225 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.test.ts @@ -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: '' }, + }); + + expect(screen.getByTestId('custom-action')).toBeInTheDocument(); + expect(screen.queryByTestId('delete-selected-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('clear-selection-button')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.vue b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.vue similarity index 64% rename from packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.vue rename to packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.vue index 1aaeddb9801..c7a9fb1dd28 100644 --- a/packages/frontend/editor-ui/src/app/components/common/SelectedItemsInfo.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelectedItemsInfo/SelectedItemsInfo.vue @@ -1,28 +1,27 @@ - @click="onBulkRemoveMcpAccess" /> - + diff --git a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue index 02468fa93ab..5df1a5bb695 100644 --- a/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue +++ b/packages/frontend/editor-ui/src/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue @@ -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 = () => { /> - { @click="onBulkRemoveMcpAccess" /> - + diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue b/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue index 15c0e4aa591..1c35c7d2686 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue +++ b/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue @@ -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({ /> - ({ ...(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'; diff --git a/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue b/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue index 79178f2fa2f..a64fe5f7f97 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue +++ b/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue @@ -1,5 +1,4 @@