diff --git a/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.stories.ts new file mode 100644 index 0000000000..b587c9780e --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.stories.ts @@ -0,0 +1,72 @@ +import type { StoryFn } from '@storybook/vue3'; + +import N8nExternalLink from './ExternalLink.vue'; + +export default { + title: 'Atoms/ExternalLink', + component: N8nExternalLink, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium', 'large'], + }, + newWindow: { + control: 'boolean', + }, + }, +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + N8nExternalLink, + }, + template: '{{ args.default }}', +}); + +export const IconOnly = Template.bind({}); +IconOnly.args = { + href: 'https://n8n.io', + size: 'medium', + newWindow: true, +}; + +export const WithText = Template.bind({}); +WithText.args = { + href: 'https://n8n.io', + size: 'medium', + newWindow: true, + default: 'Visit n8n', +}; + +export const Small = Template.bind({}); +Small.args = { + href: 'https://n8n.io', + size: 'small', + newWindow: true, + default: 'Visit n8n', +}; + +export const Large = Template.bind({}); +Large.args = { + href: 'https://n8n.io', + size: 'large', + newWindow: true, + default: 'Visit n8n', +}; + +export const SameWindow = Template.bind({}); +SameWindow.args = { + href: 'https://n8n.io', + size: 'medium', + newWindow: false, + default: 'Visit n8n', +}; + +export const WithClickHandler = Template.bind({}); +WithClickHandler.args = { + size: 'medium', + onClick: () => alert('Clicked!'), + default: 'Click me', +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.test.ts new file mode 100644 index 0000000000..c3f41f3303 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.test.ts @@ -0,0 +1,138 @@ +import { render } from '@testing-library/vue'; + +import N8nExternalLink from './ExternalLink.vue'; + +const stubs = ['n8n-icon']; + +describe('components', () => { + describe('N8nExternalLink', () => { + it('should render correctly with href', () => { + const wrapper = render(N8nExternalLink, { + props: { + href: 'https://n8n.io', + }, + global: { + stubs, + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render correctly without href', () => { + const wrapper = render(N8nExternalLink, { + props: {}, + global: { + stubs, + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render correctly with slot content', () => { + const wrapper = render(N8nExternalLink, { + props: { href: 'https://n8n.io' }, + slots: { default: 'Visit n8n' }, + global: { + stubs, + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + describe('props', () => { + describe('href', () => { + it('should set href attribute', () => { + const href = 'https://example.com'; + const wrapper = render(N8nExternalLink, { + props: { href }, + global: { stubs }, + }); + const link = wrapper.getByRole('link'); + expect(link.getAttribute('href')).toBe(href); + }); + }); + + describe('newWindow', () => { + it('should open in new window by default', () => { + const wrapper = render(N8nExternalLink, { + props: { href: 'https://n8n.io' }, + global: { stubs }, + }); + const link = wrapper.getByRole('link'); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should not open in new window when newWindow is false', () => { + const wrapper = render(N8nExternalLink, { + props: { + href: 'https://n8n.io', + newWindow: false, + }, + global: { stubs }, + }); + const link = wrapper.getByRole('link'); + expect(link.getAttribute('target')).toBeNull(); + expect(link.getAttribute('rel')).toBeNull(); + }); + }); + + describe('size', () => { + it('should pass size prop to icon', () => { + const wrapper = render(N8nExternalLink, { + props: { + href: 'https://n8n.io', + size: 'large', + }, + global: { stubs }, + }); + const iconStub = wrapper.html(); + expect(iconStub).toContain('size="large"'); + }); + }); + }); + + describe('element type', () => { + it('should render as anchor when href is provided', () => { + const wrapper = render(N8nExternalLink, { + props: { href: 'https://n8n.io' }, + global: { stubs }, + }); + const element = wrapper.getByRole('link'); + expect(element.tagName).toBe('A'); + }); + + it('should render as button when href is not provided', () => { + const wrapper = render(N8nExternalLink, { + props: {}, + global: { stubs }, + }); + const element = wrapper.getByRole('button'); + expect(element.tagName).toBe('BUTTON'); + }); + }); + + describe('slot content', () => { + it('should display slot content before icon', () => { + const wrapper = render(N8nExternalLink, { + props: { href: 'https://n8n.io' }, + slots: { default: 'Visit n8n' }, + global: { stubs }, + }); + const link = wrapper.getByRole('link'); + expect(link).toHaveTextContent('Visit n8n'); + }); + }); + + describe('styling', () => { + it('should have base text color initially', () => { + const wrapper = render(N8nExternalLink, { + props: { href: 'https://n8n.io' }, + global: { stubs }, + }); + const link = wrapper.getByRole('link'); + expect(link).toHaveClass('link'); + }); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.vue b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.vue new file mode 100644 index 0000000000..42f81f82a5 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/ExternalLink.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/__snapshots__/ExternalLink.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/__snapshots__/ExternalLink.test.ts.snap new file mode 100644 index 0000000000..7f23e2d3b9 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/__snapshots__/ExternalLink.test.ts.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`components > N8nExternalLink > should render correctly with href 1`] = ` +" + +" +`; + +exports[`components > N8nExternalLink > should render correctly with slot content 1`] = `"Visit n8n"`; + +exports[`components > N8nExternalLink > should render correctly without href 1`] = ` +"" +`; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/index.ts new file mode 100644 index 0000000000..3acdf066f4 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nExternalLink/index.ts @@ -0,0 +1,3 @@ +import N8nExternalLink from './ExternalLink.vue'; + +export default N8nExternalLink; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/__snapshots__/Icon.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nIcon/__snapshots__/Icon.test.ts.snap index aae79e6a2c..cc4054c0d5 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/__snapshots__/Icon.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/__snapshots__/Icon.test.ts.snap @@ -18,12 +18,6 @@ exports[`Icon > should render correctly with a deprecated icon 1`] = ` " `; -exports[`Icon > should render correctly with all props combined 1`] = ` -"" -`; - exports[`Icon > should render correctly with default props 1`] = ` "
+ + + + +
+ `, +}); + +export const SimpleExample = Template.bind({}); +SimpleExample.args = {}; +SimpleExample.storyName = 'With Form Inputs'; + +const ScrollableTemplate: StoryFn = (args) => ({ + setup() { + const isOpen = ref(false); + return { args, isOpen }; + }, + components: { + N8nPopoverReka, + N8nButton, + }, + template: ` +
+ + + + +
+ `, +}); + +export const WithScrolling = ScrollableTemplate.bind({}); +WithScrolling.args = { + maxHeight: '300px', + enableScrolling: true, + scrollType: 'hover', +}; +WithScrolling.storyName = 'With Scrollable Content'; + +export const AlwaysVisibleScrollbars = ScrollableTemplate.bind({}); +AlwaysVisibleScrollbars.args = { + maxHeight: '250px', + enableScrolling: true, + scrollType: 'always', +}; +AlwaysVisibleScrollbars.storyName = 'Always Visible Scrollbars'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPopoverReka/N8nPopoverReka.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nPopoverReka/N8nPopoverReka.test.ts new file mode 100644 index 0000000000..9193501729 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nPopoverReka/N8nPopoverReka.test.ts @@ -0,0 +1,116 @@ +import { render } from '@testing-library/vue'; +import { mount } from '@vue/test-utils'; +import { vi } from 'vitest'; + +import N8nPopoverReka from './N8nPopoverReka.vue'; + +const defaultStubs = { + PopoverContent: { + template: + '', + props: ['open', 'side', 'sideOffset', 'class', 'style'], + }, + PopoverPortal: { template: '' }, + PopoverRoot: { + template: + '', + props: ['open'], + emits: ['update:open'], + }, + PopoverTrigger: { + template: '', + props: ['asChild'], + }, +}; + +describe('N8nPopoverReka', () => { + it('should render correctly with default props', () => { + const wrapper = render(N8nPopoverReka, { + props: {}, + global: { + stubs: defaultStubs, + }, + slots: { + trigger: ''); + expect(wrapper.html()).toContain(''); + }); + + it('should emit update:open with false when close function is called', () => { + let closeFunction: (() => void) | undefined; + + const wrapper = render(N8nPopoverReka, { + props: {}, + global: { + stubs: { + ...defaultStubs, + PopoverContent: { + template: + '', + props: ['side', 'sideOffset', 'class'], + setup() { + const mockClose = vi.fn(() => { + if (closeFunction) { + closeFunction(); + } + }); + return { mockClose }; + }, + }, + }, + }, + slots: { + trigger: ''; + }, + }, + }); + + // Call the close function + if (closeFunction) { + closeFunction(); + } + + expect(wrapper.emitted()).toHaveProperty('update:open'); + expect(wrapper.emitted()['update:open']).toContainEqual([false]); + }); + + it('should apply maxHeight style when maxHeight prop is provided', () => { + const wrapper = mount(N8nPopoverReka, { + props: { + maxHeight: '200px', + }, + global: { + stubs: defaultStubs, + }, + slots: { + trigger: ' + + +
+
+
+
+ +
+
+
+ + + + +
+
+
+" +`; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPopoverReka/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nPopoverReka/index.ts new file mode 100644 index 0000000000..53c6684abc --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nPopoverReka/index.ts @@ -0,0 +1,3 @@ +import N8nPopoverReka from './N8nPopoverReka.vue'; + +export default N8nPopoverReka; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.stories.ts new file mode 100644 index 0000000000..720ff84401 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.stories.ts @@ -0,0 +1,170 @@ +import type { StoryFn } from '@storybook/vue3'; + +import N8nScrollArea from './N8nScrollArea.vue'; + +export default { + title: 'Atoms/ScrollArea', + component: N8nScrollArea, + argTypes: { + type: { + control: 'select', + options: ['auto', 'always', 'scroll', 'hover'], + }, + dir: { + control: 'select', + options: ['ltr', 'rtl'], + }, + scrollHideDelay: { + control: 'number', + }, + maxHeight: { + control: 'text', + }, + maxWidth: { + control: 'text', + }, + enableHorizontalScroll: { + control: 'boolean', + }, + enableVerticalScroll: { + control: 'boolean', + }, + }, +}; + +const Template: StoryFn = (args) => ({ + setup() { + return { args }; + }, + components: { + N8nScrollArea, + }, + template: ` +
+ +
+

Scrollable Content

+

+ This is a scrollable area with custom styled scrollbars. The content will scroll when it overflows the container. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+

+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. +

+

+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +

+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium. +

+
+
+
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + type: 'hover', + enableVerticalScroll: true, + enableHorizontalScroll: false, +}; + +export const AlwaysVisible = Template.bind({}); +AlwaysVisible.args = { + type: 'always', + enableVerticalScroll: true, + enableHorizontalScroll: false, +}; + +export const WithMaxHeight = Template.bind({}); +WithMaxHeight.args = { + type: 'hover', + maxHeight: '150px', + enableVerticalScroll: true, + enableHorizontalScroll: false, +}; + +const HorizontalScrollTemplate: StoryFn = (args) => ({ + setup() { + return { args }; + }, + components: { + N8nScrollArea, + }, + template: ` +
+ +
+ Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. +
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
+
+
+
+ `, +}); + +export const HorizontalScroll = HorizontalScrollTemplate.bind({}); +HorizontalScroll.args = { + type: 'hover', + enableVerticalScroll: false, + enableHorizontalScroll: true, +}; + +export const BothDirections = HorizontalScrollTemplate.bind({}); +BothDirections.args = { + type: 'hover', + enableVerticalScroll: true, + enableHorizontalScroll: true, +}; + +const InPopoverTemplate: StoryFn = (args) => ({ + setup() { + return { args }; + }, + components: { + N8nScrollArea, + }, + template: ` +
+ +
+

Long Menu Items

+
+ Menu item {{ i }}: Some descriptive text that might be quite long +
+
+
+
+ `, +}); + +export const InPopover = InPopoverTemplate.bind({}); +InPopover.args = { + type: 'hover', + maxHeight: '200px', + enableVerticalScroll: true, + enableHorizontalScroll: false, +}; +InPopover.storyName = 'Popover Example'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.test.ts new file mode 100644 index 0000000000..a62862274c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.test.ts @@ -0,0 +1,148 @@ +import { render } from '@testing-library/vue'; + +import N8nScrollArea from './N8nScrollArea.vue'; + +describe('N8nScrollArea', () => { + it('should render correctly with default props', () => { + const wrapper = render(N8nScrollArea, { + slots: { + default: '
Test content
', + }, + }); + + expect(wrapper.container).toMatchSnapshot(); + }); + + it('should render with custom type prop', () => { + const wrapper = render(N8nScrollArea, { + props: { + type: 'always', + }, + slots: { + default: '
Test content
', + }, + }); + + const scrollAreaRoot = wrapper.container.querySelector('.scrollAreaRoot'); + expect(scrollAreaRoot).toBeInTheDocument(); + }); + + it('should render with maxHeight style when provided', () => { + const wrapper = render(N8nScrollArea, { + props: { + maxHeight: '200px', + }, + slots: { + default: '
Test content
', + }, + }); + + const viewport = wrapper.container.querySelector('[style*="max-height"]'); + expect(viewport).toHaveStyle({ maxHeight: '200px' }); + }); + + it('should render with maxWidth style when provided', () => { + const wrapper = render(N8nScrollArea, { + props: { + maxWidth: '300px', + }, + slots: { + default: '
Test content
', + }, + }); + + const viewport = wrapper.container.querySelector('[style*="max-width"]'); + expect(viewport).toHaveStyle({ maxWidth: '300px' }); + }); + + it('should render with enableVerticalScroll prop', () => { + const wrapper = render(N8nScrollArea, { + props: { + enableVerticalScroll: true, + }, + slots: { + default: '
Test content
', + }, + }); + + // Check that the component renders successfully with the prop + expect(wrapper.container.querySelector('.scrollAreaRoot')).toBeInTheDocument(); + expect(wrapper.container.querySelector('.viewport')).toBeInTheDocument(); + }); + + it('should render with enableHorizontalScroll prop', () => { + const wrapper = render(N8nScrollArea, { + props: { + enableHorizontalScroll: true, + }, + slots: { + default: '
Test content
', + }, + }); + + // Check that the component renders successfully with the prop + expect(wrapper.container.querySelector('.scrollAreaRoot')).toBeInTheDocument(); + expect(wrapper.container.querySelector('.viewport')).toBeInTheDocument(); + }); + + it('should render with both scroll directions enabled', () => { + const wrapper = render(N8nScrollArea, { + props: { + enableVerticalScroll: true, + enableHorizontalScroll: true, + }, + slots: { + default: '
Test content
', + }, + }); + + // Check that the component renders successfully with both props + expect(wrapper.container.querySelector('.scrollAreaRoot')).toBeInTheDocument(); + expect(wrapper.container.querySelector('.viewport')).toBeInTheDocument(); + }); + + it('should render with scroll disabled', () => { + const wrapper = render(N8nScrollArea, { + props: { + enableVerticalScroll: false, + enableHorizontalScroll: false, + }, + slots: { + default: '
Test content
', + }, + }); + + // Check that the component still renders properly even with scrollbars disabled + expect(wrapper.container.querySelector('.scrollAreaRoot')).toBeInTheDocument(); + expect(wrapper.container.querySelector('.viewport')).toBeInTheDocument(); + }); + + it('should pass dir prop to ScrollAreaRoot', () => { + const wrapper = render(N8nScrollArea, { + props: { + dir: 'rtl', + }, + slots: { + default: '
Test content
', + }, + }); + + const scrollAreaRoot = wrapper.container.querySelector('.scrollAreaRoot'); + expect(scrollAreaRoot).toHaveAttribute('dir', 'rtl'); + }); + + it('should pass scrollHideDelay prop to ScrollAreaRoot', () => { + const wrapper = render(N8nScrollArea, { + props: { + scrollHideDelay: 1000, + }, + slots: { + default: '
Test content
', + }, + }); + + // Note: This would need to be tested with more sophisticated testing + // as the scrollHideDelay is an internal prop that affects behavior + expect(wrapper.container).toMatchSnapshot(); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.vue b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.vue new file mode 100644 index 0000000000..7281d9074b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/N8nScrollArea.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/__snapshots__/N8nScrollArea.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/__snapshots__/N8nScrollArea.test.ts.snap new file mode 100644 index 0000000000..389cf19a93 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/__snapshots__/N8nScrollArea.test.ts.snap @@ -0,0 +1,75 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`N8nScrollArea > should pass scrollHideDelay prop to ScrollAreaRoot 1`] = ` +
+
+ + +
+
+ + +
+ Test content +
+ + +
+
+ + + + + + +
+
+`; + +exports[`N8nScrollArea > should render correctly with default props 1`] = ` +
+
+ + +
+
+ + +
+ Test content +
+ + +
+
+ + + + + + +
+
+`; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/index.ts new file mode 100644 index 0000000000..8079b1af0b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nScrollArea/index.ts @@ -0,0 +1,3 @@ +import N8nScrollArea from './N8nScrollArea.vue'; + +export default N8nScrollArea; diff --git a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.stories.ts b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.stories.ts new file mode 100644 index 0000000000..73e71bb845 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.stories.ts @@ -0,0 +1,112 @@ +import type { StoryFn } from '@storybook/vue3'; +import { ref } from 'vue'; + +import TableHeaderControlsButton from './TableHeaderControlsButton.vue'; + +interface ColumnHeader { + key: string; + label: string; + visible: boolean; +} + +interface StoryArgs { + columns: ColumnHeader[]; +} + +export default { + title: 'Modules/TableHeaderControlsButton', + component: TableHeaderControlsButton, +}; + +const Template: StoryFn = (args) => ({ + setup: () => { + const columns = ref([...args.columns]); + + const handleColumnVisibilityUpdate = (key: string, visibility: boolean) => { + const column = columns.value.find((col: ColumnHeader) => col.key === key); + if (column) { + column.visible = visibility; + } + }; + + const handleColumnOrderUpdate = (newOrder: string[]) => { + const reorderedColumns = newOrder.map( + (key: string) => columns.value.find((col: ColumnHeader) => col.key === key)!, + ); + const hiddenColumns = columns.value.filter((col: ColumnHeader) => !col.visible); + columns.value = [...reorderedColumns, ...hiddenColumns]; + }; + + return { + columns, + handleColumnVisibilityUpdate, + handleColumnOrderUpdate, + }; + }, + components: { + TableHeaderControlsButton, + }, + template: ` + + `, +}); + +export const AllColumnsShown = Template.bind({}); +AllColumnsShown.args = { + columns: [ + { key: 'name', label: 'Name', visible: true }, + { key: 'email', label: 'Email', visible: true }, + { key: 'role', label: 'Role', visible: true }, + { key: 'status', label: 'Status', visible: true }, + { key: 'created', label: 'Created', visible: true }, + ], +}; + +export const ALotOfColumnsShown = Template.bind({}); +ALotOfColumnsShown.args = { + columns: [ + { key: 'name', label: 'Name', visible: true }, + { key: 'email', label: 'Email', visible: true }, + { key: 'role', label: 'Role', visible: true }, + { key: 'status', label: 'Status', visible: false }, + { key: 'created', label: 'Created', visible: true }, + { key: 'department', label: 'Department', visible: true }, + { key: 'manager', label: 'Manager', visible: false }, + { key: 'location', label: 'Location', visible: true }, + { key: 'phone', label: 'Phone', visible: false }, + { key: 'salary', label: 'Salary', visible: false }, + { key: 'startDate', label: 'Start Date', visible: true }, + { key: 'endDate', label: 'End Date', visible: false }, + { key: 'projects', label: 'Projects', visible: true }, + { key: 'skills', label: 'Skills', visible: false }, + { key: 'experience', label: 'Experience', visible: true }, + { key: 'education', label: 'Education', visible: false }, + { key: 'certifications', label: 'Certifications', visible: false }, + { key: 'languages', label: 'Languages', visible: false }, + { key: 'notes', label: 'Notes', visible: false }, + { key: 'lastLogin', label: 'Last Login', visible: true }, + ], +}; + +export const SomeColumnsHidden = Template.bind({}); +SomeColumnsHidden.args = { + columns: [ + { key: 'name', label: 'Name', visible: true }, + { key: 'email', label: 'Email', visible: false }, + { key: 'role', label: 'Role', visible: true }, + { key: 'status', label: 'Status', visible: false }, + { key: 'created', label: 'Created', visible: true }, + ], +}; + +export const MinimalColumns = Template.bind({}); +MinimalColumns.args = { + columns: [ + { key: 'name', label: 'Name', visible: true }, + { key: 'email', label: 'Email', visible: false }, + ], +}; diff --git a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.test.ts b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.test.ts new file mode 100644 index 0000000000..6fdbb9401a --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.test.ts @@ -0,0 +1,977 @@ +import { render, fireEvent } from '@testing-library/vue'; +import { vi } from 'vitest'; + +import type { ColumnHeader } from './TableHeaderControlsButton.vue'; +import TableHeaderControlsButton from './TableHeaderControlsButton.vue'; + +// Mock the useI18n composable +vi.mock('@n8n/design-system/composables/useI18n', () => ({ + useI18n: () => ({ + t: (key: string) => { + const translations: Record = { + 'tableControlsButton.display': 'Display', + 'tableControlsButton.shown': 'Shown columns', + 'tableControlsButton.hidden': 'Hidden columns', + }; + return translations[key] || key; + }, + }), +})); + +// Helper function to create test columns +const createTestColumns = (): ColumnHeader[] => [ + { key: 'col1', label: 'Column 1', disabled: false, visible: true }, + { key: 'col2', label: 'Column 2', disabled: false, visible: true }, + { key: 'col3', label: 'Column 3', disabled: false, visible: false }, + { key: 'col4', label: 'Column 4', disabled: false, visible: true }, + { key: 'col5', label: 'Column 5', disabled: false, visible: false }, +]; + +// Helper function to create drag event +const createDragEvent = (type: string, dataTransfer?: Partial) => { + const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent; + + // Mock dataTransfer + const mockDataTransfer = { + effectAllowed: 'none', + dropEffect: 'none', + files: [] as unknown as FileList, + items: {} as DataTransferItemList, + types: [], + clearData: vi.fn(), + getData: vi.fn().mockReturnValue(''), + setData: vi.fn(), + setDragImage: vi.fn(), + ...dataTransfer, + }; + + Object.defineProperty(event, 'dataTransfer', { + value: mockDataTransfer, + writable: false, + }); + + return event; +}; + +const defaultStubs = { + N8nButton: true, + N8nIcon: true, + N8nPopoverReka: { + template: + '
', + props: [], + }, +}; + +describe('TableHeaderControlsButton', () => { + it('should render correctly with mixed visible and hidden columns', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + // Verify correct count of visible and hidden columns + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + expect(visibleColumns).toHaveLength(3); // col1, col2, col4 are visible + expect(hiddenColumns).toHaveLength(2); // col3, col5 are hidden + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render correctly with all columns visible', () => { + const columns = createTestColumns().map((col) => ({ ...col, visible: true })); + const wrapper = render(TableHeaderControlsButton, { + props: { + columns, + }, + global: { + stubs: defaultStubs, + }, + }); + // Verify correct count of visible and hidden columns + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + expect(visibleColumns).toHaveLength(5); // All columns are visible + expect(hiddenColumns).toHaveLength(0); // No columns are hidden + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render correctly with all columns hidden', () => { + const columns = createTestColumns().map((col) => ({ ...col, visible: false })); + const wrapper = render(TableHeaderControlsButton, { + props: { + columns, + }, + global: { + stubs: defaultStubs, + }, + }); + + // Verify correct count of visible and hidden columns + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + expect(visibleColumns).toHaveLength(0); // No columns are visible + expect(hiddenColumns).toHaveLength(5); // All columns are hidden + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render correctly with no columns', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: [], + }, + global: { + stubs: defaultStubs, + }, + }); + + // Verify correct count of visible and hidden columns + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + expect(visibleColumns).toHaveLength(0); // No columns + expect(hiddenColumns).toHaveLength(0); // No columns + + expect(wrapper.html()).toMatchSnapshot(); + }); + + describe('Column visibility toggling', () => { + it('should emit update:columnVisibility event when hiding a visible column', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + // Find the eye icon for the first visible column (col1) + const visibleToggles = wrapper.container.querySelectorAll( + '[data-testid="visibility-toggle-visible"]', + ); + expect(visibleToggles.length).toBeGreaterThan(0); + + await fireEvent.click(visibleToggles[0]); + + const emitted = wrapper.emitted('update:columnVisibility'); + expect(emitted).toBeTruthy(); + expect(emitted[0]).toEqual(['col1', false]); + }); + + it('should emit update:columnVisibility event when showing a hidden column', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + // Find the eye-off icon for the first hidden column (col3) + const hiddenToggles = wrapper.container.querySelectorAll( + '[data-testid="visibility-toggle-hidden"]', + ); + expect(hiddenToggles.length).toBeGreaterThan(0); + + await fireEvent.click(hiddenToggles[0]); + + const emitted = wrapper.emitted('update:columnVisibility'); + expect(emitted).toBeTruthy(); + expect(emitted[0]).toEqual(['col3', true]); + }); + + it('should show correct number of visible and hidden columns', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + expect(visibleColumns).toHaveLength(3); // col1, col2, col4 + expect(hiddenColumns).toHaveLength(2); // col3, col5 + }); + }); + + describe('Drag and drop functionality', () => { + it('should handle dragstart event correctly', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstDraggableColumn = wrapper.container.querySelector( + '[data-testid="visible-column"]', + ); + expect(firstDraggableColumn).toBeTruthy(); + + const dragEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + + await fireEvent(firstDraggableColumn!, dragEvent); + + expect(dragEvent.dataTransfer!.setData).toHaveBeenCalledWith('text/plain', 'col1'); + }); + + it('should handle dragover event correctly', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const secondDraggableColumn = wrapper.container.querySelectorAll( + '[data-testid="visible-column"]', + )[1]; + expect(secondDraggableColumn).toBeTruthy(); + + const dragEvent = createDragEvent('dragover', { + dropEffect: 'move', + }); + + const preventDefaultSpy = vi.spyOn(dragEvent, 'preventDefault'); + + await fireEvent(secondDraggableColumn, dragEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(dragEvent.dataTransfer!.dropEffect).toBe('move'); + }); + + it('should emit update:columnOrder when dropping column in new position', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const draggableColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const firstColumn = draggableColumns[0]; // col1 + const thirdColumn = draggableColumns[2]; // col2 + + // Start dragging first column + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(firstColumn, dragStartEvent); + + // Drop it on second column + const dropEvent = createDragEvent('drop', { + getData: vi.fn().mockReturnValue('col1'), + }); + const preventDefaultSpy = vi.spyOn(dropEvent, 'preventDefault'); + + await fireEvent(thirdColumn, dropEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + + const emitted = wrapper.emitted('update:columnOrder'); + expect(emitted).toBeTruthy(); + expect(emitted[0]).toEqual([['col2', 'col3', 'col1', 'col4', 'col5']]); + }); + + it('should emit update:columnOrder when dropping column at the end', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstColumn = wrapper.container.querySelector('[data-testid="visible-column"]'); + const endDropZone = wrapper.container.querySelector('[data-testid="end-drop-zone"]'); + + expect(firstColumn).toBeTruthy(); + expect(endDropZone).toBeTruthy(); + + // Start dragging first column + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(firstColumn!, dragStartEvent); + + // Drop it at the end + const dropEvent = createDragEvent('drop', { + getData: vi.fn().mockReturnValue('col1'), + }); + const preventDefaultSpy = vi.spyOn(dropEvent, 'preventDefault'); + + await fireEvent(endDropZone!, dropEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + + const emitted = wrapper.emitted('update:columnOrder'); + expect(emitted).toBeTruthy(); + expect(emitted[0]).toEqual([['col2', 'col3', 'col4', 'col5', 'col1']]); + }); + + it('should not emit update:columnOrder when dropping column on itself', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstColumn = wrapper.container.querySelector('[data-testid="visible-column"]'); + + // Start dragging first column + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(firstColumn!, dragStartEvent); + + // Drop it on itself + const dropEvent = createDragEvent('drop', { + getData: vi.fn().mockReturnValue('col1'), + }); + await fireEvent(firstColumn!, dropEvent); + + const emitted = wrapper.emitted('update:columnOrder'); + expect(emitted).toBeFalsy(); + }); + + it('should handle dragend event and reset drag state', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstColumn = wrapper.container.querySelector('[data-testid="visible-column"]'); + + // Start dragging + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(firstColumn!, dragStartEvent); + + // Verify drag state is active by checking for dragging class + expect(firstColumn).toHaveClass('dragging'); + + // End dragging + const dragEndEvent = createDragEvent('dragend'); + await fireEvent(firstColumn!, dragEndEvent); + + // Verify drag state is reset + expect(firstColumn).not.toHaveClass('dragging'); + }); + + it('should handle dragleave event', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstColumn = wrapper.container.querySelector('[data-testid="visible-column"]'); + + // Trigger dragleave + const dragLeaveEvent = createDragEvent('dragleave'); + await fireEvent(firstColumn!, dragLeaveEvent); + + // The component should handle this gracefully without errors + // No specific assertions needed as the behavior is internal state management + }); + + it('should show drop indicator when dragging over column', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const draggableColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const firstColumn = draggableColumns[0]; + const secondColumn = draggableColumns[1]; + + // Start dragging first column + await fireEvent( + firstColumn, + createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }), + ); + + // Drag over second column + await fireEvent( + secondColumn, + createDragEvent('dragover', { + dropEffect: 'move', + }), + ); + + // Check if drop indicator is shown + const dropIndicator = wrapper.container.querySelector('[data-testid="drop-indicator"]'); + expect(dropIndicator).toBeTruthy(); + }); + + it('should show drop indicator when dragging over end zone', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstColumn = wrapper.container.querySelector('[data-testid="visible-column"]'); + const endDropZone = wrapper.container.querySelector('[data-testid="end-drop-zone"]'); + + // Start dragging first column + await fireEvent( + firstColumn!, + createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }), + ); + + // Drag over end zone + await fireEvent( + endDropZone!, + createDragEvent('dragover', { + dropEffect: 'move', + }), + ); + + // Check if drop indicator is shown in end zone + const endDropIndicator = endDropZone!.querySelector('[data-testid="drop-indicator"]'); + expect(endDropIndicator).toBeTruthy(); + }); + + it('should correctly reorder columns when dragging from earlier to later position', async () => { + // This test specifically addresses the bug mentioned in the comment: + // targetIndex is computed before the dragged element is removed, + // so when the element is dragged from an earlier index, the stored index + // becomes stale after the first splice and the column is inserted one position too far to the right. + + const testColumns: ColumnHeader[] = [ + { key: 'A', label: 'Column A', disabled: false, visible: true }, + { key: 'B', label: 'Column B', disabled: false, visible: true }, + { key: 'C', label: 'Column C', disabled: false, visible: true }, + { key: 'D', label: 'Column D', disabled: false, visible: true }, + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: testColumns, + }, + global: { + stubs: defaultStubs, + }, + }); + + const draggableColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const columnA = draggableColumns[0]; // First column (A) + const columnC = draggableColumns[2]; // Third column (C) + + // Start dragging column A (index 0) + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(columnA, dragStartEvent); + + // Drop it on column C (index 2) + const dropEvent = createDragEvent('drop', { + getData: vi.fn().mockReturnValue('A'), + }); + await fireEvent(columnC, dropEvent); + + const emitted = wrapper.emitted('update:columnOrder'); + expect(emitted).toBeTruthy(); + + // Expected behavior: A should be inserted BEFORE C, not after + // Original order: [A, B, C, D] + // After moving A to C's position: [B, A, C, D] + expect(emitted[0]).toEqual([['B', 'A', 'C', 'D']]); + }); + + it('should correctly reorder columns when dragging from later to earlier position', async () => { + const testColumns: ColumnHeader[] = [ + { key: 'A', label: 'Column A', disabled: false, visible: true }, + { key: 'B', label: 'Column B', disabled: false, visible: true }, + { key: 'C', label: 'Column C', disabled: false, visible: true }, + { key: 'D', label: 'Column D', disabled: false, visible: true }, + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: testColumns, + }, + global: { + stubs: defaultStubs, + }, + }); + + const draggableColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const columnA = draggableColumns[0]; // First column (A) + const columnD = draggableColumns[3]; // Fourth column (D) + + // Start dragging column D (index 3) + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(columnD, dragStartEvent); + + // Drop it on column A (index 0) + const dropEvent = createDragEvent('drop', { + getData: vi.fn().mockReturnValue('D'), + }); + await fireEvent(columnA, dropEvent); + + const emitted = wrapper.emitted('update:columnOrder'); + expect(emitted).toBeTruthy(); + + // Expected behavior: D should be inserted BEFORE A + // Original order: [A, B, C, D] + // After moving D to A's position: [D, A, B, C] + expect(emitted[0]).toEqual([['D', 'A', 'B', 'C']]); + }); + }); + + describe('Edge cases', () => { + it('should handle drag events without dataTransfer', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const firstColumn = wrapper.container.querySelector('[data-testid="visible-column"]'); + + // Create event without dataTransfer + const dragEvent = new Event('dragstart', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(dragEvent, 'dataTransfer', { + value: null, + writable: false, + }); + + // Should not throw an error + await fireEvent(firstColumn!, dragEvent); + + // No events should be emitted + expect(wrapper.emitted('update:columnOrder')).toBeFalsy(); + }); + + it('should handle empty columns array gracefully', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: [], + }, + global: { + stubs: defaultStubs, + }, + }); + + // Should not crash and render properly + expect(wrapper.container).toBeTruthy(); + expect(wrapper.container.querySelectorAll('[data-testid="visible-column"]')).toHaveLength(0); + expect(wrapper.container.querySelectorAll('[data-testid="hidden-column"]')).toHaveLength(0); + }); + + it('should handle columns with same key gracefully', () => { + const duplicateColumns = [ + { key: 'col1', label: 'Column 1', disabled: false, visible: true }, + { key: 'col1', label: 'Column 1 Duplicate', disabled: false, visible: false }, + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: duplicateColumns, + }, + global: { + stubs: defaultStubs, + }, + }); + + // Should render without errors + expect(wrapper.container).toBeTruthy(); + }); + }); + + describe('Accessibility', () => { + it('should have proper draggable attribute on visible columns', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const draggableColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + draggableColumns.forEach((column) => { + expect(column).toHaveAttribute('draggable', 'true'); + }); + }); + + it('should have proper labels for visibility toggle buttons', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const visibleToggles = wrapper.container.querySelectorAll( + '[data-testid="visibility-toggle-visible"]', + ); + const hiddenToggles = wrapper.container.querySelectorAll( + '[data-testid="visibility-toggle-hidden"]', + ); + + expect(visibleToggles.length).toBeGreaterThan(0); + expect(hiddenToggles.length).toBeGreaterThan(0); + }); + }); + + it('should accept different button sizes', () => { + const sizes = ['mini', 'small', 'medium', 'large'] as const; + + sizes.forEach((size) => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createTestColumns(), + buttonSize: size, + }, + global: { + stubs: { + ...defaultStubs, + N8nButton: { + template: '', + props: ['size', 'icon', 'type'], + }, + }, + }, + }); + + const button = wrapper.container.querySelector('mock-button'); + expect(button).toBeTruthy(); + expect(button).toHaveAttribute('size', size); + }); + }); + + describe('Disabled columns', () => { + // Helper function to create test columns with disabled ones + const createColumnsWithDisabled = (): ColumnHeader[] => [ + { key: 'col1', label: 'Column 1', disabled: false, visible: true }, + { key: 'col2', disabled: true }, // Disabled column + { key: 'col3', label: 'Column 3', disabled: false, visible: false }, + { key: 'col4', disabled: true }, // Another disabled column + { key: 'col5', label: 'Column 5', disabled: false, visible: true }, + ]; + + it('should filter out disabled columns from visible columns', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createColumnsWithDisabled(), + }, + global: { + stubs: defaultStubs, + }, + }); + + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + // Only non-disabled columns should appear in the UI + expect(visibleColumns).toHaveLength(2); // col1, col5 (both visible and not disabled) + expect(hiddenColumns).toHaveLength(1); // col3 (hidden but not disabled) + + // Verify that disabled columns are not rendered at all + const allColumnKeys = Array.from(wrapper.container.querySelectorAll('[data-column-key]')).map( + (el) => el.getAttribute('data-column-key'), + ); + + expect(allColumnKeys).not.toContain('col2'); + expect(allColumnKeys).not.toContain('col4'); + expect(allColumnKeys).toContain('col1'); + expect(allColumnKeys).toContain('col3'); + expect(allColumnKeys).toContain('col5'); + }); + + it('should filter out disabled columns from hidden columns', () => { + const columns: ColumnHeader[] = [ + { key: 'col1', label: 'Column 1', disabled: false, visible: true }, + { key: 'col2', disabled: true }, // Disabled column + { key: 'col3', label: 'Column 3', disabled: false, visible: false }, + { key: 'col4', disabled: true }, // Another disabled column + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns, + }, + global: { + stubs: defaultStubs, + }, + }); + + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + // Only col3 should be in hidden columns (not disabled and visible: false) + expect(hiddenColumns).toHaveLength(1); + + const hiddenColumnKey = hiddenColumns[0].getAttribute('data-column-key'); + expect(hiddenColumnKey).toBe('col3'); + }); + + it('should handle all disabled columns gracefully', () => { + const allDisabledColumns: ColumnHeader[] = [ + { key: 'col1', disabled: true }, + { key: 'col2', disabled: true }, + { key: 'col3', disabled: true }, + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: allDisabledColumns, + }, + global: { + stubs: defaultStubs, + }, + }); + + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + // No columns should be rendered + expect(visibleColumns).toHaveLength(0); + expect(hiddenColumns).toHaveLength(0); + + // Verify sections are not displayed when empty + const visibleSection = wrapper.container.querySelector( + '[data-testid="visible-columns-section"]', + ); + const hiddenSection = wrapper.container.querySelector( + '[data-testid="hidden-columns-section"]', + ); + + expect(visibleSection).toBeFalsy(); + expect(hiddenSection).toBeFalsy(); + }); + + it('should handle mixed disabled and enabled columns correctly', () => { + const mixedColumns: ColumnHeader[] = [ + { key: 'enabled1', label: 'Enabled 1', disabled: false, visible: true }, + { key: 'disabled1', disabled: true }, + { key: 'enabled2', label: 'Enabled 2', disabled: false, visible: false }, + { key: 'disabled2', disabled: true }, + { key: 'enabled3', label: 'Enabled 3', disabled: false, visible: true }, + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: mixedColumns, + }, + global: { + stubs: defaultStubs, + }, + }); + + const visibleColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + const hiddenColumns = wrapper.container.querySelectorAll('[data-testid="hidden-column"]'); + + expect(visibleColumns).toHaveLength(2); // enabled1, enabled3 + expect(hiddenColumns).toHaveLength(1); // enabled2 + + // Verify correct columns are displayed + const visibleKeys = Array.from(visibleColumns).map((el) => + el.getAttribute('data-column-key'), + ); + const hiddenKeys = Array.from(hiddenColumns).map((el) => el.getAttribute('data-column-key')); + + expect(visibleKeys).toEqual(expect.arrayContaining(['enabled1', 'enabled3'])); + expect(hiddenKeys).toEqual(['enabled2']); + }); + + it('should include disabled and hidden columns in drag and drop operations', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createColumnsWithDisabled(), + }, + global: { + stubs: defaultStubs, + }, + }); + + // Get all draggable columns + const draggableColumns = wrapper.container.querySelectorAll('[data-testid="visible-column"]'); + + // Verify only non-disabled columns are draggable + expect(draggableColumns).toHaveLength(2); // col1, col5 + + const draggableKeys = Array.from(draggableColumns).map((el) => + el.getAttribute('data-column-key'), + ); + expect(draggableKeys).toEqual(expect.arrayContaining(['col1', 'col5'])); + expect(draggableKeys).not.toContain('col2'); + expect(draggableKeys).not.toContain('col4'); + + // Test drag and drop functionality with only enabled columns + const firstColumn = draggableColumns[0]; + const secondColumn = draggableColumns[1]; + + // Start dragging first column + const dragStartEvent = createDragEvent('dragstart', { + setData: vi.fn(), + effectAllowed: 'move', + }); + await fireEvent(firstColumn, dragStartEvent); + + // Drop it on second column + const dropEvent = createDragEvent('drop', { + getData: vi.fn().mockReturnValue('col1'), + }); + await fireEvent(secondColumn, dropEvent); + + const emitted = wrapper.emitted('update:columnOrder'); + expect(emitted).toBeTruthy(); + // After moving col1 to col5's position: [col2, col3, col4, col1, col5] + expect(emitted[0]).toEqual([['col2', 'col3', 'col4', 'col1', 'col5']]); + }); + + it('should not affect visibility toggle functionality for enabled columns when disabled columns are present', async () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createColumnsWithDisabled(), + }, + global: { + stubs: defaultStubs, + }, + }); + + // Test hiding a visible enabled column + const visibleToggles = wrapper.container.querySelectorAll( + '[data-testid="visibility-toggle-visible"]', + ); + expect(visibleToggles.length).toBeGreaterThan(0); + + await fireEvent.click(visibleToggles[0]); + + let emitted = wrapper.emitted('update:columnVisibility'); + expect(emitted).toBeTruthy(); + expect(emitted[0]).toEqual(['col1', false]); + + // Test showing a hidden enabled column + const hiddenToggles = wrapper.container.querySelectorAll( + '[data-testid="visibility-toggle-hidden"]', + ); + expect(hiddenToggles.length).toBeGreaterThan(0); + + await fireEvent.click(hiddenToggles[0]); + + emitted = wrapper.emitted('update:columnVisibility'); + expect(emitted).toBeTruthy(); + expect(emitted[1]).toEqual(['col3', true]); + }); + + it('should render correctly when only disabled columns exist', () => { + const onlyDisabledColumns: ColumnHeader[] = [ + { key: 'disabled1', disabled: true }, + { key: 'disabled2', disabled: true }, + ]; + + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: onlyDisabledColumns, + }, + global: { + stubs: defaultStubs, + }, + }); + + // Should render the component but with no visible or hidden columns + expect(wrapper.container).toBeTruthy(); + expect(wrapper.container.querySelectorAll('[data-testid="visible-column"]')).toHaveLength(0); + expect(wrapper.container.querySelectorAll('[data-testid="hidden-column"]')).toHaveLength(0); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should maintain correct computed properties with disabled columns', () => { + const wrapper = render(TableHeaderControlsButton, { + props: { + columns: createColumnsWithDisabled(), + }, + global: { + stubs: defaultStubs, + }, + }); + + // The component should correctly filter disabled columns in computed properties + // This is tested implicitly through the rendering behavior + + const visibleSection = wrapper.container.querySelector( + '[data-testid="visible-columns-section"]', + ); + const hiddenSection = wrapper.container.querySelector( + '[data-testid="hidden-columns-section"]', + ); + + // Both sections should exist because we have non-disabled columns + expect(visibleSection).toBeTruthy(); + expect(hiddenSection).toBeTruthy(); + + // Verify section headers are present + expect(visibleSection?.textContent).toContain('Shown columns'); + expect(hiddenSection?.textContent).toContain('Hidden columns'); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue new file mode 100644 index 0000000000..9832137e69 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/TableHeaderControlsButton.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/__snapshots__/TableHeaderControlsButton.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/__snapshots__/TableHeaderControlsButton.test.ts.snap new file mode 100644 index 0000000000..8b36184e98 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/__snapshots__/TableHeaderControlsButton.test.ts.snap @@ -0,0 +1,153 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TableHeaderControlsButton > Disabled columns > should render correctly when only disabled columns exist 1`] = ` +"
+ + + + +
" +`; + +exports[`TableHeaderControlsButton > should render correctly with all columns hidden 1`] = ` +"
+ + + + + +
+

Hidden columns

+ + + + + +
+
+
" +`; + +exports[`TableHeaderControlsButton > should render correctly with all columns visible 1`] = ` +"
+ + + + +
+
Shown columns
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+
" +`; + +exports[`TableHeaderControlsButton > should render correctly with mixed visible and hidden columns 1`] = ` +"
+ + + + +
+
Shown columns
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+
+

Hidden columns

+ + +
+
+
" +`; + +exports[`TableHeaderControlsButton > should render correctly with no columns 1`] = ` +"
+ + + + +
" +`; diff --git a/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/index.ts b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/index.ts new file mode 100644 index 0000000000..d685be2214 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/TableHeaderControlsButton/index.ts @@ -0,0 +1,3 @@ +import N8nTableHeaderControlsButton from './TableHeaderControlsButton.vue'; + +export default N8nTableHeaderControlsButton; diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index 8920758048..8ba324b986 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -12,6 +12,7 @@ export { default as N8nCheckbox } from './N8nCheckbox'; export { default as N8nCircleLoader } from './N8nCircleLoader'; export { default as N8nColorPicker } from './N8nColorPicker'; export { default as N8nDatatable } from './N8nDatatable'; +export { default as N8nExternalLink } from './N8nExternalLink'; export { default as N8nFormBox } from './N8nFormBox'; export { default as N8nFormInputs } from './N8nFormInputs'; export { default as N8nFormInput } from './N8nFormInput'; @@ -60,4 +61,6 @@ export { default as N8nIconPicker } from './N8nIconPicker'; export { default as N8nBreadcrumbs } from './N8nBreadcrumbs'; export { default as N8nTableBase } from './TableBase'; export { default as N8nDataTableServer } from './N8nDataTableServer'; +export { default as N8nTableHeaderControlsButton } from './TableHeaderControlsButton'; export { default as N8nInlineTextEdit } from './N8nInlineTextEdit'; +export { default as N8nScrollArea } from './N8nScrollArea'; diff --git a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts index f6a6ed17ed..571bcc0c2c 100644 --- a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts +++ b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts @@ -71,4 +71,7 @@ export default { 'iconPicker.tabs.emojis': 'Emojis', 'selectableList.addDefault': '+ Add a', 'auth.changePassword.passwordsMustMatchError': 'Passwords must match', + 'tableControlsButton.display': 'Display', + 'tableControlsButton.shown': 'Shown', + 'tableControlsButton.hidden': 'Hidden', } as N8nLocale; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ca7445585c..e5b4e5db66 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3160,6 +3160,7 @@ "evaluation.listRuns.metricsOverTime": "Metrics over time", "evaluation.listRuns.status": "Status", "evaluation.listRuns.runListHeader": "All runs", + "evaluation.listRuns.allTestCases": "All test cases | All test cases ({count})", "evaluation.listRuns.testCasesListHeader": "Run #{index}", "evaluation.listRuns.runNumber": "Run", "evaluation.listRuns.runDate": "Run date", diff --git a/packages/frontend/editor-ui/src/components/Evaluations.ee/shared/TestTableBase.vue b/packages/frontend/editor-ui/src/components/Evaluations.ee/shared/TestTableBase.vue index 925571057a..3f0ed78280 100644 --- a/packages/frontend/editor-ui/src/components/Evaluations.ee/shared/TestTableBase.vue +++ b/packages/frontend/editor-ui/src/components/Evaluations.ee/shared/TestTableBase.vue @@ -1,10 +1,12 @@ @@ -111,10 +131,8 @@ defineSlots<{ :default-sort="defaultSort" :data="localData" :border="true" - :cell-class-name="$style.customCell" - :row-class-name=" - ({ row }) => (row?.status === 'error' ? $style.customDisabledRow : $style.customRow) - " + :cell-class-name="getCellClassName" + :row-class-name="getRowClassName" scrollbar-always-on @selection-change="handleSelectionChange" @header-dragend="handleColumnResize" @@ -135,7 +153,7 @@ defineSlots<{ v-bind="column" :resizable="true" data-test-id="table-column" - :min-width="125" + :min-width="column.minWidth ?? SHORT_TABLE_CELL_MIN_WIDTH" > diff --git a/packages/frontend/editor-ui/src/plugins/cache.test.ts b/packages/frontend/editor-ui/src/plugins/cache.test.ts new file mode 100644 index 0000000000..c46093a75d --- /dev/null +++ b/packages/frontend/editor-ui/src/plugins/cache.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { indexedDbCache } from './cache'; +// @ts-ignore +import FDBFactory from 'fake-indexeddb/lib/FDBFactory'; +// @ts-ignore +import FDBKeyRange from 'fake-indexeddb/lib/FDBKeyRange'; + +const globalTeardown = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (global as any).indexedDB; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (global as any).IDBKeyRange; +}; + +const globalSetup = () => { + global.indexedDB = new FDBFactory(); + global.IDBKeyRange = FDBKeyRange; +}; + +describe('indexedDbCache', () => { + const dbName = 'testDb'; + const storeName = 'testStore'; + + beforeEach(() => { + globalSetup(); + }); + + afterEach(() => { + globalTeardown(); + }); + + it('should create cache instance and initialize empty', async () => { + const cache = await indexedDbCache(dbName, storeName); + + expect(cache.getItem('nonexistent')).toBe(null); + }); + + it('should set and get items', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('key1', 'value1'); + expect(cache.getItem('key1')).toBe('value1'); + + cache.setItem('key2', 'value2'); + expect(cache.getItem('key2')).toBe('value2'); + }); + + it('should return null for non-existent keys', async () => { + const cache = await indexedDbCache(dbName, storeName); + + expect(cache.getItem('nonexistent')).toBe(null); + }); + + it('should remove items', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('key1', 'value1'); + expect(cache.getItem('key1')).toBe('value1'); + + cache.removeItem('key1'); + expect(cache.getItem('key1')).toBe(null); + }); + + it('should clear all items', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('key1', 'value1'); + cache.setItem('key2', 'value2'); + + cache.clear(); + + expect(cache.getItem('key1')).toBe(null); + expect(cache.getItem('key2')).toBe(null); + }); + + it('should get all items with prefix', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('prefix:key1', 'value1'); + cache.setItem('prefix:key2', 'value2'); + cache.setItem('other:key3', 'value3'); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const results = await cache.getAllWithPrefix('prefix:'); + + expect(results).toEqual({ + 'prefix:key1': 'value1', + 'prefix:key2': 'value2', + }); + + expect(results['other:key3']).toBeUndefined(); + }); + + it('should persist data between cache instances', async () => { + const cache1 = await indexedDbCache(dbName, storeName); + cache1.setItem('persistent', 'value'); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const cache2 = await indexedDbCache(dbName, storeName); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(cache2.getItem('persistent')).toBe('value'); + }); + + it('should handle empty prefix queries', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('key1', 'value1'); + cache.setItem('key2', 'value2'); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const results = await cache.getAllWithPrefix(''); + + expect(results).toEqual({ + key1: 'value1', + key2: 'value2', + }); + }); + + it('should handle non-matching prefix queries', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('key1', 'value1'); + cache.setItem('key2', 'value2'); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const results = await cache.getAllWithPrefix('nonexistent:'); + + expect(results).toEqual({}); + }); + + it('should handle concurrent operations', async () => { + const cache = await indexedDbCache(dbName, storeName); + + const promises = []; + + for (let i = 0; i < 10; i++) { + promises.push( + new Promise((resolve) => { + cache.setItem(`key${i}`, `value${i}`); + resolve(); + }), + ); + } + + await Promise.all(promises); + + for (let i = 0; i < 10; i++) { + expect(cache.getItem(`key${i}`)).toBe(`value${i}`); + } + }); + + it('should update existing items', async () => { + const cache = await indexedDbCache(dbName, storeName); + + cache.setItem('key1', 'originalValue'); + expect(cache.getItem('key1')).toBe('originalValue'); + + cache.setItem('key1', 'updatedValue'); + expect(cache.getItem('key1')).toBe('updatedValue'); + }); + + it('should handle special characters in keys and values', async () => { + const cache = await indexedDbCache(dbName, storeName); + + const specialKey = 'key:with/special\\chars'; + const specialValue = 'value with "quotes" and \nnewlines'; + + cache.setItem(specialKey, specialValue); + expect(cache.getItem(specialKey)).toBe(specialValue); + }); + + it('should work with different database names', async () => { + const cache1 = await indexedDbCache('db1', storeName); + const cache2 = await indexedDbCache('db2', storeName); + + cache1.setItem('key', 'value1'); + cache2.setItem('key', 'value2'); + + expect(cache1.getItem('key')).toBe('value1'); + expect(cache2.getItem('key')).toBe('value2'); + }); + + it('should handle database initialization errors gracefully', async () => { + const originalIndexedDB = global.indexedDB; + + const mockIndexedDB = { + open: vi.fn().mockImplementation(() => { + const request = { + onerror: null as ((event: Event) => void) | null, + onsuccess: null as ((event: Event) => void) | null, + onupgradeneeded: null as ((event: Event) => void) | null, + result: null, + error: new Error('Database error'), + }; + setTimeout(() => { + if (request.onerror) request.onerror(new Event('error')); + }, 0); + return request; + }), + cmp: vi.fn(), + databases: vi.fn(), + deleteDatabase: vi.fn(), + }; + + global.indexedDB = mockIndexedDB; + + await expect(indexedDbCache(dbName, storeName)).rejects.toThrow(); + + global.indexedDB = originalIndexedDB; + }); + + it('should ensure IndexedDB operations are persisted correctly', async () => { + const cache = await indexedDbCache(dbName, storeName); + + // Set multiple items + cache.setItem('persist1', 'value1'); + cache.setItem('persist2', 'value2'); + cache.setItem('persist3', 'value3'); + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Create new instance to verify persistence + const newCache = await indexedDbCache(dbName, storeName); + + // Wait for load to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(newCache.getItem('persist1')).toBe('value1'); + expect(newCache.getItem('persist2')).toBe('value2'); + expect(newCache.getItem('persist3')).toBe('value3'); + }); + + it('should ensure removeItem persists deletion to IndexedDB', async () => { + const cache = await indexedDbCache(dbName, storeName); + + // Set and verify item exists + cache.setItem('toDelete', 'value'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const cache2 = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(cache2.getItem('toDelete')).toBe('value'); + + // Remove item and verify it's deleted from IndexedDB + cache.removeItem('toDelete'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const cache3 = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(cache3.getItem('toDelete')).toBe(null); + }); + + it('should ensure clear persists to IndexedDB', async () => { + const cache = await indexedDbCache(dbName, storeName); + + // Set multiple items + cache.setItem('clear1', 'value1'); + cache.setItem('clear2', 'value2'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify items exist in new instance + const cache2 = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(cache2.getItem('clear1')).toBe('value1'); + expect(cache2.getItem('clear2')).toBe('value2'); + + // Clear and verify persistence + cache.clear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const cache3 = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(cache3.getItem('clear1')).toBe(null); + expect(cache3.getItem('clear2')).toBe(null); + }); + + it('should handle rapid successive operations correctly', async () => { + const cache = await indexedDbCache(dbName, storeName); + + // Rapid operations on same key + cache.setItem('rapid', 'value1'); + cache.setItem('rapid', 'value2'); + cache.setItem('rapid', 'value3'); + cache.removeItem('rapid'); + cache.setItem('rapid', 'final'); + + // In-memory should be immediate + expect(cache.getItem('rapid')).toBe('final'); + + // Wait for all async operations to settle + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify final state persisted correctly + const newCache = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(newCache.getItem('rapid')).toBe('final'); + }); + + it('should maintain consistency between memory cache and IndexedDB', async () => { + const cache = await indexedDbCache(dbName, storeName); + + const operations = [ + () => cache.setItem('consistency1', 'value1'), + () => cache.setItem('consistency2', 'value2'), + () => cache.removeItem('consistency1'), + () => cache.setItem('consistency3', 'value3'), + () => cache.clear(), + () => cache.setItem('final', 'finalValue'), + ]; + + // Execute operations with small delays + for (const op of operations) { + op(); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Wait for all operations to complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Verify final state in new instance + const newCache = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(newCache.getItem('consistency1')).toBe(null); + expect(newCache.getItem('consistency2')).toBe(null); + expect(newCache.getItem('consistency3')).toBe(null); + expect(newCache.getItem('final')).toBe('finalValue'); + }); + + it('should verify transaction operations are called with correct parameters', async () => { + const cache = await indexedDbCache(dbName, storeName); + + // Test that operations work through the full IndexedDB integration + cache.setItem('txTest1', 'value1'); + cache.setItem('txTest2', 'value2'); + cache.removeItem('txTest1'); + + // Wait for operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify in-memory state + expect(cache.getItem('txTest1')).toBe(null); + expect(cache.getItem('txTest2')).toBe('value2'); + + // Verify persistence to IndexedDB + const newCache = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(newCache.getItem('txTest1')).toBe(null); + expect(newCache.getItem('txTest2')).toBe('value2'); + + // Clear and verify + cache.clear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const clearedCache = await indexedDbCache(dbName, storeName); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(clearedCache.getItem('txTest2')).toBe(null); + }); +}); diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/cache.ts b/packages/frontend/editor-ui/src/plugins/cache.ts similarity index 100% rename from packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/cache.ts rename to packages/frontend/editor-ui/src/plugins/cache.ts diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/env.ts b/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/env.ts index e746f2a853..0149b3e9cf 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/env.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/env.ts @@ -1,7 +1,7 @@ import * as tsvfs from '@typescript/vfs'; import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants'; import ts from 'typescript'; -import type { IndexedDbCache } from './cache'; +import type { IndexedDbCache } from '../../../cache'; import globalTypes from './type-declarations/globals.d.ts?raw'; import n8nTypes from './type-declarations/n8n.d.ts?raw'; diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts b/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts index 2f7b57dc4b..4f2170ee91 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts @@ -1,6 +1,6 @@ import * as Comlink from 'comlink'; import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types'; -import { indexedDbCache } from './cache'; +import { indexedDbCache } from '../../../cache'; import { bufferChangeSets, fnPrefix } from './utils'; import type { CodeExecutionMode } from 'n8n-workflow'; diff --git a/packages/frontend/editor-ui/src/views/Evaluations.ee/TestRunDetailView.vue b/packages/frontend/editor-ui/src/views/Evaluations.ee/TestRunDetailView.vue index d1eb0228b4..ff89f2dfbb 100644 --- a/packages/frontend/editor-ui/src/views/Evaluations.ee/TestRunDetailView.vue +++ b/packages/frontend/editor-ui/src/views/Evaluations.ee/TestRunDetailView.vue @@ -9,13 +9,47 @@ import type { BaseTextKey } from '@n8n/i18n'; import { useEvaluationStore } from '@/stores/evaluation.store.ee'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { convertToDisplayDate } from '@/utils/formatters/dateFormatter'; -import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system'; +import { + N8nText, + N8nTooltip, + N8nIcon, + N8nTableHeaderControlsButton, + N8nExternalLink, +} from '@n8n/design-system'; import { computed, onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; import orderBy from 'lodash/orderBy'; import { statusDictionary } from '@/components/Evaluations.ee/shared/statusDictionary'; import { getErrorBaseKey } from '@/components/Evaluations.ee/shared/errorCodes'; -import { getTestCasesColumns, mapToNumericColumns } from './utils'; +import { + applyCachedSortOrder, + applyCachedVisibility, + getDefaultOrderedColumns, + getTestCasesColumns, + getTestTableHeaders, +} from './utils'; +import { indexedDbCache } from '@/plugins/cache'; +import { jsonParse } from 'n8n-workflow'; + +export type Column = + | { + key: string; + label: string; + visible: boolean; + numeric?: boolean; + disabled: false; + columnType: 'inputs' | 'outputs' | 'metrics'; + } + // Disabled state ensures current sort order is not lost if user resorts teh columns + // even if some columns are disabled / not available in the current run + | { key: string; disabled: true }; + +interface UserPreferences { + order: string[]; + visibility: Record; +} + +export type Header = TestTableColumn; const router = useRouter(); const toast = useToast(); @@ -31,6 +65,9 @@ const runId = computed(() => router.currentRoute.value.params.runId as string); const workflowId = computed(() => router.currentRoute.value.params.name as string); const workflowName = computed(() => workflowsStore.getWorkflowById(workflowId.value)?.name ?? ''); +const cachedUserPreferences = ref(); +const expandedRows = ref>(new Set()); + const run = computed(() => evaluationStore.testRunsById[runId.value]); const runErrorDetails = computed(() => { return run.value?.errorDetails as Record; @@ -42,6 +79,8 @@ const filteredTestCases = computed(() => ), ); +const isAllExpanded = computed(() => expandedRows.value.size === filteredTestCases.value.length); + const testRunIndex = computed(() => Object.values( orderBy(evaluationStore.testRunsById, (record) => new Date(record.runAt), ['asc']).filter( @@ -52,7 +91,7 @@ const testRunIndex = computed(() => const formattedTime = computed(() => convertToDisplayDate(new Date(run.value?.runAt).getTime())); -const handleRowClick = (row: TestCaseExecutionRecord) => { +const openRelatedExecution = (row: TestCaseExecutionRecord) => { const executionId = row.executionId; if (executionId) { const { href } = router.resolve({ @@ -68,35 +107,27 @@ const handleRowClick = (row: TestCaseExecutionRecord) => { const inputColumns = computed(() => getTestCasesColumns(filteredTestCases.value, 'inputs')); -const columns = computed( - (): Array> => { - const specialKeys = ['promptTokens', 'completionTokens', 'totalTokens', 'executionTime']; - const metricColumns = Object.keys(run.value?.metrics ?? {}).filter( - (key) => !specialKeys.includes(key), - ); - const specialColumns = specialKeys.filter((key) => - run.value?.metrics ? key in run.value.metrics : false, - ); +const orderedColumns = computed((): Column[] => { + const defaultOrder = getDefaultOrderedColumns(run.value, filteredTestCases.value); + const appliedCachedOrder = applyCachedSortOrder(defaultOrder, cachedUserPreferences.value?.order); - return [ - { - prop: 'index', - width: 100, - label: locale.baseText('evaluation.runDetail.testCase'), - sortable: true, - formatter: (row: TestCaseExecutionRecord & { index: number }) => `#${row.index}`, - }, - { - prop: 'status', - label: locale.baseText('evaluation.listRuns.status'), - }, - ...inputColumns.value, - ...getTestCasesColumns(filteredTestCases.value, 'outputs'), - ...mapToNumericColumns(metricColumns), - ...mapToNumericColumns(specialColumns), - ]; - }, -); + return applyCachedVisibility(appliedCachedOrder, cachedUserPreferences.value?.visibility); +}); + +const columns = computed((): Header[] => [ + { + prop: 'index', + width: 100, + label: locale.baseText('evaluation.runDetail.testCase'), + sortable: true, + } satisfies Header, + { + prop: 'status', + label: locale.baseText('evaluation.listRuns.status'), + minWidth: 125, + } satisfies Header, + ...getTestTableHeaders(orderedColumns.value, filteredTestCases.value), +]); const metrics = computed(() => run.value?.metrics ?? {}); @@ -126,8 +157,54 @@ const fetchExecutionTestCases = async () => { } }; +async function loadCachedUserPreferences() { + const cache = await indexedDbCache('workflows', 'evaluations'); + cachedUserPreferences.value = jsonParse(cache.getItem(workflowId.value) ?? '', { + fallbackValue: { + order: [], + visibility: {}, + }, + }); +} + +async function saveCachedUserPreferences() { + const cache = await indexedDbCache('workflows', 'evaluations'); + cache.setItem(workflowId.value, JSON.stringify(cachedUserPreferences.value)); +} + +async function handleColumnVisibilityUpdate(columnKey: string, visibility: boolean) { + cachedUserPreferences.value ??= { order: [], visibility: {} }; + cachedUserPreferences.value.visibility[columnKey] = visibility; + await saveCachedUserPreferences(); +} + +async function handleColumnOrderUpdate(newOrder: string[]) { + cachedUserPreferences.value ??= { order: [], visibility: {} }; + cachedUserPreferences.value.order = newOrder; + await saveCachedUserPreferences(); +} + +function toggleRowExpansion(row: { id: string }) { + if (expandedRows.value.has(row.id)) { + expandedRows.value.delete(row.id); + } else { + expandedRows.value.add(row.id); + } +} + +function toggleAllExpansion() { + if (isAllExpanded.value) { + // Collapse all + expandedRows.value.clear(); + } else { + // Expand all + expandedRows.value = new Set(filteredTestCases.value.map((row) => row.id)); + } +} + onMounted(async () => { await fetchExecutionTestCases(); + await loadCachedUserPreferences(); }); @@ -226,6 +303,35 @@ onMounted(async () => { +
+
+ {{ + locale.baseText('evaluation.listRuns.allTestCases', { + interpolate: { + count: filteredTestCases.length, + }, + }) + }} + +
+
+ + +
+
+ + + + diff --git a/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.test.ts b/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.test.ts index 1172145d17..1b141b02a3 100644 --- a/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.test.ts +++ b/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.test.ts @@ -1,421 +1,1198 @@ import { describe, it, expect } from 'vitest'; -import { getTestCasesColumns } from './utils'; -import type { TestCaseExecutionRecord } from '../../api/evaluation.ee'; -import { mock } from 'vitest-mock-extended'; +import { + applyCachedSortOrder, + applyCachedVisibility, + getDefaultOrderedColumns, + getTestCasesColumns, + getTestTableHeaders, +} from './utils'; +import type { TestCaseExecutionRecord } from '@/api/evaluation.ee'; describe('utils', () => { + describe('applyCachedSortOrder', () => { + it('should reorder columns according to cached order', () => { + const defaultOrder = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, + { + key: 'metrics.executionTime', + label: 'executionTime', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, + ]; + + const cachedOrder = [ + 'metrics.accuracy', + 'inputs.query', + 'metrics.executionTime', + 'outputs.result', + ]; + + const result = applyCachedSortOrder(defaultOrder, cachedOrder); + + expect(result).toEqual([ + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { + key: 'metrics.executionTime', + label: 'executionTime', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs', + }, + ]); + }); + + it('should handle extra keys in both cached order and default order', () => { + const defaultOrder = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'inputs.limit', + label: 'limit', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, // Extra key not in cached order + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, + { + key: 'outputs.count', + label: 'count', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, // Extra key not in cached order + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, + { + key: 'metrics.executionTime', + label: 'executionTime', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, // Extra key not in cached order + ]; + + const cachedOrder = [ + 'metrics.accuracy', + 'metrics.nonexistent1', // Extra key not in default order + 'inputs.query', + 'metrics.nonexistent2', // Extra key not in default order + 'outputs.result', + 'metrics.nonexistent3', // Extra key not in default order + ]; + + const result = applyCachedSortOrder(defaultOrder, cachedOrder); + + expect(result).toEqual([ + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { key: 'metrics.nonexistent1', disabled: true }, + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { key: 'metrics.nonexistent2', disabled: true }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs', + }, + { key: 'metrics.nonexistent3', disabled: true }, + { + key: 'inputs.limit', + label: 'limit', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { + key: 'outputs.count', + label: 'count', + visible: true, + disabled: false, + columnType: 'outputs', + }, + { + key: 'metrics.executionTime', + label: 'executionTime', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + ]); + }); + + it('should handle empty cached order and return default order unchanged', () => { + const defaultOrder = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, + ]; + + const result = applyCachedSortOrder(defaultOrder, []); + + expect(result).toEqual(defaultOrder); + }); + + it('should handle undefined cached order and return default order unchanged', () => { + const defaultOrder = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, + ]; + + const result = applyCachedSortOrder(defaultOrder, undefined); + + expect(result).toEqual(defaultOrder); + }); + + it('should handle cached order with all keys not in default order', () => { + const defaultOrder = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, + ]; + + const cachedOrder = ['metrics.accuracy', 'metrics.speed', 'outputs.error']; + + const result = applyCachedSortOrder(defaultOrder, cachedOrder); + + expect(result).toEqual([ + { key: 'metrics.accuracy', disabled: true }, + { key: 'metrics.speed', disabled: true }, + { key: 'outputs.error', disabled: true }, + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs', + }, + ]); + }); + }); + + describe('applyCachedVisibility', () => { + it('should apply visibility settings to columns', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs' as const, + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics' as const, + numeric: true, + }, + ]; + + const visibility = { + 'inputs.query': false, + 'metrics.accuracy': true, + }; + + const result = applyCachedVisibility(columns, visibility); + + expect(result).toEqual([ + { + key: 'inputs.query', + label: 'query', + visible: false, + disabled: false, + columnType: 'inputs', + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs', + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + ]); + }); + + it('should not modify disabled columns', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { key: 'metrics.nonexistent', disabled: true as const }, + ]; + + const visibility = { + 'inputs.query': false, + 'metrics.nonexistent': true, + }; + + const result = applyCachedVisibility(columns, visibility); + + expect(result).toEqual([ + { + key: 'inputs.query', + label: 'query', + visible: false, + disabled: false, + columnType: 'inputs', + }, + { key: 'metrics.nonexistent', disabled: true }, + ]); + }); + + it('should return original columns when visibility is undefined', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: false, + disabled: false, + columnType: 'outputs' as const, + }, + ]; + + const result = applyCachedVisibility(columns, undefined); + + expect(result).toEqual(columns); + }); + + it('should preserve original visibility for keys not in visibility object', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: false, + disabled: false, + columnType: 'outputs' as const, + }, + ]; + + const visibility = { + 'inputs.query': false, + }; + + const result = applyCachedVisibility(columns, visibility); + + expect(result).toEqual([ + { + key: 'inputs.query', + label: 'query', + visible: false, + disabled: false, + columnType: 'inputs', + }, + { + key: 'outputs.result', + label: 'result', + visible: false, + disabled: false, + columnType: 'outputs', + }, + ]); + }); + }); + describe('getTestCasesColumns', () => { - const mockTestCases: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - query: 'test query', - limit: 10, - category: 'test', + it('should extract unique input column names from test cases', () => { + const testCases: TestCaseExecutionRecord[] = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test', limit: 10 }, + outputs: { result: 'success' }, }, - outputs: { - result: 'success', - count: 5, + { + id: '2', + testRunId: 'run1', + executionId: 'exec2', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test2', offset: 5 }, + outputs: { result: 'success', count: 3 }, }, - metrics: { - accuracy: 0.95, - }, - }), - mock({ - id: 'test-case-2', - testRunId: 'test-run-1', - executionId: 'execution-2', - status: 'completed', - createdAt: '2023-10-01T10:01:00Z', - updatedAt: '2023-10-01T10:01:00Z', - runAt: '2023-10-01T10:01:00Z', - inputs: { - query: 'another query', - limit: 20, - filter: 'active', - }, - outputs: { - result: 'success', - data: { items: [] }, - }, - metrics: { - accuracy: 0.88, - }, - }), - mock({ - id: 'test-case-3', - testRunId: 'test-run-1', - executionId: 'execution-3', - status: 'error', - createdAt: '2023-10-01T10:02:00Z', - updatedAt: '2023-10-01T10:02:00Z', - runAt: '2023-10-01T10:02:00Z', - inputs: { - query: 'error query', - timeout: 5000, - }, - outputs: { - error: 'timeout occurred', - }, - metrics: { - accuracy: 0.0, - }, - }), - ]; + ]; - it('should extract input columns from test cases', () => { - const columns = getTestCasesColumns(mockTestCases, 'inputs'); + const inputColumns = getTestCasesColumns(testCases, 'inputs'); + const outputColumns = getTestCasesColumns(testCases, 'outputs'); - expect(columns).toHaveLength(5); - - const columnProps = columns.map((col) => col.prop); - expect(columnProps).toContain('inputs.query'); - expect(columnProps).toContain('inputs.limit'); - expect(columnProps).toContain('inputs.category'); - expect(columnProps).toContain('inputs.filter'); - expect(columnProps).toContain('inputs.timeout'); + expect(inputColumns.sort()).toEqual(['limit', 'offset', 'query']); + expect(outputColumns.sort()).toEqual(['count', 'result']); }); - it('should extract output columns from test cases', () => { - const columns = getTestCasesColumns(mockTestCases, 'outputs'); + it('should return empty array when test cases have no inputs/outputs', () => { + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + }, + ]; - expect(columns).toHaveLength(4); + const inputColumns = getTestCasesColumns(testCases, 'inputs'); + const outputColumns = getTestCasesColumns(testCases, 'outputs'); - const columnProps = columns.map((col) => col.prop); - expect(columnProps).toContain('outputs.result'); - expect(columnProps).toContain('outputs.count'); - expect(columnProps).toContain('outputs.data'); - expect(columnProps).toContain('outputs.error'); - }); - - it('should return columns with correct structure', () => { - const columns = getTestCasesColumns(mockTestCases, 'inputs'); - const firstColumn = columns[0]; - - expect(firstColumn).toHaveProperty('prop'); - expect(firstColumn).toHaveProperty('label'); - expect(firstColumn).toHaveProperty('sortable', true); - expect(firstColumn).toHaveProperty('filter', true); - expect(firstColumn).toHaveProperty('showHeaderTooltip', true); - }); - - it('should set correct label for columns', () => { - const columns = getTestCasesColumns(mockTestCases, 'inputs'); - const queryColumn = columns.find((col) => col.prop === 'inputs.query'); - - expect(queryColumn?.label).toBe('query'); + expect(inputColumns).toEqual([]); + expect(outputColumns).toEqual([]); }); it('should handle empty test cases array', () => { - const columns = getTestCasesColumns([], 'inputs'); + const inputColumns = getTestCasesColumns([], 'inputs'); + const outputColumns = getTestCasesColumns([], 'outputs'); - expect(columns).toHaveLength(0); + expect(inputColumns).toEqual([]); + expect(outputColumns).toEqual([]); }); - it('should handle test cases with no inputs', () => { - const testCasesWithoutInputs: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: {}, - outputs: { - result: 'success', - }, - metrics: { - accuracy: 0.95, - }, - }), - ]; - - const columns = getTestCasesColumns(testCasesWithoutInputs, 'inputs'); - - expect(columns).toHaveLength(0); - }); - - it('should handle test cases with no outputs', () => { - const testCasesWithoutOutputs: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - query: 'test', - }, - outputs: {}, - metrics: { - accuracy: 0.95, - }, - }), - ]; - - const columns = getTestCasesColumns(testCasesWithoutOutputs, 'outputs'); - - expect(columns).toHaveLength(0); - }); - - it('should handle test cases with undefined inputs', () => { - const testCasesWithUndefinedInputs: TestCaseExecutionRecord[] = [ + it('should handle test cases with null/undefined inputs/outputs', () => { + const testCases = [ { - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - outputs: { - result: 'success', - }, - metrics: { - accuracy: 0.95, - }, + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: undefined, + outputs: undefined, + }, + { + id: '2', + testRunId: 'run1', + executionId: 'exec2', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test' }, }, ]; - const columns = getTestCasesColumns(testCasesWithUndefinedInputs, 'inputs'); + const inputColumns = getTestCasesColumns(testCases, 'inputs'); + const outputColumns = getTestCasesColumns(testCases, 'outputs'); - expect(columns).toHaveLength(0); + expect(inputColumns).toEqual(['query']); + expect(outputColumns).toEqual([]); }); + }); - it('should handle test cases with undefined outputs', () => { - const testCasesWithUndefinedOutputs: TestCaseExecutionRecord[] = [ + describe('getDefaultOrderedColumns', () => { + it('should return default column order with inputs, outputs, metrics, and special columns', () => { + const run = { + id: 'run1', + workflowId: 'workflow1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + completedAt: '2023-01-01', + metrics: { + accuracy: 0.95, + precision: 0.88, + promptTokens: 100, + completionTokens: 50, + executionTime: 1.5, + }, + }; + + const testCases = [ { - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - query: 'test', - }, - metrics: { - accuracy: 0.95, - }, + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test', limit: 10 }, + outputs: { result: 'success', count: 3 }, }, ]; - const columns = getTestCasesColumns(testCasesWithUndefinedOutputs, 'outputs'); + const result = getDefaultOrderedColumns(run, testCases); - expect(columns).toHaveLength(0); + expect(result).toEqual([ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { + key: 'inputs.limit', + label: 'limit', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs', + }, + { + key: 'outputs.count', + label: 'count', + visible: true, + disabled: false, + columnType: 'outputs', + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'metrics.precision', + label: 'precision', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'metrics.promptTokens', + label: 'promptTokens', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'metrics.completionTokens', + label: 'completionTokens', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'metrics.executionTime', + label: 'executionTime', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + ]); }); - it('should handle mixed test cases with some having empty inputs/outputs', () => { - const mixedTestCases: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - query: 'test query', - limit: 10, - }, - outputs: { - result: 'success', - }, - metrics: { - accuracy: 0.95, - }, - }), - mock({ - id: 'test-case-2', - testRunId: 'test-run-1', - executionId: 'execution-2', - status: 'completed', - createdAt: '2023-10-01T10:01:00Z', - updatedAt: '2023-10-01T10:01:00Z', - runAt: '2023-10-01T10:01:00Z', + it('should handle undefined run and test cases', () => { + const result = getDefaultOrderedColumns(undefined, undefined); + + expect(result).toEqual([]); + }); + + it('should handle run without metrics', () => { + const run = { + id: 'run1', + workflowId: 'workflow1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + completedAt: '2023-01-01', + }; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test' }, + outputs: { result: 'success' }, + }, + ]; + + const result = getDefaultOrderedColumns(run, testCases); + + expect(result).toEqual([ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false, + columnType: 'inputs', + }, + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false, + columnType: 'outputs', + }, + ]); + }); + + it('should only include special metric columns that exist in run metrics', () => { + const run = { + id: 'run1', + workflowId: 'workflow1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + completedAt: '2023-01-01', + metrics: { + accuracy: 0.95, + promptTokens: 100, + // Missing completionTokens, totalTokens, executionTime + }, + }; + + const result = getDefaultOrderedColumns(run, []); + + expect(result).toEqual([ + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + { + key: 'metrics.promptTokens', + label: 'promptTokens', + visible: true, + disabled: false, + columnType: 'metrics', + numeric: true, + }, + ]); + }); + }); + + describe('getHeaders', () => { + it('should convert visible enabled columns to headers', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false as const, + columnType: 'metrics' as const, + numeric: true, + }, + { + key: 'outputs.result', + label: 'result', + visible: false, + disabled: false as const, + columnType: 'outputs' as const, + }, + { key: 'metrics.disabled', disabled: true as const }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'short' }, + metrics: { accuracy: 0.95 }, + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + + expect(result).toHaveLength(2); + expect(result[0].prop).toBe('inputs.query'); + expect(result[0].label).toBe('query'); + expect(result[0].sortable).toBe(true); + expect(result[0].showHeaderTooltip).toBe(true); + + expect(result[1].prop).toBe('metrics.accuracy'); + expect(result[1].label).toBe('accuracy'); + }); + + it('should format numeric values correctly in formatter', () => { + const columns = [ + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false as const, + columnType: 'metrics' as const, + numeric: true, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + metrics: { accuracy: 0.95678 }, + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + const formatter = result[0]?.formatter; + + const testRow = { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + metrics: { accuracy: 0.95678 }, + index: 1, + }; + + expect(formatter).toBeDefined(); + expect(formatter!(testRow)).toBe('0.96'); + }); + + it('should format object values as JSON string in formatter', () => { + const columns = [ + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false as const, + columnType: 'outputs' as const, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + outputs: { result: { status: 'success', data: [1, 2, 3] } }, + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + const formatter = result[0]?.formatter; + + const testRow = { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + outputs: { result: { status: 'success', data: [1, 2, 3] } }, + index: 1, + }; + + expect(formatter).toBeDefined(); + if (formatter) { + const formatted = formatter(testRow); + expect(formatted).toBe(JSON.stringify({ status: 'success', data: [1, 2, 3] }, null, 2)); + } + }); + + it('should format primitive values as strings in formatter', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test query' }, + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + const formatter = result[0]?.formatter; + + const testRow = { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test query' }, + index: 1, + }; + + expect(formatter).toBeDefined(); + if (formatter) { + expect(formatter(testRow)).toBe('test query'); + } + }); + + it('should handle missing values in formatter', () => { + const columns = [ + { + key: 'inputs.missing', + label: 'missing', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', inputs: {}, - outputs: { - result: 'success', - count: 5, - }, - metrics: { - accuracy: 0.88, - }, - }), - mock({ - id: 'test-case-3', - testRunId: 'test-run-1', - executionId: 'execution-3', - status: 'completed', - createdAt: '2023-10-01T10:02:00Z', - updatedAt: '2023-10-01T10:02:00Z', - runAt: '2023-10-01T10:02:00Z', - inputs: { - filter: 'active', - }, - outputs: {}, - metrics: { - accuracy: 0.92, - }, - }), + }, ]; - const inputColumns = getTestCasesColumns(mixedTestCases, 'inputs'); - const outputColumns = getTestCasesColumns(mixedTestCases, 'outputs'); + const result = getTestTableHeaders(columns, testCases); + const formatter = result[0]?.formatter; - expect(inputColumns).toHaveLength(3); - expect(outputColumns).toHaveLength(2); + const testRow = { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: {}, + index: 1, + }; - const inputProps = inputColumns.map((col) => col.prop); - expect(inputProps).toContain('inputs.query'); - expect(inputProps).toContain('inputs.limit'); - expect(inputProps).toContain('inputs.filter'); - - const outputProps = outputColumns.map((col) => col.prop); - expect(outputProps).toContain('outputs.result'); - expect(outputProps).toContain('outputs.count'); + expect(formatter).toBeDefined(); + if (formatter) { + expect(formatter(testRow)).toBe('undefined'); + } }); - it('should remove duplicate columns from multiple test cases', () => { - const testCasesWithDuplicates: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - query: 'test query 1', - limit: 10, - }, - outputs: { - result: 'success', - }, - metrics: { - accuracy: 0.95, - }, - }), - mock({ - id: 'test-case-2', - testRunId: 'test-run-1', - executionId: 'execution-2', - status: 'completed', - createdAt: '2023-10-01T10:01:00Z', - updatedAt: '2023-10-01T10:01:00Z', - runAt: '2023-10-01T10:01:00Z', - inputs: { - query: 'test query 2', - limit: 20, - }, - outputs: { - result: 'success', - }, - metrics: { - accuracy: 0.88, - }, - }), + it('should filter out disabled and invisible columns', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, + { + key: 'outputs.result', + label: 'result', + visible: false, + disabled: false as const, + columnType: 'outputs' as const, + }, + { key: 'metrics.disabled', disabled: true as const }, + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false as const, + columnType: 'metrics' as const, + numeric: true, + }, ]; - const inputColumns = getTestCasesColumns(testCasesWithDuplicates, 'inputs'); - const outputColumns = getTestCasesColumns(testCasesWithDuplicates, 'outputs'); - - expect(inputColumns).toHaveLength(2); - expect(outputColumns).toHaveLength(1); - }); - - it('should handle complex nested object keys', () => { - const testCasesWithComplexKeys: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - 'user.name': 'John Doe', - 'user.email': 'john@example.com', - 'config.timeout': 5000, - 'config.retries': 3, - }, - outputs: { - 'response.status': 200, - 'response.data': { success: true }, - }, - metrics: { - accuracy: 0.95, - }, - }), + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'test' }, + metrics: { accuracy: 0.95 }, + }, ]; - const inputColumns = getTestCasesColumns(testCasesWithComplexKeys, 'inputs'); - const outputColumns = getTestCasesColumns(testCasesWithComplexKeys, 'outputs'); + const result = getTestTableHeaders(columns, testCases); - expect(inputColumns).toHaveLength(4); - expect(outputColumns).toHaveLength(2); - - const inputLabels = inputColumns.map((col) => col.label); - expect(inputLabels).toContain('user.name'); - expect(inputLabels).toContain('user.email'); - expect(inputLabels).toContain('config.timeout'); - expect(inputLabels).toContain('config.retries'); - - const outputLabels = outputColumns.map((col) => col.label); - expect(outputLabels).toContain('response.status'); - expect(outputLabels).toContain('response.data'); + expect(result).toHaveLength(2); + expect(result[0]?.prop).toBe('inputs.query'); + expect(result[1]?.prop).toBe('metrics.accuracy'); }); - it('should maintain consistent column order across multiple calls', () => { - const columns1 = getTestCasesColumns(mockTestCases, 'inputs'); - const columns2 = getTestCasesColumns(mockTestCases, 'inputs'); - - expect(columns1.map((col) => col.prop)).toEqual(columns2.map((col) => col.prop)); - }); - - it('should handle single test case', () => { - const singleTestCase: TestCaseExecutionRecord[] = [ - mock({ - id: 'test-case-1', - testRunId: 'test-run-1', - executionId: 'execution-1', - status: 'completed', - createdAt: '2023-10-01T10:00:00Z', - updatedAt: '2023-10-01T10:00:00Z', - runAt: '2023-10-01T10:00:00Z', - inputs: { - query: 'single test', - }, - outputs: { - result: 'success', - }, - metrics: { - accuracy: 0.95, - }, - }), + it('should set minWidth to 125 for short content', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, ]; - const inputColumns = getTestCasesColumns(singleTestCase, 'inputs'); - const outputColumns = getTestCasesColumns(singleTestCase, 'outputs'); + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'short' }, // 5 characters, <= 10 + }, + ]; - expect(inputColumns).toHaveLength(1); - expect(outputColumns).toHaveLength(1); - expect(inputColumns[0].prop).toBe('inputs.query'); - expect(outputColumns[0].prop).toBe('outputs.result'); + const result = getTestTableHeaders(columns, testCases); + + expect(result).toHaveLength(1); + expect(result[0]?.minWidth).toBe(125); + }); + + it('should set minWidth to 250 for long content', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'this is a very long query string' }, // > 10 characters + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + + expect(result).toHaveLength(1); + expect(result[0]?.minWidth).toBe(250); + }); + + it('should set minWidth to 250 for long numeric content when formatted', () => { + const columns = [ + { + key: 'metrics.accuracy', + label: 'accuracy', + visible: true, + disabled: false as const, + columnType: 'metrics' as const, + numeric: true, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + metrics: { accuracy: 999999.999999 }, // When formatted to 2 decimals: "999999.00" (9 chars, still <= 10) + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + + expect(result).toHaveLength(1); + expect(result[0]?.minWidth).toBe(125); // Short content + }); + + it('should set minWidth to 250 for long JSON content', () => { + const columns = [ + { + key: 'outputs.result', + label: 'result', + visible: true, + disabled: false as const, + columnType: 'outputs' as const, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + outputs: { result: { status: 'success', data: [1, 2, 3], message: 'completed' } }, // Long JSON string + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + + expect(result).toHaveLength(1); + expect(result[0]?.minWidth).toBe(250); + }); + + it('should check all test cases to determine minWidth', () => { + const columns = [ + { + key: 'inputs.query', + label: 'query', + visible: true, + disabled: false as const, + columnType: 'inputs' as const, + }, + ]; + + const testCases = [ + { + id: '1', + testRunId: 'run1', + executionId: 'exec1', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'short' }, // 5 characters + }, + { + id: '2', + testRunId: 'run1', + executionId: 'exec2', + status: 'completed' as const, + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + runAt: '2023-01-01', + inputs: { query: 'this is a very long query' }, // > 10 characters + }, + ]; + + const result = getTestTableHeaders(columns, testCases); + + expect(result).toHaveLength(1); + expect(result[0]?.minWidth).toBe(250); // Should be 250 because second test case has long content }); }); }); diff --git a/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.ts b/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.ts index d32d1db622..490b575c4d 100644 --- a/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.ts +++ b/packages/frontend/editor-ui/src/views/Evaluations.ee/utils.ts @@ -1,10 +1,77 @@ -import type { TestTableColumn } from '@/components/Evaluations.ee/shared/TestTableBase.vue'; -import type { TestCaseExecutionRecord } from '../../api/evaluation.ee'; +import type { JsonValue } from 'n8n-workflow'; +import type { TestCaseExecutionRecord, TestRunRecord } from '../../api/evaluation.ee'; +import type { Column, Header } from './TestRunDetailView.vue'; + +export const SHORT_TABLE_CELL_MIN_WIDTH = 125; +const LONG_TABLE_CELL_MIN_WIDTH = 250; +const specialKeys = ['promptTokens', 'completionTokens', 'totalTokens', 'executionTime']; + +export function getDefaultOrderedColumns( + run?: TestRunRecord, + filteredTestCases?: TestCaseExecutionRecord[], +) { + // Default sort order + // -> inputs, outputs, metrics, tokens, executionTime + const metricColumns = Object.keys(run?.metrics ?? {}).filter((key) => !specialKeys.includes(key)); + const specialColumns = specialKeys.filter((key) => (run?.metrics ? key in run.metrics : false)); + const inputColumns = getTestCasesColumns(filteredTestCases ?? [], 'inputs'); + const outputColumns = getTestCasesColumns(filteredTestCases ?? [], 'outputs'); + + const defaultOrder: Column[] = [ + ...mapToColumns(inputColumns, 'inputs'), + ...mapToColumns(outputColumns, 'outputs'), + ...mapToColumns(metricColumns, 'metrics', true), + ...mapToColumns(specialColumns, 'metrics', true), + ]; + + return defaultOrder; +} + +export function applyCachedVisibility( + columns: Column[], + visibility?: Record, +): Column[] { + if (!visibility) { + return columns; + } + + return columns.map((column) => + column.disabled + ? column + : { + ...column, + visible: visibility.hasOwnProperty(column.key) ? visibility[column.key] : column.visible, + }, + ); +} + +export function applyCachedSortOrder(defaultOrder: Column[], cachedOrder?: string[]): Column[] { + if (!cachedOrder?.length) { + return defaultOrder; + } + + const result = cachedOrder.map((columnKey): Column => { + const matchingColumn = defaultOrder.find((col) => columnKey === col.key); + if (!matchingColumn) { + return { + key: columnKey, + disabled: true, + }; + } + return matchingColumn; + }); + + // Add any missing columns from defaultOrder that aren't in the cached order + const missingColumns = defaultOrder.filter((defaultCol) => !cachedOrder.includes(defaultCol.key)); + result.push(...missingColumns); + + return result; +} export function getTestCasesColumns( cases: TestCaseExecutionRecord[], columnType: 'inputs' | 'outputs', -): Array> { +): string[] { const inputColumnNames = cases.reduce( (set, testCase) => { Object.keys(testCase[columnType] ?? {}).forEach((key) => set.add(key)); @@ -13,30 +80,72 @@ export function getTestCasesColumns( new Set([] as string[]), ); - return Array.from(inputColumnNames.keys()).map((column) => ({ - prop: `${columnType}.${column}`, + return Array.from(inputColumnNames.keys()); +} + +function mapToColumns( + columnNames: string[], + columnType: 'inputs' | 'outputs' | 'metrics', + numeric?: boolean, +): Column[] { + return columnNames.map((column) => ({ + key: `${columnType}.${column}`, label: column, - sortable: true, - filter: true, - showHeaderTooltip: true, - formatter: (row: TestCaseExecutionRecord) => { - const value = row[columnType]?.[column]; - if (typeof value === 'object' && value !== null) { - return JSON.stringify(value, null, 2); - } - - return `${value}`; - }, + visible: true, + disabled: false, + columnType, + numeric, })); } -export function mapToNumericColumns(columnNames: string[]) { - return columnNames.map((metric) => ({ - prop: `metrics.${metric}`, - label: metric, - sortable: true, - filter: true, - showHeaderTooltip: true, - formatter: (row: TestCaseExecutionRecord) => row.metrics?.[metric]?.toFixed(2) ?? '-', - })); +function formatValue( + key: string, + value?: JsonValue, + { numeric }: { numeric?: boolean } = { numeric: false }, +) { + let stringValue: string; + + if (numeric && typeof value === 'number' && !specialKeys.includes(key)) { + stringValue = value.toFixed(2) ?? '-'; + } else if (typeof value === 'object' && value !== null) { + stringValue = JSON.stringify(value, null, 2); + } else { + stringValue = `${value}`; + } + + return stringValue; +} + +export function getTestTableHeaders( + columnNames: Column[], + testCases: TestCaseExecutionRecord[], +): Header[] { + return columnNames + .filter((column): column is Column & { disabled: false } => !column.disabled && column.visible) + .map((column) => { + // Check if any content exceeds 10 characters + const hasLongContent = Boolean( + testCases.find((row) => { + const value = row[column.columnType]?.[column.label]; + const formattedValue = formatValue(column.label, value, { numeric: column.numeric }); + + return formattedValue?.length > 10; + }), + ); + + return { + prop: column.key, + label: column.disabled ? column.key : column.label, + sortable: true, + filter: true, + showHeaderTooltip: true, + minWidth: hasLongContent ? LONG_TABLE_CELL_MIN_WIDTH : SHORT_TABLE_CELL_MIN_WIDTH, + formatter: (row: TestCaseExecutionRecord) => { + const value = row[column.columnType]?.[column.label]; + const formattedValue = formatValue(column.label, value, { numeric: column.numeric }); + + return formattedValue; + }, + }; + }); }