mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(editor): Add dragging and hiding for evaluation table columns (#17587)
This commit is contained in:
@@ -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: '<N8nExternalLink v-bind="args">{{ args.default }}</N8nExternalLink>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { IconSize } from '@n8n/design-system/types';
|
||||||
|
|
||||||
|
import N8nIcon from '../N8nIcon';
|
||||||
|
|
||||||
|
interface ExternalLinkProps {
|
||||||
|
href?: string;
|
||||||
|
size?: IconSize;
|
||||||
|
newWindow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ name: 'N8nExternalLink' });
|
||||||
|
withDefaults(defineProps<ExternalLinkProps>(), {
|
||||||
|
href: undefined,
|
||||||
|
size: undefined,
|
||||||
|
newWindow: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="href ? 'a' : 'button'"
|
||||||
|
:href="href"
|
||||||
|
:target="href && newWindow ? '_blank' : undefined"
|
||||||
|
:rel="href && newWindow ? 'noopener noreferrer' : undefined"
|
||||||
|
:class="$style.link"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
<N8nIcon icon="external-link" :size="size" />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.link {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4xs);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--spacing-4xs) var(--spacing-2xs);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-foreground-light);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: var(--color-primary-shade-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`components > N8nExternalLink > should render correctly with href 1`] = `
|
||||||
|
"<a href="https://n8n.io" target="_blank" rel="noopener noreferrer" class="link">
|
||||||
|
<n8n-icon-stub icon="external-link" spin="false"></n8n-icon-stub>
|
||||||
|
</a>"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`components > N8nExternalLink > should render correctly with slot content 1`] = `"<a href="https://n8n.io" target="_blank" rel="noopener noreferrer" class="link">Visit n8n<n8n-icon-stub icon="external-link" spin="false"></n8n-icon-stub></a>"`;
|
||||||
|
|
||||||
|
exports[`components > N8nExternalLink > should render correctly without href 1`] = `
|
||||||
|
"<button class="link">
|
||||||
|
<n8n-icon-stub icon="external-link" spin="false"></n8n-icon-stub>
|
||||||
|
</button>"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import N8nExternalLink from './ExternalLink.vue';
|
||||||
|
|
||||||
|
export default N8nExternalLink;
|
||||||
@@ -18,12 +18,6 @@ exports[`Icon > should render correctly with a deprecated icon 1`] = `
|
|||||||
</svg>"
|
</svg>"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`Icon > should render correctly with all props combined 1`] = `
|
|
||||||
"<svg viewBox="0 0 24 24" width="14px" height="14px" class="n8n-icon spin" aria-hidden="true" focusable="false" role="img" data-icon="check">
|
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5"></path>
|
|
||||||
</svg>"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`Icon > should render correctly with default props 1`] = `
|
exports[`Icon > should render correctly with default props 1`] = `
|
||||||
"<svg viewBox="0 0 24 24" width="1em" height="1em" class="n8n-icon" aria-hidden="true" focusable="false" role="img" data-icon="check">
|
"<svg viewBox="0 0 24 24" width="1em" height="1em" class="n8n-icon" aria-hidden="true" focusable="false" role="img" data-icon="check">
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5"></path>
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5"></path>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { StoryFn } from '@storybook/vue3';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import N8nPopoverReka from './N8nPopoverReka.vue';
|
||||||
|
import N8nButton from '../N8nButton/Button.vue';
|
||||||
|
import N8nInput from '../N8nInput/Input.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Atoms/PopoverReka',
|
||||||
|
component: N8nPopoverReka,
|
||||||
|
argTypes: {
|
||||||
|
enableScrolling: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
scrollType: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['auto', 'always', 'scroll', 'hover'],
|
||||||
|
},
|
||||||
|
maxHeight: {
|
||||||
|
control: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Template: StoryFn = (args) => ({
|
||||||
|
setup() {
|
||||||
|
const username = ref('');
|
||||||
|
const email = ref('');
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
return { args, username, email, isOpen };
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
N8nPopoverReka,
|
||||||
|
N8nButton,
|
||||||
|
N8nInput,
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div style="padding: 50px;">
|
||||||
|
<N8nPopoverReka v-model:open="isOpen" v-bind="args">
|
||||||
|
<template #trigger>
|
||||||
|
<N8nButton type="primary">Open Form</N8nButton>
|
||||||
|
</template>
|
||||||
|
<template #content="{ close }">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<h3 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600;">User Information</h3>
|
||||||
|
<N8nInput
|
||||||
|
v-model="username"
|
||||||
|
placeholder="Enter username"
|
||||||
|
label="Username"
|
||||||
|
/>
|
||||||
|
<N8nInput
|
||||||
|
v-model="email"
|
||||||
|
placeholder="Enter email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||||
|
<N8nButton size="small" type="primary">Save</N8nButton>
|
||||||
|
<N8nButton size="small" type="secondary" @click="close">Cancel</N8nButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</N8nPopoverReka>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: `
|
||||||
|
<div style="padding: 50px;">
|
||||||
|
<N8nPopoverReka v-model:open="isOpen" v-bind="args">
|
||||||
|
<template #trigger>
|
||||||
|
<N8nButton type="primary">Open Scrollable Menu</N8nButton>
|
||||||
|
</template>
|
||||||
|
<template #content="{ close }">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||||
|
<h3 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600;">Menu Items</h3>
|
||||||
|
<div v-for="i in 20" :key="i"
|
||||||
|
style="padding: 8px 12px; background: var(--color-background-base); border-radius: 4px; cursor: pointer; min-height: 40px; display: flex; align-items: center;"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Menu Item {{ i }}: Some description text that explains what this item does
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</N8nPopoverReka>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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';
|
||||||
@@ -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:
|
||||||
|
'<mock-popover-content v-bind="$attrs" :style="$attrs.style"><slot /></mock-popover-content>',
|
||||||
|
props: ['open', 'side', 'sideOffset', 'class', 'style'],
|
||||||
|
},
|
||||||
|
PopoverPortal: { template: '<mock-popover-portal><slot /></mock-popover-portal>' },
|
||||||
|
PopoverRoot: {
|
||||||
|
template:
|
||||||
|
'<mock-popover-root v-bind="$attrs" @update:open="$emit(\'update:open\', $event)"><slot /></mock-popover-root>',
|
||||||
|
props: ['open'],
|
||||||
|
emits: ['update:open'],
|
||||||
|
},
|
||||||
|
PopoverTrigger: {
|
||||||
|
template: '<mock-popover-trigger v-bind="$attrs"><slot /></mock-popover-trigger>',
|
||||||
|
props: ['asChild'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('N8nPopoverReka', () => {
|
||||||
|
it('should render correctly with default props', () => {
|
||||||
|
const wrapper = render(N8nPopoverReka, {
|
||||||
|
props: {},
|
||||||
|
global: {
|
||||||
|
stubs: defaultStubs,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
trigger: '<button />',
|
||||||
|
content: '<content />',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.html()).toMatchSnapshot();
|
||||||
|
expect(wrapper.html()).toContain('<button></button>');
|
||||||
|
expect(wrapper.html()).toContain('<content></content>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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:
|
||||||
|
'<mock-popover-content v-bind="$attrs"><slot :close="mockClose" /></mock-popover-content>',
|
||||||
|
props: ['side', 'sideOffset', 'class'],
|
||||||
|
setup() {
|
||||||
|
const mockClose = vi.fn(() => {
|
||||||
|
if (closeFunction) {
|
||||||
|
closeFunction();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { mockClose };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
trigger: '<button />',
|
||||||
|
content: ({ close }: { close: () => void }) => {
|
||||||
|
closeFunction = close;
|
||||||
|
return '<button>Close</button>';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '<button />',
|
||||||
|
content: '<content />',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.props('maxHeight')).toBe('200px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply maxHeight style when maxHeight prop is not provided', () => {
|
||||||
|
const wrapper = mount(N8nPopoverReka, {
|
||||||
|
props: {},
|
||||||
|
global: {
|
||||||
|
stubs: defaultStubs,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
trigger: '<button />',
|
||||||
|
content: '<content />',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.props('maxHeight')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
|
||||||
|
|
||||||
|
import N8nScrollArea from '../N8nScrollArea/N8nScrollArea.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to enable scrolling in the popover content
|
||||||
|
*/
|
||||||
|
enableScrolling?: boolean;
|
||||||
|
/**
|
||||||
|
* Scrollbar visibility behavior
|
||||||
|
*/
|
||||||
|
scrollType?: 'auto' | 'always' | 'scroll' | 'hover';
|
||||||
|
/**
|
||||||
|
* Popover width
|
||||||
|
*/
|
||||||
|
width?: string;
|
||||||
|
/**
|
||||||
|
* Popover max height
|
||||||
|
*/
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(event: 'update:open', value: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
open: undefined,
|
||||||
|
maxHeight: undefined,
|
||||||
|
width: undefined,
|
||||||
|
enableScrolling: true,
|
||||||
|
scrollType: 'hover',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverRoot :open="open" @update:open="emit('update:open', $event)">
|
||||||
|
<PopoverTrigger :as-child="true">
|
||||||
|
<slot name="trigger"></slot>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverPortal>
|
||||||
|
<PopoverContent side="bottom" :side-offset="5" :class="$style.popoverContent">
|
||||||
|
<N8nScrollArea
|
||||||
|
v-if="enableScrolling"
|
||||||
|
:max-height="props.maxHeight"
|
||||||
|
:type="scrollType"
|
||||||
|
:enable-vertical-scroll="true"
|
||||||
|
:enable-horizontal-scroll="false"
|
||||||
|
>
|
||||||
|
<div :class="$style.contentContainer" :style="{ width }">
|
||||||
|
<slot name="content" :close="() => emit('update:open', false)" />
|
||||||
|
</div>
|
||||||
|
</N8nScrollArea>
|
||||||
|
<div v-else :class="$style.contentContainer" :style="{ width }">
|
||||||
|
<slot name="content" :close="() => emit('update:open', false)" />
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</PopoverRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.popoverContent {
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
border: var(--border-base);
|
||||||
|
box-shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0px 10px 15px -3px,
|
||||||
|
rgba(0, 0, 0, 0.05) 0px 4px 6px -2px;
|
||||||
|
animation-duration: 400ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentContainer {
|
||||||
|
padding: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverContent[data-state='open'][data-side='top'] {
|
||||||
|
animation-name: slideDownAndFade;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverContent[data-state='open'][data-side='right'] {
|
||||||
|
animation-name: slideLeftAndFade;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverContent[data-state='open'][data-side='bottom'] {
|
||||||
|
animation-name: slideUpAndFade;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverContent[data-state='open'][data-side='left'] {
|
||||||
|
animation-name: slideRightAndFade;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUpAndFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideRightAndFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDownAndFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideLeftAndFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`N8nPopoverReka > should render correctly with default props 1`] = `
|
||||||
|
"<mock-popover-root>
|
||||||
|
<mock-popover-trigger><button></button></mock-popover-trigger>
|
||||||
|
<mock-popover-portal>
|
||||||
|
<mock-popover-content>
|
||||||
|
<div dir="ltr" style="position: relative; --reka-scroll-area-corner-width: 0px; --reka-scroll-area-corner-height: 0px;" class="scrollAreaRoot">
|
||||||
|
<div data-reka-scroll-area-viewport="" style="overflow-x: hidden; overflow-y: hidden;" class="viewport" tabindex="0">
|
||||||
|
<div>
|
||||||
|
<div class="contentContainer">
|
||||||
|
<content></content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */
|
||||||
|
[data-reka-scroll-area-viewport] {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-reka-scroll-area-viewport]::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!---->
|
||||||
|
<!--v-if-->
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
</mock-popover-content>
|
||||||
|
</mock-popover-portal>
|
||||||
|
</mock-popover-root>"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import N8nPopoverReka from './N8nPopoverReka.vue';
|
||||||
|
|
||||||
|
export default N8nPopoverReka;
|
||||||
@@ -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: `
|
||||||
|
<div style="width: 300px; height: 200px; border: 1px solid var(--color-foreground-base); border-radius: 4px;">
|
||||||
|
<N8nScrollArea v-bind="args">
|
||||||
|
<div style="padding: 16px;">
|
||||||
|
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Scrollable Content</h3>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.6;">
|
||||||
|
This is a scrollable area with custom styled scrollbars. The content will scroll when it overflows the container.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.6;">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.6;">
|
||||||
|
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.6;">
|
||||||
|
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px 0; line-height: 1.6;">
|
||||||
|
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; line-height: 1.6;">
|
||||||
|
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</N8nScrollArea>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: `
|
||||||
|
<div style="width: 300px; height: 100px; border: 1px solid var(--color-foreground-base); border-radius: 4px;">
|
||||||
|
<N8nScrollArea v-bind="args">
|
||||||
|
<div style="padding: 16px; white-space: nowrap; width: 600px;">
|
||||||
|
<span style="font-weight: 600;">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.</span>
|
||||||
|
<div style="margin-left: 12px; max-width: 200px; text-wrap: auto">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.</div>
|
||||||
|
</div>
|
||||||
|
</N8nScrollArea>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: `
|
||||||
|
<div style="width: 260px; padding: 16px; background-color: var(--color-foreground-xlight); border: var(--border-base); border-radius: var(--border-radius-base); box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px;">
|
||||||
|
<N8nScrollArea v-bind="args">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<h3 style="margin: 0; font-size: 14px; font-weight: 600;">Long Menu Items</h3>
|
||||||
|
<div v-for="i in 15" :key="i" style="padding: 8px 12px; background: var(--color-background-base); border-radius: 4px; cursor: pointer;">
|
||||||
|
Menu item {{ i }}: Some descriptive text that might be quite long
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</N8nScrollArea>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InPopover = InPopoverTemplate.bind({});
|
||||||
|
InPopover.args = {
|
||||||
|
type: 'hover',
|
||||||
|
maxHeight: '200px',
|
||||||
|
enableVerticalScroll: true,
|
||||||
|
enableHorizontalScroll: false,
|
||||||
|
};
|
||||||
|
InPopover.storyName = 'Popover Example';
|
||||||
@@ -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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with custom type prop', () => {
|
||||||
|
const wrapper = render(N8nScrollArea, {
|
||||||
|
props: {
|
||||||
|
type: 'always',
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<div>Test content</div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ScrollAreaCorner,
|
||||||
|
ScrollAreaRoot,
|
||||||
|
ScrollAreaScrollbar,
|
||||||
|
ScrollAreaThumb,
|
||||||
|
ScrollAreaViewport,
|
||||||
|
} from 'reka-ui';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* Controls scrollbar visibility behavior
|
||||||
|
* - auto: shows scrollbars only when content overflows
|
||||||
|
* - always: always shows scrollbars
|
||||||
|
* - scroll: shows scrollbars when scrolling
|
||||||
|
* - hover: shows scrollbars on hover
|
||||||
|
*/
|
||||||
|
type?: 'auto' | 'always' | 'scroll' | 'hover';
|
||||||
|
/**
|
||||||
|
* Reading direction for RTL support
|
||||||
|
*/
|
||||||
|
dir?: 'ltr' | 'rtl';
|
||||||
|
/**
|
||||||
|
* Time in milliseconds before scrollbars auto-hide
|
||||||
|
*/
|
||||||
|
scrollHideDelay?: number;
|
||||||
|
/**
|
||||||
|
* Maximum height of the scroll area
|
||||||
|
*/
|
||||||
|
maxHeight?: string;
|
||||||
|
/**
|
||||||
|
* Maximum width of the scroll area
|
||||||
|
*/
|
||||||
|
maxWidth?: string;
|
||||||
|
/**
|
||||||
|
* Whether to show horizontal scrollbar
|
||||||
|
*/
|
||||||
|
enableHorizontalScroll?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to show vertical scrollbar
|
||||||
|
*/
|
||||||
|
enableVerticalScroll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'hover',
|
||||||
|
dir: 'ltr',
|
||||||
|
scrollHideDelay: 600,
|
||||||
|
maxHeight: undefined,
|
||||||
|
maxWidth: undefined,
|
||||||
|
enableHorizontalScroll: false,
|
||||||
|
enableVerticalScroll: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewportStyle = computed(() => {
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
if (props.maxHeight) {
|
||||||
|
style.maxHeight = props.maxHeight;
|
||||||
|
}
|
||||||
|
if (props.maxWidth) {
|
||||||
|
style.maxWidth = props.maxWidth;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScrollAreaRoot
|
||||||
|
:type="type"
|
||||||
|
:dir="dir"
|
||||||
|
:scroll-hide-delay="scrollHideDelay"
|
||||||
|
:class="$style.scrollAreaRoot"
|
||||||
|
>
|
||||||
|
<ScrollAreaViewport :class="$style.viewport" :style="viewportStyle">
|
||||||
|
<slot />
|
||||||
|
</ScrollAreaViewport>
|
||||||
|
|
||||||
|
<ScrollAreaScrollbar
|
||||||
|
v-if="enableVerticalScroll"
|
||||||
|
orientation="vertical"
|
||||||
|
:class="$style.scrollbar"
|
||||||
|
>
|
||||||
|
<ScrollAreaThumb :class="$style.thumb" />
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
|
||||||
|
<ScrollAreaScrollbar
|
||||||
|
v-if="enableHorizontalScroll"
|
||||||
|
orientation="horizontal"
|
||||||
|
:class="$style.scrollbar"
|
||||||
|
>
|
||||||
|
<ScrollAreaThumb :class="$style.thumb" />
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
|
||||||
|
<ScrollAreaCorner v-if="enableHorizontalScroll && enableVerticalScroll" />
|
||||||
|
</ScrollAreaRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.scrollAreaRoot {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
--scrollbar-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar {
|
||||||
|
display: flex;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
padding: var(--spacing-5xs);
|
||||||
|
background: transparent;
|
||||||
|
transition: background 160ms ease-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-foreground-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-orientation='vertical'] {
|
||||||
|
width: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-orientation='horizontal'] {
|
||||||
|
height: var(--spacing-xs);
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-foreground-base);
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-foreground-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--color-foreground-xdark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style the scrollbar when type is 'always' to be more subtle
|
||||||
|
.scrollAreaRoot[data-type='always'] {
|
||||||
|
.scrollbar {
|
||||||
|
background: var(--color-foreground-xlight);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-foreground-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
background: var(--color-foreground-light);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-foreground-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced styling for hover type
|
||||||
|
.scrollAreaRoot[data-type='hover'] {
|
||||||
|
.scrollbar {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .scrollbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`N8nScrollArea > should pass scrollHideDelay prop to ScrollAreaRoot 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="scrollAreaRoot"
|
||||||
|
dir="ltr"
|
||||||
|
style="position: relative; --reka-scroll-area-corner-width: 0px; --reka-scroll-area-corner-height: 0px;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="viewport"
|
||||||
|
data-reka-scroll-area-viewport=""
|
||||||
|
style="overflow-x: hidden; overflow-y: hidden;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Test content
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */ [data-reka-scroll-area-viewport] { scrollbar-width:none; -ms-overflow-style:none; -webkit-overflow-scrolling:touch; } [data-reka-scroll-area-viewport]::-webkit-scrollbar { display:none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
<!--v-if-->
|
||||||
|
<!--v-if-->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`N8nScrollArea > should render correctly with default props 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="scrollAreaRoot"
|
||||||
|
dir="ltr"
|
||||||
|
style="position: relative; --reka-scroll-area-corner-width: 0px; --reka-scroll-area-corner-height: 0px;"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="viewport"
|
||||||
|
data-reka-scroll-area-viewport=""
|
||||||
|
style="overflow-x: hidden; overflow-y: hidden;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Test content
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */ [data-reka-scroll-area-viewport] { scrollbar-width:none; -ms-overflow-style:none; -webkit-overflow-scrolling:touch; } [data-reka-scroll-area-viewport]::-webkit-scrollbar { display:none; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!---->
|
||||||
|
<!--v-if-->
|
||||||
|
<!--v-if-->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import N8nScrollArea from './N8nScrollArea.vue';
|
||||||
|
|
||||||
|
export default N8nScrollArea;
|
||||||
@@ -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<StoryArgs> = (args) => ({
|
||||||
|
setup: () => {
|
||||||
|
const columns = ref<ColumnHeader[]>([...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: `
|
||||||
|
<table-header-controls-button
|
||||||
|
:columns="columns"
|
||||||
|
@update:columnVisibility="handleColumnVisibilityUpdate"
|
||||||
|
@update:columnOrder="handleColumnOrderUpdate"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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<DataTransfer>) => {
|
||||||
|
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:
|
||||||
|
'<div><trigger><slot name="trigger" /></trigger> <content><slot name="content" /></content></div>',
|
||||||
|
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: '<mock-button :size="size"><slot /></mock-button>',
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<script setup lang="ts" generic="ColumnType extends ColumnHeader">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useI18n } from '@n8n/design-system/composables/useI18n';
|
||||||
|
import type { ButtonSize, IconSize } from '@n8n/design-system/types';
|
||||||
|
|
||||||
|
import N8nButton from '../N8nButton';
|
||||||
|
import N8nIcon from '../N8nIcon';
|
||||||
|
import N8nPopoverReka from '../N8nPopoverReka/N8nPopoverReka.vue';
|
||||||
|
|
||||||
|
export type ColumnHeader =
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
visible: boolean;
|
||||||
|
disabled: false;
|
||||||
|
}
|
||||||
|
// 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 Props {
|
||||||
|
columns: ColumnType[];
|
||||||
|
buttonSize?: ButtonSize;
|
||||||
|
iconSize?: IconSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const visibleColumns = computed(() =>
|
||||||
|
props.columns.filter(
|
||||||
|
(column): column is ColumnType & { disabled: false } => !column.disabled && column.visible,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const hiddenColumns = computed(() =>
|
||||||
|
props.columns.filter(
|
||||||
|
(column): column is ColumnType & { disabled: false } => !column.disabled && !column.visible,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const draggedItem = ref<string | null>(null);
|
||||||
|
const dragOverItem = ref<string | null>(null);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:columnVisibility': [key: string, visibility: boolean];
|
||||||
|
'update:columnOrder': [newOrder: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const resetDragState = () => {
|
||||||
|
draggedItem.value = null;
|
||||||
|
dragOverItem.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragEvent, columnKey: string) => {
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
draggedItem.value = columnKey;
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', columnKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent, columnKey: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
dragOverItem.value = columnKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
dragOverItem.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent, targetColumnKey: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const draggedColumnKey = draggedItem.value;
|
||||||
|
if (!draggedColumnKey || draggedColumnKey === targetColumnKey) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all column keys in their original order, including hidden and disabled
|
||||||
|
const allColumnKeys = props.columns.map((col) => col.key);
|
||||||
|
const draggedIndex = allColumnKeys.indexOf(draggedColumnKey);
|
||||||
|
|
||||||
|
if (draggedIndex === -1) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newOrder: string[];
|
||||||
|
|
||||||
|
if (targetColumnKey === 'END') {
|
||||||
|
// Move to end
|
||||||
|
newOrder = [...allColumnKeys];
|
||||||
|
newOrder.splice(draggedIndex, 1);
|
||||||
|
newOrder.push(draggedColumnKey);
|
||||||
|
} else {
|
||||||
|
// Move to specific position
|
||||||
|
const targetIndex = allColumnKeys.indexOf(targetColumnKey);
|
||||||
|
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newOrder = [...allColumnKeys];
|
||||||
|
newOrder.splice(draggedIndex, 1);
|
||||||
|
|
||||||
|
// When dragging onto a target, insert at the target's position
|
||||||
|
// The target will naturally shift due to the insertion
|
||||||
|
let insertIndex = targetIndex;
|
||||||
|
|
||||||
|
// If we removed an item before the target, the target's index has shifted left by 1
|
||||||
|
if (draggedIndex <= targetIndex) {
|
||||||
|
insertIndex = targetIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
newOrder.splice(insertIndex, 0, draggedColumnKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:columnOrder', newOrder);
|
||||||
|
resetDragState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
resetDragState();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<N8nPopoverReka :class="$style.container" width="260px" max-height="300px" scroll-type="auto">
|
||||||
|
<template #trigger>
|
||||||
|
<N8nButton
|
||||||
|
icon="sliders-horizontal"
|
||||||
|
type="secondary"
|
||||||
|
:icon-size="iconSize"
|
||||||
|
:size="buttonSize"
|
||||||
|
>
|
||||||
|
{{ t('tableControlsButton.display') }}
|
||||||
|
</N8nButton>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div
|
||||||
|
v-if="visibleColumns.length"
|
||||||
|
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
|
||||||
|
data-testid="visible-columns-section"
|
||||||
|
>
|
||||||
|
<h5 :class="$style.header">
|
||||||
|
{{ t('tableControlsButton.shown') }}
|
||||||
|
</h5>
|
||||||
|
<div v-for="column in visibleColumns" :key="column.key" :class="$style.columnWrapper">
|
||||||
|
<div
|
||||||
|
v-if="dragOverItem === column.key"
|
||||||
|
:class="$style.dropIndicator"
|
||||||
|
data-testid="drop-indicator"
|
||||||
|
></div>
|
||||||
|
<fieldset
|
||||||
|
:class="[
|
||||||
|
$style.column,
|
||||||
|
$style.draggable,
|
||||||
|
{ [$style.dragging]: draggedItem === column.key },
|
||||||
|
]"
|
||||||
|
draggable="true"
|
||||||
|
data-testid="visible-column"
|
||||||
|
:data-column-key="column.key"
|
||||||
|
@dragstart="(event) => handleDragStart(event, column.key)"
|
||||||
|
@dragover="(event) => handleDragOver(event, column.key)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="(event) => handleDrop(event, column.key)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
>
|
||||||
|
<N8nIcon icon="grip-vertical" :class="$style.grip" />
|
||||||
|
<label>{{ column.label }}</label>
|
||||||
|
<N8nIcon
|
||||||
|
:class="$style.visibilityToggle"
|
||||||
|
icon="eye"
|
||||||
|
data-testid="visibility-toggle-visible"
|
||||||
|
@click="() => emit('update:columnVisibility', column.key, false)"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<!-- Drop zone at the end -->
|
||||||
|
<div
|
||||||
|
:class="$style.endDropZone"
|
||||||
|
data-testid="end-drop-zone"
|
||||||
|
@dragover="(event) => handleDragOver(event, 'END')"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="(event) => handleDrop(event, 'END')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="dragOverItem === 'END'"
|
||||||
|
:class="$style.dropIndicator"
|
||||||
|
data-testid="drop-indicator"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hiddenColumns.length"
|
||||||
|
:style="{ display: 'flex', flexDirection: 'column', gap: 2 }"
|
||||||
|
data-testid="hidden-columns-section"
|
||||||
|
>
|
||||||
|
<h4 :class="$style.header">
|
||||||
|
{{ t('tableControlsButton.hidden') }}
|
||||||
|
</h4>
|
||||||
|
<fieldset
|
||||||
|
v-for="column in hiddenColumns"
|
||||||
|
:key="column.key"
|
||||||
|
:class="[$style.column, $style.hidden]"
|
||||||
|
data-testid="hidden-column"
|
||||||
|
:data-column-key="column.key"
|
||||||
|
>
|
||||||
|
<N8nIcon icon="grip-vertical" :class="[$style.grip, $style.hidden]" />
|
||||||
|
<label>{{ column.label }}</label>
|
||||||
|
<N8nIcon
|
||||||
|
:class="$style.visibilityToggle"
|
||||||
|
icon="eye-off"
|
||||||
|
data-testid="visibility-toggle-hidden"
|
||||||
|
@click="() => emit('update:columnVisibility', column.key, true)"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</N8nPopoverReka>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.header {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grip {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
cursor: move;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
padding: 6px 0;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable {
|
||||||
|
cursor: grab;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropIndicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background-color: var(--prim-color-secondary);
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endDropZone {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
color: var(--color-text-lighter);
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibilityToggle {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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`] = `
|
||||||
|
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
|
||||||
|
<trigger>
|
||||||
|
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||||
|
</trigger>
|
||||||
|
<content></content>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`TableHeaderControlsButton > should render correctly with all columns hidden 1`] = `
|
||||||
|
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
|
||||||
|
<trigger>
|
||||||
|
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||||
|
</trigger>
|
||||||
|
<content>
|
||||||
|
<!--v-if-->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="hidden-columns-section">
|
||||||
|
<h4 class="header">Hidden columns</h4>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col1">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 1</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col2">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 2</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col3">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 3</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col4">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 4</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col5">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 5</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</content>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`TableHeaderControlsButton > should render correctly with all columns visible 1`] = `
|
||||||
|
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
|
||||||
|
<trigger>
|
||||||
|
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||||
|
</trigger>
|
||||||
|
<content>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="visible-columns-section">
|
||||||
|
<h5 class="header">Shown columns</h5>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col1">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 1</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col2">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 2</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col3">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 3</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col4">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 4</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col5">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 5</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div><!-- Drop zone at the end -->
|
||||||
|
<div class="endDropZone" data-testid="end-drop-zone">
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
</content>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`TableHeaderControlsButton > should render correctly with mixed visible and hidden columns 1`] = `
|
||||||
|
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
|
||||||
|
<trigger>
|
||||||
|
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||||
|
</trigger>
|
||||||
|
<content>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="visible-columns-section">
|
||||||
|
<h5 class="header">Shown columns</h5>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col1">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 1</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col2">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 2</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="columnWrapper">
|
||||||
|
<!--v-if-->
|
||||||
|
<fieldset class="column draggable" draggable="true" data-testid="visible-column" data-column-key="col4">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip"></n8n-icon-stub><label>Column 4</label>
|
||||||
|
<n8n-icon-stub icon="eye" spin="false" class="visibilityToggle" data-testid="visibility-toggle-visible"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div><!-- Drop zone at the end -->
|
||||||
|
<div class="endDropZone" data-testid="end-drop-zone">
|
||||||
|
<!--v-if-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2;" data-testid="hidden-columns-section">
|
||||||
|
<h4 class="header">Hidden columns</h4>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col3">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 3</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="column hidden" data-testid="hidden-column" data-column-key="col5">
|
||||||
|
<n8n-icon-stub icon="grip-vertical" spin="false" class="grip hidden"></n8n-icon-stub><label>Column 5</label>
|
||||||
|
<n8n-icon-stub icon="eye-off" spin="false" class="visibilityToggle" data-testid="visibility-toggle-hidden"></n8n-icon-stub>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</content>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`TableHeaderControlsButton > should render correctly with no columns 1`] = `
|
||||||
|
"<div class="container" width="260px" max-height="300px" scroll-type="auto">
|
||||||
|
<trigger>
|
||||||
|
<n8n-button-stub icon="sliders-horizontal" block="false" element="button" label="" square="false" active="false" disabled="false" loading="false" outline="false" size="medium" text="false" type="secondary"></n8n-button-stub>
|
||||||
|
</trigger>
|
||||||
|
<content></content>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import N8nTableHeaderControlsButton from './TableHeaderControlsButton.vue';
|
||||||
|
|
||||||
|
export default N8nTableHeaderControlsButton;
|
||||||
@@ -12,6 +12,7 @@ export { default as N8nCheckbox } from './N8nCheckbox';
|
|||||||
export { default as N8nCircleLoader } from './N8nCircleLoader';
|
export { default as N8nCircleLoader } from './N8nCircleLoader';
|
||||||
export { default as N8nColorPicker } from './N8nColorPicker';
|
export { default as N8nColorPicker } from './N8nColorPicker';
|
||||||
export { default as N8nDatatable } from './N8nDatatable';
|
export { default as N8nDatatable } from './N8nDatatable';
|
||||||
|
export { default as N8nExternalLink } from './N8nExternalLink';
|
||||||
export { default as N8nFormBox } from './N8nFormBox';
|
export { default as N8nFormBox } from './N8nFormBox';
|
||||||
export { default as N8nFormInputs } from './N8nFormInputs';
|
export { default as N8nFormInputs } from './N8nFormInputs';
|
||||||
export { default as N8nFormInput } from './N8nFormInput';
|
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 N8nBreadcrumbs } from './N8nBreadcrumbs';
|
||||||
export { default as N8nTableBase } from './TableBase';
|
export { default as N8nTableBase } from './TableBase';
|
||||||
export { default as N8nDataTableServer } from './N8nDataTableServer';
|
export { default as N8nDataTableServer } from './N8nDataTableServer';
|
||||||
|
export { default as N8nTableHeaderControlsButton } from './TableHeaderControlsButton';
|
||||||
export { default as N8nInlineTextEdit } from './N8nInlineTextEdit';
|
export { default as N8nInlineTextEdit } from './N8nInlineTextEdit';
|
||||||
|
export { default as N8nScrollArea } from './N8nScrollArea';
|
||||||
|
|||||||
@@ -71,4 +71,7 @@ export default {
|
|||||||
'iconPicker.tabs.emojis': 'Emojis',
|
'iconPicker.tabs.emojis': 'Emojis',
|
||||||
'selectableList.addDefault': '+ Add a',
|
'selectableList.addDefault': '+ Add a',
|
||||||
'auth.changePassword.passwordsMustMatchError': 'Passwords must match',
|
'auth.changePassword.passwordsMustMatchError': 'Passwords must match',
|
||||||
|
'tableControlsButton.display': 'Display',
|
||||||
|
'tableControlsButton.shown': 'Shown',
|
||||||
|
'tableControlsButton.hidden': 'Hidden',
|
||||||
} as N8nLocale;
|
} as N8nLocale;
|
||||||
|
|||||||
@@ -3160,6 +3160,7 @@
|
|||||||
"evaluation.listRuns.metricsOverTime": "Metrics over time",
|
"evaluation.listRuns.metricsOverTime": "Metrics over time",
|
||||||
"evaluation.listRuns.status": "Status",
|
"evaluation.listRuns.status": "Status",
|
||||||
"evaluation.listRuns.runListHeader": "All runs",
|
"evaluation.listRuns.runListHeader": "All runs",
|
||||||
|
"evaluation.listRuns.allTestCases": "All test cases | All test cases ({count})",
|
||||||
"evaluation.listRuns.testCasesListHeader": "Run #{index}",
|
"evaluation.listRuns.testCasesListHeader": "Run #{index}",
|
||||||
"evaluation.listRuns.runNumber": "Run",
|
"evaluation.listRuns.runNumber": "Run",
|
||||||
"evaluation.listRuns.runDate": "Run date",
|
"evaluation.listRuns.runDate": "Run date",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts" generic="T extends object">
|
<script setup lang="ts" generic="T extends object">
|
||||||
|
import { SHORT_TABLE_CELL_MIN_WIDTH } from '@/views/Evaluations.ee/utils';
|
||||||
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
|
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
|
||||||
import type { TableInstance } from 'element-plus';
|
import type { ColumnCls, TableInstance } from 'element-plus';
|
||||||
import { ElTable, ElTableColumn } from 'element-plus';
|
import { ElTable, ElTableColumn } from 'element-plus';
|
||||||
import isEqual from 'lodash/isEqual';
|
import isEqual from 'lodash/isEqual';
|
||||||
import { nextTick, ref, watch } from 'vue';
|
import { nextTick, ref, useCssModule, watch } from 'vue';
|
||||||
import type { RouteLocationRaw } from 'vue-router';
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A reusable table component for displaying evaluation results data
|
* A reusable table component for displaying evaluation results data
|
||||||
* @template T - The type of data being displayed in the table rows
|
* @template T - The type of data being displayed in the table rows
|
||||||
@@ -28,6 +30,7 @@ export type TestTableColumn<TRow> = {
|
|||||||
sortMethod?: (a: TRow, b: TRow) => number;
|
sortMethod?: (a: TRow, b: TRow) => number;
|
||||||
openInNewTab?: boolean;
|
openInNewTab?: boolean;
|
||||||
formatter?: (row: TRow) => string;
|
formatter?: (row: TRow) => string;
|
||||||
|
minWidth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableRow = T & { id: string };
|
type TableRow = T & { id: string };
|
||||||
@@ -39,14 +42,18 @@ const props = withDefaults(
|
|||||||
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
|
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
selectableFilter?: (row: TableRow) => boolean;
|
selectableFilter?: (row: TableRow) => boolean;
|
||||||
|
expandedRows?: Set<string>;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
defaultSort: () => ({ prop: 'date', order: 'descending' }),
|
defaultSort: () => ({ prop: 'date', order: 'descending' }),
|
||||||
selectable: false,
|
selectable: false,
|
||||||
selectableFilter: () => true,
|
selectableFilter: () => true,
|
||||||
|
expandedRows: () => new Set(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const $style = useCssModule();
|
||||||
|
|
||||||
const tableRef = ref<TableInstance>();
|
const tableRef = ref<TableInstance>();
|
||||||
const selectedRows = ref<TableRow[]>([]);
|
const selectedRows = ref<TableRow[]>([]);
|
||||||
const localData = ref<TableRow[]>([]);
|
const localData = ref<TableRow[]>([]);
|
||||||
@@ -98,8 +105,21 @@ const handleColumnResize = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCellClassName: ColumnCls<TableRow> = ({ row }) => {
|
||||||
|
return `${props.expandedRows?.has(row.id) ? $style.expandedCell : $style.baseCell}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowClassName: ColumnCls<TableRow> = ({ row }) => {
|
||||||
|
const baseClass =
|
||||||
|
'status' in row && row?.status === 'error' ? $style.customDisabledRow : $style.customRow;
|
||||||
|
|
||||||
|
const expandedClass = props.expandedRows?.has(row.id) ? $style.expandedRow : '';
|
||||||
|
return `${baseClass} ${expandedClass}`;
|
||||||
|
};
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
id(props: { row: TableRow }): unknown;
|
id(props: { row: TableRow }): unknown;
|
||||||
|
index(props: { row: TableRow }): unknown;
|
||||||
status(props: { row: TableRow }): unknown;
|
status(props: { row: TableRow }): unknown;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -111,10 +131,8 @@ defineSlots<{
|
|||||||
:default-sort="defaultSort"
|
:default-sort="defaultSort"
|
||||||
:data="localData"
|
:data="localData"
|
||||||
:border="true"
|
:border="true"
|
||||||
:cell-class-name="$style.customCell"
|
:cell-class-name="getCellClassName"
|
||||||
:row-class-name="
|
:row-class-name="getRowClassName"
|
||||||
({ row }) => (row?.status === 'error' ? $style.customDisabledRow : $style.customRow)
|
|
||||||
"
|
|
||||||
scrollbar-always-on
|
scrollbar-always-on
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
@header-dragend="handleColumnResize"
|
@header-dragend="handleColumnResize"
|
||||||
@@ -135,7 +153,7 @@ defineSlots<{
|
|||||||
v-bind="column"
|
v-bind="column"
|
||||||
:resizable="true"
|
:resizable="true"
|
||||||
data-test-id="table-column"
|
data-test-id="table-column"
|
||||||
:min-width="125"
|
:min-width="column.minWidth ?? SHORT_TABLE_CELL_MIN_WIDTH"
|
||||||
>
|
>
|
||||||
<template #header="headerProps">
|
<template #header="headerProps">
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
@@ -161,6 +179,7 @@ defineSlots<{
|
|||||||
</template>
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<slot v-if="column.prop === 'id'" name="id" v-bind="{ row }"></slot>
|
<slot v-if="column.prop === 'id'" name="id" v-bind="{ row }"></slot>
|
||||||
|
<slot v-if="column.prop === 'index'" name="index" v-bind="{ row }"></slot>
|
||||||
<slot v-if="column.prop === 'status'" name="status" v-bind="{ row }"></slot>
|
<slot v-if="column.prop === 'status'" name="status" v-bind="{ row }"></slot>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
@@ -168,26 +187,32 @@ defineSlots<{
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.customCell {
|
.baseCell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
border-bottom: 1px solid var(--border-color-light) !important;
|
border-bottom: 1px solid var(--border-color-light) !important;
|
||||||
|
vertical-align: top !important;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
max-height: 100px;
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.expandedCell {
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
overflow: hidden;
|
background: var(--color-background-light-base);
|
||||||
text-overflow: ellipsis;
|
border-bottom: 1px solid var(--border-color-light) !important;
|
||||||
|
vertical-align: top !important;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.customRow {
|
.customRow {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
--color-table-row-hover-background: var(--color-background-light);
|
--color-table-row-hover-background: var(--color-background-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.customDisabledRow {
|
.customDisabledRow {
|
||||||
@@ -217,10 +242,6 @@ defineSlots<{
|
|||||||
.table {
|
.table {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
:global(.el-scrollbar__wrap) {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.el-table__column-resize-proxy) {
|
:global(.el-table__column-resize-proxy) {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
width: 3px;
|
width: 3px;
|
||||||
@@ -241,5 +262,16 @@ defineSlots<{
|
|||||||
:global(.el-scrollbar__bar) {
|
:global(.el-scrollbar__bar) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
// hide browser scrollbars completely
|
||||||
|
// but still allow mouse gestures to scroll
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
368
packages/frontend/editor-ui/src/plugins/cache.test.ts
Normal file
368
packages/frontend/editor-ui/src/plugins/cache.test.ts
Normal file
@@ -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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as tsvfs from '@typescript/vfs';
|
import * as tsvfs from '@typescript/vfs';
|
||||||
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
|
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
|
||||||
import ts from 'typescript';
|
import ts from 'typescript';
|
||||||
import type { IndexedDbCache } from './cache';
|
import type { IndexedDbCache } from '../../../cache';
|
||||||
|
|
||||||
import globalTypes from './type-declarations/globals.d.ts?raw';
|
import globalTypes from './type-declarations/globals.d.ts?raw';
|
||||||
import n8nTypes from './type-declarations/n8n.d.ts?raw';
|
import n8nTypes from './type-declarations/n8n.d.ts?raw';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as Comlink from 'comlink';
|
import * as Comlink from 'comlink';
|
||||||
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
|
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
|
||||||
import { indexedDbCache } from './cache';
|
import { indexedDbCache } from '../../../cache';
|
||||||
import { bufferChangeSets, fnPrefix } from './utils';
|
import { bufferChangeSets, fnPrefix } from './utils';
|
||||||
|
|
||||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||||
|
|||||||
@@ -9,13 +9,47 @@ import type { BaseTextKey } from '@n8n/i18n';
|
|||||||
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
|
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
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 { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import orderBy from 'lodash/orderBy';
|
import orderBy from 'lodash/orderBy';
|
||||||
import { statusDictionary } from '@/components/Evaluations.ee/shared/statusDictionary';
|
import { statusDictionary } from '@/components/Evaluations.ee/shared/statusDictionary';
|
||||||
import { getErrorBaseKey } from '@/components/Evaluations.ee/shared/errorCodes';
|
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<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Header = TestTableColumn<TestCaseExecutionRecord & { index: number }>;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
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 workflowId = computed(() => router.currentRoute.value.params.name as string);
|
||||||
const workflowName = computed(() => workflowsStore.getWorkflowById(workflowId.value)?.name ?? '');
|
const workflowName = computed(() => workflowsStore.getWorkflowById(workflowId.value)?.name ?? '');
|
||||||
|
|
||||||
|
const cachedUserPreferences = ref<UserPreferences | undefined>();
|
||||||
|
const expandedRows = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
const run = computed(() => evaluationStore.testRunsById[runId.value]);
|
const run = computed(() => evaluationStore.testRunsById[runId.value]);
|
||||||
const runErrorDetails = computed(() => {
|
const runErrorDetails = computed(() => {
|
||||||
return run.value?.errorDetails as Record<string, string | number>;
|
return run.value?.errorDetails as Record<string, string | number>;
|
||||||
@@ -42,6 +79,8 @@ const filteredTestCases = computed(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isAllExpanded = computed(() => expandedRows.value.size === filteredTestCases.value.length);
|
||||||
|
|
||||||
const testRunIndex = computed(() =>
|
const testRunIndex = computed(() =>
|
||||||
Object.values(
|
Object.values(
|
||||||
orderBy(evaluationStore.testRunsById, (record) => new Date(record.runAt), ['asc']).filter(
|
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 formattedTime = computed(() => convertToDisplayDate(new Date(run.value?.runAt).getTime()));
|
||||||
|
|
||||||
const handleRowClick = (row: TestCaseExecutionRecord) => {
|
const openRelatedExecution = (row: TestCaseExecutionRecord) => {
|
||||||
const executionId = row.executionId;
|
const executionId = row.executionId;
|
||||||
if (executionId) {
|
if (executionId) {
|
||||||
const { href } = router.resolve({
|
const { href } = router.resolve({
|
||||||
@@ -68,35 +107,27 @@ const handleRowClick = (row: TestCaseExecutionRecord) => {
|
|||||||
|
|
||||||
const inputColumns = computed(() => getTestCasesColumns(filteredTestCases.value, 'inputs'));
|
const inputColumns = computed(() => getTestCasesColumns(filteredTestCases.value, 'inputs'));
|
||||||
|
|
||||||
const columns = computed(
|
const orderedColumns = computed((): Column[] => {
|
||||||
(): Array<TestTableColumn<TestCaseExecutionRecord & { index: number }>> => {
|
const defaultOrder = getDefaultOrderedColumns(run.value, filteredTestCases.value);
|
||||||
const specialKeys = ['promptTokens', 'completionTokens', 'totalTokens', 'executionTime'];
|
const appliedCachedOrder = applyCachedSortOrder(defaultOrder, cachedUserPreferences.value?.order);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return applyCachedVisibility(appliedCachedOrder, cachedUserPreferences.value?.visibility);
|
||||||
{
|
});
|
||||||
prop: 'index',
|
|
||||||
width: 100,
|
const columns = computed((): Header[] => [
|
||||||
label: locale.baseText('evaluation.runDetail.testCase'),
|
{
|
||||||
sortable: true,
|
prop: 'index',
|
||||||
formatter: (row: TestCaseExecutionRecord & { index: number }) => `#${row.index}`,
|
width: 100,
|
||||||
},
|
label: locale.baseText('evaluation.runDetail.testCase'),
|
||||||
{
|
sortable: true,
|
||||||
prop: 'status',
|
} satisfies Header,
|
||||||
label: locale.baseText('evaluation.listRuns.status'),
|
{
|
||||||
},
|
prop: 'status',
|
||||||
...inputColumns.value,
|
label: locale.baseText('evaluation.listRuns.status'),
|
||||||
...getTestCasesColumns(filteredTestCases.value, 'outputs'),
|
minWidth: 125,
|
||||||
...mapToNumericColumns(metricColumns),
|
} satisfies Header,
|
||||||
...mapToNumericColumns(specialColumns),
|
...getTestTableHeaders(orderedColumns.value, filteredTestCases.value),
|
||||||
];
|
]);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const metrics = computed(() => run.value?.metrics ?? {});
|
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 () => {
|
onMounted(async () => {
|
||||||
await fetchExecutionTestCases();
|
await fetchExecutionTestCases();
|
||||||
|
await loadCachedUserPreferences();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -226,6 +303,35 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
|
||||||
|
<div :class="['mb-s', $style.runsHeader]">
|
||||||
|
<div>
|
||||||
|
<n8n-heading size="large" :bold="true"
|
||||||
|
>{{
|
||||||
|
locale.baseText('evaluation.listRuns.allTestCases', {
|
||||||
|
interpolate: {
|
||||||
|
count: filteredTestCases.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</n8n-heading>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.runsHeaderButtons">
|
||||||
|
<n8n-icon-button
|
||||||
|
:icon="isAllExpanded ? 'chevrons-down-up' : 'chevrons-up-down'"
|
||||||
|
type="secondary"
|
||||||
|
size="medium"
|
||||||
|
@click="toggleAllExpansion"
|
||||||
|
/>
|
||||||
|
<N8nTableHeaderControlsButton
|
||||||
|
size="medium"
|
||||||
|
icon-size="small"
|
||||||
|
:columns="orderedColumns"
|
||||||
|
@update:column-visibility="handleColumnVisibilityUpdate"
|
||||||
|
@update:column-order="handleColumnOrderUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n8n-callout
|
<n8n-callout
|
||||||
v-if="
|
v-if="
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
@@ -251,13 +357,26 @@ onMounted(async () => {
|
|||||||
:data="filteredTestCases"
|
:data="filteredTestCases"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:default-sort="{ prop: 'id', order: 'descending' }"
|
:default-sort="{ prop: 'id', order: 'descending' }"
|
||||||
@row-click="handleRowClick"
|
:expanded-rows="expandedRows"
|
||||||
|
@row-click="toggleRowExpansion"
|
||||||
>
|
>
|
||||||
<template #id="{ row }">
|
<template #id="{ row }">
|
||||||
<div style="display: flex; justify-content: space-between; gap: 10px">
|
<div style="display: flex; justify-content: space-between; gap: 10px">
|
||||||
{{ row.id }}
|
{{ row.id }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #index="{ row }">
|
||||||
|
<div>
|
||||||
|
<N8nExternalLink
|
||||||
|
v-if="row.executionId"
|
||||||
|
class="open-execution-link"
|
||||||
|
@click.stop.prevent="openRelatedExecution(row)"
|
||||||
|
>
|
||||||
|
#{{ row.index }}
|
||||||
|
</N8nExternalLink>
|
||||||
|
<span v-else :class="$style.deletedExecutionRowIndex">#{{ row.index }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
<div style="display: inline-flex; gap: 12px; align-items: center; max-width: 100%">
|
<div style="display: inline-flex; gap: 12px; align-items: center; max-width: 100%">
|
||||||
<N8nIcon
|
<N8nIcon
|
||||||
@@ -289,13 +408,21 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/**
|
||||||
|
When hovering over link in row, ensure hover background is removed from row
|
||||||
|
*/
|
||||||
|
:global(tr:hover:has(.open-execution-link:hover)) {
|
||||||
|
--color-table-row-hover-background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.container {
|
.container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--content-container-width);
|
max-width: var(--content-container-width);
|
||||||
margin: auto;
|
padding: var(--spacing-l) 0;
|
||||||
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -354,6 +481,19 @@ onMounted(async () => {
|
|||||||
margin-bottom: var(--spacing-s);
|
margin-bottom: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runsHeader {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.runsHeaderButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -432,4 +572,9 @@ onMounted(async () => {
|
|||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deletedExecutionRowIndex {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,77 @@
|
|||||||
import type { TestTableColumn } from '@/components/Evaluations.ee/shared/TestTableBase.vue';
|
import type { JsonValue } from 'n8n-workflow';
|
||||||
import type { TestCaseExecutionRecord } from '../../api/evaluation.ee';
|
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<string, boolean>,
|
||||||
|
): 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(
|
export function getTestCasesColumns(
|
||||||
cases: TestCaseExecutionRecord[],
|
cases: TestCaseExecutionRecord[],
|
||||||
columnType: 'inputs' | 'outputs',
|
columnType: 'inputs' | 'outputs',
|
||||||
): Array<TestTableColumn<TestCaseExecutionRecord & { index: number }>> {
|
): string[] {
|
||||||
const inputColumnNames = cases.reduce(
|
const inputColumnNames = cases.reduce(
|
||||||
(set, testCase) => {
|
(set, testCase) => {
|
||||||
Object.keys(testCase[columnType] ?? {}).forEach((key) => set.add(key));
|
Object.keys(testCase[columnType] ?? {}).forEach((key) => set.add(key));
|
||||||
@@ -13,30 +80,72 @@ export function getTestCasesColumns(
|
|||||||
new Set([] as string[]),
|
new Set([] as string[]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.from(inputColumnNames.keys()).map((column) => ({
|
return Array.from(inputColumnNames.keys());
|
||||||
prop: `${columnType}.${column}`,
|
}
|
||||||
|
|
||||||
|
function mapToColumns(
|
||||||
|
columnNames: string[],
|
||||||
|
columnType: 'inputs' | 'outputs' | 'metrics',
|
||||||
|
numeric?: boolean,
|
||||||
|
): Column[] {
|
||||||
|
return columnNames.map((column) => ({
|
||||||
|
key: `${columnType}.${column}`,
|
||||||
label: column,
|
label: column,
|
||||||
sortable: true,
|
visible: true,
|
||||||
filter: true,
|
disabled: false,
|
||||||
showHeaderTooltip: true,
|
columnType,
|
||||||
formatter: (row: TestCaseExecutionRecord) => {
|
numeric,
|
||||||
const value = row[columnType]?.[column];
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${value}`;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapToNumericColumns(columnNames: string[]) {
|
function formatValue(
|
||||||
return columnNames.map((metric) => ({
|
key: string,
|
||||||
prop: `metrics.${metric}`,
|
value?: JsonValue,
|
||||||
label: metric,
|
{ numeric }: { numeric?: boolean } = { numeric: false },
|
||||||
sortable: true,
|
) {
|
||||||
filter: true,
|
let stringValue: string;
|
||||||
showHeaderTooltip: true,
|
|
||||||
formatter: (row: TestCaseExecutionRecord) => row.metrics?.[metric]?.toFixed(2) ?? '-',
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user