diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 240b5c9732..e5d441e6fb 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -49,6 +49,8 @@ "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", + + "@tanstack/vue-table": "^8.21.2", "element-plus": "catalog:frontend", "is-emoji-supported": "^0.0.5", "markdown-it": "^13.0.2", diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts new file mode 100644 index 0000000000..8c5d259c5c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts @@ -0,0 +1,105 @@ +import userEvent from '@testing-library/user-event'; +import { render, within } from '@testing-library/vue'; + +import N8nDataTableServer, { type TableHeader } from './N8nDataTableServer.vue'; + +const itemFactory = () => ({ + id: crypto.randomUUID() as string, + firstName: crypto.randomUUID() as string, + lastName: crypto.randomUUID() as string, +}); +type Item = ReturnType; + +const items: Item[] = [...Array(20).keys()].map(itemFactory); +const headers: Array> = [ + { + title: 'Id', + key: 'id', + }, + { + title: 'First name', + key: 'firstName', + }, + { + title: 'Last name', + value: 'lastName', + }, + { + title: 'Full name', + key: 'fullName', + value(item: Item) { + return `${item.firstName}|${item.lastName}`; + }, + }, + { + title: 'Empty Column', + }, +]; + +describe('N8nDataTableServer', () => { + it('should render a table', () => { + const { container } = render(N8nDataTableServer, { + //@ts-expect-error testing-library errors due to header generics + props: { items, headers, itemsLength: items.length }, + }); + + expect(container.querySelectorAll('thead tr').length).toEqual(1); + expect(container.querySelectorAll('tbody tr').length).toEqual(items.length); + expect(container.querySelectorAll('tbody tr td').length).toEqual(headers.length * items.length); + }); + + it('should render dynamic slots', () => { + const slotName = 'item.id' as `item.${string}`; + const { container } = render(N8nDataTableServer, { + //@ts-expect-error testing-library errors due to header generics + props: { items, headers, itemsLength: items.length }, + slots: { + [slotName]: ({ item }: { item: Item }) => { + return `🍌 ${item.id}`; + }, + }, + }); + + const rows = container.querySelectorAll('tbody tr'); + + rows.forEach((tr, index) => { + expect(tr.querySelector('td')?.textContent).toBe(`🍌 ${items[index].id}`); + }); + }); + + it('should synchronize the state', async () => { + const { container, rerender } = render(N8nDataTableServer, { + //@ts-expect-error testing-library errors due to header generics + props: { items, headers, itemsLength: items.length }, + }); + + expect(container.querySelector('tbody tr td')?.textContent).toBe(items[0].id); + + await rerender({ + items: [{ id: '1', firstName: '1', lastName: '1' }], + headers, + itemsLength: 1, + }); + expect(container.querySelector('tbody tr td')?.textContent).toBe('1'); + }); + + it('should emit options for sorting / pagination', async () => { + const { container, emitted, getByTestId } = render(N8nDataTableServer, { + //@ts-expect-error testing-library errors due to header generics + props: { items, headers, itemsLength: 100 }, + }); + + await userEvent.click(container.querySelector('thead tr th')!); + await userEvent.click(container.querySelector('thead tr th')!); + await userEvent.click(within(getByTestId('pagination')).getByLabelText('page 2')); + + expect(emitted('update:options').length).toBe(3); + expect(emitted('update:options')[0]).toStrictEqual([ + expect.objectContaining({ sortBy: [{ id: 'id', desc: false }] }), + ]); + expect(emitted('update:options')[1]).toStrictEqual([ + expect.objectContaining({ sortBy: [{ id: 'id', desc: true }] }), + ]); + expect(emitted('update:options')[2]).toStrictEqual([expect.objectContaining({ page: 1 })]); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue new file mode 100644 index 0000000000..1e473ca5c9 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue @@ -0,0 +1,668 @@ + + + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/index.ts new file mode 100644 index 0000000000..f623eaeb4b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/index.ts @@ -0,0 +1,3 @@ +import N8nDataTableServer from './N8nDataTableServer.vue'; + +export default N8nDataTableServer; diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index c79864f60f..796c657db0 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -59,3 +59,4 @@ export { N8nKeyboardShortcut } from './N8nKeyboardShortcut'; export { default as N8nIconPicker } from './N8nIconPicker'; export { default as N8nBreadcrumbs } from './N8nBreadcrumbs'; export { default as N8nTableBase } from './TableBase'; +export { default as N8nDataTableServer } from './N8nDataTableServer'; diff --git a/packages/frontend/editor-ui/src/components/MainSidebar.vue b/packages/frontend/editor-ui/src/components/MainSidebar.vue index 7b2fbe7c24..7bf0fe220c 100644 --- a/packages/frontend/editor-ui/src/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/components/MainSidebar.vue @@ -104,6 +104,15 @@ const mainMenuItems = computed(() => [ position: 'bottom', route: { to: { name: VIEWS.VARIABLES } }, }, + { + id: 'insights', + icon: 'chart-bar', + label: 'Insights', + customIconSize: 'medium', + position: 'bottom', + route: { to: { name: VIEWS.INSIGHTS } }, + available: hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }), + }, { id: 'help', icon: 'question', diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 0369186f32..72b899e665 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -550,6 +550,7 @@ export const enum VIEWS { PROJECTS_EXECUTIONS = 'ProjectsExecutions', FOLDERS = 'Folders', PROJECTS_FOLDERS = 'ProjectsFolders', + INSIGHTS = 'Insights', } export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG]; diff --git a/packages/frontend/editor-ui/src/features/insights/chartjs.utils.ts b/packages/frontend/editor-ui/src/features/insights/chartjs.utils.ts new file mode 100644 index 0000000000..cb733576d9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/insights/chartjs.utils.ts @@ -0,0 +1,205 @@ +import { merge } from 'lodash-es'; +import { type ChartOptions, type ScriptableContext } from 'chart.js'; +import { useCssVar } from '@vueuse/core'; +import { smartDecimal } from '@n8n/utils/number/smartDecimal'; + +/** + * + * Chart js configuration + */ + +export const generateLinearGradient = (ctx: CanvasRenderingContext2D, height: number) => { + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, 'rgba(255, 111,92, 1)'); + gradient.addColorStop(0.8, 'rgba(255, 111, 92, 0.25)'); + gradient.addColorStop(1, 'rgba(255, 111, 92, 0)'); + return gradient; +}; + +export const generateLineChartOptions = ( + overrides: ChartOptions<'line'> = {}, +): ChartOptions<'line'> => { + const colorTextDark = useCssVar('--color-text-dark', document.body); + const colorBackgroundLight = useCssVar('--color-background-xlight', document.body); + const colorForeGroundBase = useCssVar('--color-foreground-base', document.body); + + const colorTextLight = useCssVar('--color-text-light', document.body); + + return merge( + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + caretSize: 0, + xAlign: 'center', + yAlign: 'bottom', + padding: 16, + titleFont: { + size: 14, + }, + bodyFont: { + size: 14, + }, + backgroundColor: colorBackgroundLight.value, + titleColor: colorTextDark.value, + bodyColor: colorTextDark.value, + borderWidth: 1, + borderColor: colorForeGroundBase.value, + callbacks: { + label(context: ScriptableContext<'line'>) { + const label = context.dataset.label ?? ''; + return `${label} ${smartDecimal(context.parsed.y)}`; + }, + labelColor(context: ScriptableContext<'line'>) { + return { + borderColor: 'rgba(0, 0, 0, 0)', + backgroundColor: context.dataset.backgroundColor as string, + borderWidth: 0, + borderRadius: 2, + }; + }, + }, + }, + }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false, + }, + scales: { + x: { + grid: { + display: false, + }, + stacked: true, + beginAtZero: true, + border: { + display: false, + }, + ticks: { + color: colorTextLight.value, + }, + }, + y: { + grid: { + color: colorForeGroundBase.value, + }, + stacked: true, + border: { + display: false, + }, + ticks: { + maxTicksLimit: 3, + color: colorTextLight.value, + }, + }, + }, + }, + overrides, + ); +}; + +export const generateBarChartOptions = ( + overrides: ChartOptions<'bar'> = {}, +): ChartOptions<'bar'> => { + const colorTextLight = useCssVar('--color-text-light', document.body); + const colorTextDark = useCssVar('--color-text-dark', document.body); + const colorBackgroundLight = useCssVar('--color-background-xlight', document.body); + const colorForeGroundBase = useCssVar('--color-foreground-base', document.body); + + return merge( + { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + align: 'end', + reverse: true, + position: 'top', + labels: { + boxWidth: 8, + boxHeight: 8, + borderRadius: 2, + useBorderRadius: true, + }, + }, + tooltip: { + caretSize: 0, + xAlign: 'center', + yAlign: 'bottom', + padding: 16, + titleFont: { + size: 14, + }, + bodyFont: { + size: 14, + }, + backgroundColor: colorBackgroundLight.value, + titleColor: colorTextDark.value, + bodyColor: colorTextDark.value, + borderWidth: 1, + borderColor: colorForeGroundBase.value, + callbacks: { + label(context: ScriptableContext<'bar'>) { + const label = context.dataset.label ?? ''; + return `${label} ${context.parsed.y}`; + }, + labelColor(context: ScriptableContext<'bar'>) { + return { + borderColor: 'rgba(0, 0, 0, 0)', + backgroundColor: context.dataset.backgroundColor as string, + borderWidth: 0, + borderRadius: 2, + }; + }, + }, + }, + }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false, + }, + datasets: { + bar: { + maxBarThickness: 32, + borderRadius: 4, + }, + }, + scales: { + x: { + grid: { + display: false, + }, + stacked: true, + beginAtZero: true, + border: { + display: false, + }, + ticks: { + color: colorTextLight.value, + }, + }, + y: { + grid: { + color: colorForeGroundBase.value, + }, + stacked: true, + border: { + display: false, + }, + ticks: { + maxTicksLimit: 3, + color: colorTextLight.value, + }, + }, + }, + }, + overrides, + ); +}; diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue b/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue new file mode 100644 index 0000000000..35c6f6cd2f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue b/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue new file mode 100644 index 0000000000..9f6ec2c8e6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.test.ts b/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.test.ts index 846fe2fd16..36b870739b 100644 --- a/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.test.ts +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.test.ts @@ -1,8 +1,25 @@ +import { reactive } from 'vue'; import InsightsSummary from '@/features/insights/components/InsightsSummary.vue'; import { createComponentRenderer } from '@/__tests__/render'; import type { InsightsSummaryDisplay } from '@/features/insights/insights.types'; -const renderComponent = createComponentRenderer(InsightsSummary); +vi.mock('vue-router', () => ({ + useRouter: () => ({}), + useRoute: () => reactive({}), + RouterLink: { + template: '', + }, +})); + +const renderComponent = createComponentRenderer(InsightsSummary, { + global: { + stubs: { + 'router-link': { + template: '', + }, + }, + }, +}); describe('InsightsSummary', () => { it('should render without error', () => { diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.vue b/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.vue index 779ffb3cf6..3c2c3bd253 100644 --- a/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.vue +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsSummary.vue @@ -1,20 +1,23 @@