mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
perf(editor): Virtualize SchemaView (#11694)
This commit is contained in:
parent
3a5bd12945
commit
9c6def9197
|
@ -172,4 +172,5 @@ export interface FrontendSettings {
|
|||
blockFileAccessToN8nFiles: boolean;
|
||||
};
|
||||
betaFeatures: FrontendBetaFeatures[];
|
||||
virtualSchemaView: boolean;
|
||||
}
|
||||
|
|
|
@ -405,4 +405,11 @@ export const schema = {
|
|||
doc: 'Set this to 1 to enable the new partial execution logic by default.',
|
||||
},
|
||||
},
|
||||
|
||||
virtualSchemaView: {
|
||||
doc: 'Whether to display the virtualized schema view',
|
||||
format: Boolean,
|
||||
default: false,
|
||||
env: 'N8N_VIRTUAL_SCHEMA_VIEW',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -231,6 +231,7 @@ export class FrontendService {
|
|||
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
||||
},
|
||||
betaFeatures: this.frontendConfig.betaFeatures,
|
||||
virtualSchemaView: config.getEnv('virtualSchemaView'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -79,6 +79,7 @@
|
|||
"vue-json-pretty": "2.2.4",
|
||||
"vue-markdown-render": "catalog:frontend",
|
||||
"vue-router": "catalog:frontend",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8",
|
||||
"vue3-touch-events": "^4.1.3",
|
||||
"xss": "catalog:"
|
||||
},
|
||||
|
|
|
@ -126,4 +126,5 @@ export const defaultSettings: FrontendSettings = {
|
|||
enabled: false,
|
||||
},
|
||||
betaFeatures: [],
|
||||
virtualSchemaView: false,
|
||||
};
|
||||
|
|
|
@ -46,6 +46,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useNodeType } from '@/composables/useNodeType';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { PinDataSource, UnpinDataSource } from '@/composables/usePinnedData';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
@ -86,8 +87,11 @@ const LazyRunDataTable = defineAsyncComponent(
|
|||
const LazyRunDataJson = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataJson.vue'),
|
||||
);
|
||||
const LazyRunDataSchema = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataSchema.vue'),
|
||||
|
||||
const LazyRunDataSchema = defineAsyncComponent(async () =>
|
||||
useSettingsStore().settings.virtualSchemaView
|
||||
? await import('@/components/VirtualSchema.vue')
|
||||
: await import('@/components/RunDataSchema.vue'),
|
||||
);
|
||||
const LazyRunDataHtml = defineAsyncComponent(
|
||||
async () => await import('@/components/RunDataHtml.vue'),
|
||||
|
|
439
packages/editor-ui/src/components/VirtualSchema.test.ts
Normal file
439
packages/editor-ui/src/components/VirtualSchema.test.ts
Normal file
|
@ -0,0 +1,439 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import VirtualSchema from '@/components/VirtualSchema.vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { cleanup, waitFor } from '@testing-library/vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import {
|
||||
createTestNode,
|
||||
defaultNodeDescriptions,
|
||||
mockNodeTypeDescription,
|
||||
} from '@/__tests__/mocks';
|
||||
import { IF_NODE_TYPE, SET_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { NodeConnectionType, type IDataObject, type INodeExecutionData } from 'n8n-workflow';
|
||||
import * as nodeHelpers from '@/composables/useNodeHelpers';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
const mockNode1 = createTestNode({
|
||||
name: 'Manual Trigger',
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const mockNode2 = createTestNode({
|
||||
name: 'Set2',
|
||||
type: SET_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const disabledNode = createTestNode({
|
||||
name: 'Disabled Node',
|
||||
type: SET_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const ifNode = createTestNode({
|
||||
name: 'If',
|
||||
type: IF_NODE_TYPE,
|
||||
typeVersion: 1,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const aiTool = createTestNode({
|
||||
name: 'AI Tool',
|
||||
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
typeVersion: 1,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const unknownNodeType = createTestNode({
|
||||
name: 'Unknown Node Type',
|
||||
type: 'unknown',
|
||||
});
|
||||
|
||||
const defaultNodes = [
|
||||
{ name: 'Manual Trigger', indicies: [], depth: 1 },
|
||||
{ name: 'Set2', indicies: [], depth: 2 },
|
||||
];
|
||||
|
||||
async function setupStore() {
|
||||
const workflow = mock<IWorkflowDb>({
|
||||
id: '123',
|
||||
name: 'Test Workflow',
|
||||
connections: {},
|
||||
active: true,
|
||||
nodes: [mockNode1, mockNode2, disabledNode, ifNode, aiTool, unknownNodeType],
|
||||
});
|
||||
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
nodeTypesStore.setNodeTypes([
|
||||
...defaultNodeDescriptions,
|
||||
mockNodeTypeDescription({
|
||||
name: MANUAL_TRIGGER_NODE_TYPE,
|
||||
outputs: [NodeConnectionType.Main],
|
||||
}),
|
||||
mockNodeTypeDescription({
|
||||
name: IF_NODE_TYPE,
|
||||
outputs: [NodeConnectionType.Main, NodeConnectionType.Main],
|
||||
}),
|
||||
]);
|
||||
workflowsStore.workflow = workflow;
|
||||
|
||||
return pinia;
|
||||
}
|
||||
|
||||
function mockNodeOutputData(nodeName: string, data: IDataObject[], outputIndex = 0) {
|
||||
const originalNodeHelpers = nodeHelpers.useNodeHelpers();
|
||||
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
|
||||
return {
|
||||
...originalNodeHelpers,
|
||||
getNodeInputData: vi.fn((node, _, output) => {
|
||||
if (node.name === nodeName && output === outputIndex) {
|
||||
return data.map((json) => ({ json }));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe('RunDataSchema.vue', () => {
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
const DynamicScrollerStub = {
|
||||
props: {
|
||||
items: Array,
|
||||
},
|
||||
template:
|
||||
'<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
|
||||
methods: {
|
||||
scrollToItem: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const DynamicScrollerItemStub = {
|
||||
template: '<slot></slot>',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
cleanup();
|
||||
renderComponent = createComponentRenderer(VirtualSchema, {
|
||||
global: {
|
||||
stubs: {
|
||||
DynamicScroller: DynamicScrollerStub,
|
||||
DynamicScrollerItem: DynamicScrollerItemStub,
|
||||
FontAwesomeIcon: true,
|
||||
},
|
||||
},
|
||||
pinia: await setupStore(),
|
||||
props: {
|
||||
mappingEnabled: true,
|
||||
runIndex: 1,
|
||||
outputIndex: 0,
|
||||
totalRuns: 2,
|
||||
paneType: 'input',
|
||||
connectionType: 'main',
|
||||
search: '',
|
||||
nodes: defaultNodes,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders schema for empty data', async () => {
|
||||
const { getAllByText, getAllByTestId } = renderComponent();
|
||||
|
||||
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(2);
|
||||
|
||||
// Collapse second node
|
||||
await userEvent.click(getAllByTestId('run-data-schema-header')[1]);
|
||||
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders schema for data', async () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: [
|
||||
{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } },
|
||||
{ json: { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] } },
|
||||
],
|
||||
});
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode2,
|
||||
data: [
|
||||
{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } },
|
||||
{ json: { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] } },
|
||||
],
|
||||
});
|
||||
|
||||
const { getAllByTestId } = renderComponent();
|
||||
const headers = getAllByTestId('run-data-schema-header');
|
||||
expect(headers.length).toBe(2);
|
||||
expect(headers[0]).toHaveTextContent('Manual Trigger');
|
||||
expect(headers[0]).toHaveTextContent('2 items');
|
||||
expect(headers[1]).toHaveTextContent('Set2');
|
||||
|
||||
const items = getAllByTestId('run-data-schema-item');
|
||||
|
||||
expect(items[0]).toHaveTextContent('nameJohn');
|
||||
expect(items[1]).toHaveTextContent('age22');
|
||||
expect(items[2]).toHaveTextContent('hobbies');
|
||||
expect(items[3]).toHaveTextContent('hobbies[0]surfing');
|
||||
expect(items[4]).toHaveTextContent('hobbies[1]traveling');
|
||||
});
|
||||
|
||||
it('renders schema in output pane', async () => {
|
||||
const { container } = renderComponent({
|
||||
props: {
|
||||
nodes: [],
|
||||
paneType: 'output',
|
||||
node: mockNode1,
|
||||
data: [
|
||||
{ name: 'John', age: 22, hobbies: ['surfing', 'traveling'] },
|
||||
{ name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders schema with spaces and dots', () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: [
|
||||
{
|
||||
json: {
|
||||
'hello world': [
|
||||
{
|
||||
test: {
|
||||
'more to think about': 1,
|
||||
},
|
||||
'test.how': 'ignore',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderComponent();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders no data to show for data empty objects', () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: [{ json: {} }, { json: {} }],
|
||||
});
|
||||
|
||||
const { getAllByText } = renderComponent();
|
||||
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(2);
|
||||
});
|
||||
|
||||
// this can happen when setting the output to [{}]
|
||||
it('renders empty state to show for empty data', () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: [{} as INodeExecutionData],
|
||||
});
|
||||
|
||||
const { getAllByText } = renderComponent({ props: { paneType: 'output' } });
|
||||
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders disabled nodes correctly', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
nodes: [{ name: disabledNode.name, indicies: [], depth: 1 }],
|
||||
},
|
||||
});
|
||||
expect(getByTestId('run-data-schema-header')).toHaveTextContent(
|
||||
`${disabledNode.name} (Deactivated)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders schema for correct output branch', async () => {
|
||||
mockNodeOutputData(
|
||||
'If',
|
||||
[
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
nodes: [{ name: 'If', indicies: [1], depth: 2 }],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('run-data-schema-header')).toHaveTextContent('If');
|
||||
expect(getByTestId('run-data-schema-header')).toHaveTextContent('2 items');
|
||||
expect(getByTestId('run-data-schema-header')).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders previous nodes schema for AI tools', async () => {
|
||||
mockNodeOutputData(
|
||||
'If',
|
||||
[
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
0,
|
||||
);
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
nodes: [
|
||||
{
|
||||
name: 'If',
|
||||
indicies: [], // indicies are not set for AI tools
|
||||
depth: 2,
|
||||
},
|
||||
],
|
||||
node: aiTool,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('run-data-schema-header')).toHaveTextContent('If');
|
||||
expect(getByTestId('run-data-schema-header')).toHaveTextContent('2 items');
|
||||
expect(getByTestId('run-data-schema-header')).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders its own data for AI tools in debug mode', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
nodes: [], // in debug mode nodes are empty
|
||||
node: aiTool,
|
||||
data: [{ output: 'AI tool output' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('run-data-schema-item')).toHaveTextContent('AI tool output');
|
||||
});
|
||||
|
||||
test.each([[[{ tx: false }, { tx: false }]], [[{ tx: '' }, { tx: '' }]], [[{ tx: [] }]]])(
|
||||
'renders schema instead of showing no data for %o',
|
||||
(data) => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: data.map((item) => ({ json: item })),
|
||||
});
|
||||
|
||||
const { getAllByTestId } = renderComponent();
|
||||
expect(getAllByTestId('run-data-schema-item')[0]).toHaveTextContent('tx');
|
||||
},
|
||||
);
|
||||
|
||||
it('should filter invalid connections', () => {
|
||||
const { pinData } = useWorkflowsStore();
|
||||
pinData({
|
||||
node: mockNode1,
|
||||
data: [{ json: { tx: 1 } }],
|
||||
});
|
||||
pinData({
|
||||
node: mockNode2,
|
||||
data: [{ json: { tx: 2 } }],
|
||||
});
|
||||
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
nodes: [
|
||||
{ name: mockNode1.name, indicies: [], depth: 1 },
|
||||
{ name: 'unknown', indicies: [], depth: 1 },
|
||||
{ name: mockNode2.name, indicies: [], depth: 1 },
|
||||
{ name: unknownNodeType.name, indicies: [], depth: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
|
||||
});
|
||||
|
||||
it('should show connections', () => {
|
||||
const ndvStore = useNDVStore();
|
||||
vi.spyOn(ndvStore, 'ndvNodeInputNumber', 'get').mockReturnValue({
|
||||
[defaultNodes[0].name]: [0],
|
||||
[defaultNodes[1].name]: [0, 1, 2],
|
||||
});
|
||||
|
||||
const { getAllByTestId } = renderComponent();
|
||||
const headers = getAllByTestId('run-data-schema-header');
|
||||
expect(headers.length).toBe(2);
|
||||
expect(headers[0]).toHaveTextContent('Input 0');
|
||||
expect(headers[1]).toHaveTextContent('Inputs 0, 1, 2');
|
||||
});
|
||||
|
||||
it('should handle drop event', async () => {
|
||||
const ndvStore = useNDVStore();
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: [{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } }],
|
||||
});
|
||||
const telemetry = useTelemetry();
|
||||
const trackSpy = vi.spyOn(telemetry, 'track');
|
||||
const reset = vi.spyOn(ndvStore, 'resetMappingTelemetry');
|
||||
const { getAllByTestId } = renderComponent();
|
||||
|
||||
const items = getAllByTestId('run-data-schema-item');
|
||||
expect(items.length).toBe(6);
|
||||
|
||||
expect(items[0].className).toBe('schema-item draggable');
|
||||
expect(items[0]).toHaveTextContent('nameJohn');
|
||||
|
||||
const pill = items[0].querySelector('.pill') as Element;
|
||||
|
||||
fireEvent(pill, new MouseEvent('mousedown', { bubbles: true }));
|
||||
fireEvent(window, new MouseEvent('mousemove', { bubbles: true }));
|
||||
expect(reset).toHaveBeenCalled();
|
||||
|
||||
fireEvent(window, new MouseEvent('mouseup', { bubbles: true }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
'User dragged data for mapping',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should expand all nodes when searching', async () => {
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode1,
|
||||
data: [{ json: { name: 'John' } }],
|
||||
});
|
||||
useWorkflowsStore().pinData({
|
||||
node: mockNode2,
|
||||
data: [{ json: { name: 'John' } }],
|
||||
});
|
||||
|
||||
const { getAllByTestId, queryAllByTestId, rerender } = renderComponent();
|
||||
const headers = getAllByTestId('run-data-schema-header');
|
||||
expect(headers.length).toBe(2);
|
||||
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
|
||||
|
||||
// Collapse all nodes
|
||||
await Promise.all(headers.map(async ($header) => await userEvent.click($header)));
|
||||
|
||||
expect(queryAllByTestId('run-data-schema-item').length).toBe(0);
|
||||
await rerender({ search: 'John' });
|
||||
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
|
||||
});
|
||||
});
|
292
packages/editor-ui/src/components/VirtualSchema.vue
Normal file
292
packages/editor-ui/src/components/VirtualSchema.vue
Normal file
|
@ -0,0 +1,292 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import VirtualSchemaItem from '@/components/VirtualSchemaItem.vue';
|
||||
import VirtualSchemaHeader from '@/components/VirtualSchemaHeader.vue';
|
||||
import { N8nText } from 'n8n-design-system';
|
||||
import Draggable from '@/components/Draggable.vue';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { NodeConnectionType, type IConnectedNode, type IDataObject } from 'n8n-workflow';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import MappingPill from './MappingPill.vue';
|
||||
import { useDataSchema, useFlattenSchema, type SchemaNode } from '@/composables/useDataSchema';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import {
|
||||
DynamicScroller,
|
||||
DynamicScrollerItem,
|
||||
type RecycleScrollerInstance,
|
||||
} from 'vue-virtual-scroller';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
type Props = {
|
||||
nodes?: IConnectedNode[];
|
||||
node?: INodeUi | null;
|
||||
data?: IDataObject[];
|
||||
mappingEnabled?: boolean;
|
||||
runIndex?: number;
|
||||
outputIndex?: number;
|
||||
totalRuns?: number;
|
||||
paneType: 'input' | 'output';
|
||||
connectionType?: NodeConnectionType;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
nodes: () => [],
|
||||
distanceFromActive: 1,
|
||||
node: null,
|
||||
data: () => [],
|
||||
runIndex: 0,
|
||||
outputIndex: 0,
|
||||
totalRuns: 1,
|
||||
connectionType: NodeConnectionType.Main,
|
||||
search: '',
|
||||
mappingEnabled: false,
|
||||
});
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
const ndvStore = useNDVStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { getSchemaForExecutionData, filterSchema } = useDataSchema();
|
||||
const { closedNodes, flattenSchema, flattenMultipleSchemas, toggleLeaf, toggleNode } =
|
||||
useFlattenSchema();
|
||||
const { getNodeInputData } = useNodeHelpers();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'clear:search': [];
|
||||
}>();
|
||||
|
||||
const scroller = ref<RecycleScrollerInstance>();
|
||||
|
||||
const toggleNodeAndScrollTop = (id: string) => {
|
||||
toggleNode(id);
|
||||
scroller.value?.scrollToItem(0);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.search,
|
||||
(newSearch) => {
|
||||
if (!newSearch) return;
|
||||
closedNodes.value.clear();
|
||||
},
|
||||
);
|
||||
|
||||
const getNodeSchema = (fullNode: INodeUi, connectedNode: IConnectedNode) => {
|
||||
const pinData = workflowsStore.pinDataByNodeName(connectedNode.name);
|
||||
const connectedOutputIndexes = connectedNode.indicies.length > 0 ? connectedNode.indicies : [0];
|
||||
const data =
|
||||
pinData ??
|
||||
connectedOutputIndexes
|
||||
.map((outputIndex) =>
|
||||
executionDataToJson(
|
||||
getNodeInputData(
|
||||
fullNode,
|
||||
props.runIndex,
|
||||
outputIndex,
|
||||
props.paneType,
|
||||
props.connectionType,
|
||||
),
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
|
||||
return {
|
||||
schema: getSchemaForExecutionData(data),
|
||||
connectedOutputIndexes,
|
||||
itemsCount: data.length,
|
||||
};
|
||||
};
|
||||
|
||||
const nodeSchema = computed(() =>
|
||||
filterSchema(getSchemaForExecutionData(props.data), props.search),
|
||||
);
|
||||
|
||||
const nodesSchemas = computed<SchemaNode[]>(() => {
|
||||
return props.nodes.reduce<SchemaNode[]>((acc, node) => {
|
||||
const fullNode = workflowsStore.getNodeByName(node.name);
|
||||
if (!fullNode) return acc;
|
||||
|
||||
const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion);
|
||||
if (!nodeType) return acc;
|
||||
|
||||
const { schema, connectedOutputIndexes, itemsCount } = getNodeSchema(fullNode, node);
|
||||
|
||||
const filteredSchema = filterSchema(schema, props.search);
|
||||
|
||||
if (!filteredSchema) return acc;
|
||||
|
||||
acc.push({
|
||||
node: fullNode,
|
||||
connectedOutputIndexes,
|
||||
depth: node.depth,
|
||||
itemsCount,
|
||||
nodeType,
|
||||
schema: filteredSchema,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
const nodeAdditionalInfo = (node: INodeUi) => {
|
||||
const returnData: string[] = [];
|
||||
if (node.disabled) {
|
||||
returnData.push(i18n.baseText('node.disabled'));
|
||||
}
|
||||
|
||||
const connections = ndvStore.ndvNodeInputNumber[node.name];
|
||||
if (connections) {
|
||||
if (connections.length === 1) {
|
||||
returnData.push(`Input ${connections}`);
|
||||
} else {
|
||||
returnData.push(`Inputs ${connections.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData.length ? `(${returnData.join(' | ')})` : '';
|
||||
};
|
||||
|
||||
const flattenedNodes = computed(() =>
|
||||
flattenMultipleSchemas(nodesSchemas.value, nodeAdditionalInfo),
|
||||
);
|
||||
|
||||
const flattenNodeSchema = computed(() =>
|
||||
nodeSchema.value ? flattenSchema({ schema: nodeSchema.value, depth: 0, level: -1 }) : [],
|
||||
);
|
||||
|
||||
/**
|
||||
* In debug mode nodes are empty
|
||||
*/
|
||||
const isDebugging = computed(() => !props.nodes.length);
|
||||
|
||||
const items = computed(() => {
|
||||
if (isDebugging.value || props.paneType === 'output') {
|
||||
return flattenNodeSchema.value;
|
||||
}
|
||||
|
||||
return flattenedNodes.value;
|
||||
});
|
||||
|
||||
const noSearchResults = computed(() => {
|
||||
return Boolean(props.search.trim()) && !Boolean(items.value.length);
|
||||
});
|
||||
|
||||
const onDragStart = () => {
|
||||
ndvStore.resetMappingTelemetry();
|
||||
};
|
||||
|
||||
const onDragEnd = (el: HTMLElement) => {
|
||||
setTimeout(() => {
|
||||
const mappingTelemetry = ndvStore.mappingTelemetry;
|
||||
const telemetryPayload = {
|
||||
src_node_type: el.dataset.nodeType,
|
||||
src_field_name: el.dataset.name ?? '',
|
||||
src_nodes_back: el.dataset.depth,
|
||||
src_run_index: props.runIndex,
|
||||
src_runs_total: props.totalRuns,
|
||||
src_field_nest_level: el.dataset.level ?? 0,
|
||||
src_view: 'schema',
|
||||
src_element: el,
|
||||
success: false,
|
||||
...mappingTelemetry,
|
||||
};
|
||||
|
||||
void useExternalHooks().run('runDataJson.onDragEnd', telemetryPayload);
|
||||
|
||||
telemetry.track('User dragged data for mapping', telemetryPayload, { withPostHog: true });
|
||||
}, 250); // ensure dest data gets set if drop
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="run-data-schema full-height">
|
||||
<div v-if="noSearchResults" class="no-results">
|
||||
<N8nText tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</N8nText>
|
||||
<N8nText>
|
||||
<i18n-t keypath="ndv.search.noMatch.description" tag="span">
|
||||
<template #link>
|
||||
<a href="#" @click="emit('clear:search')">
|
||||
{{ i18n.baseText('ndv.search.noMatch.description.link') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
|
||||
<N8nText v-if="paneType === 'output'">
|
||||
{{ i18n.baseText('ndv.search.noMatchSchema.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<Draggable
|
||||
v-if="items.length"
|
||||
class="full-height"
|
||||
type="mapping"
|
||||
target-data-key="mappable"
|
||||
:disabled="!mappingEnabled"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<template #preview="{ canDrop, el }">
|
||||
<MappingPill v-if="el" :html="el.outerHTML" :can-drop="canDrop" />
|
||||
</template>
|
||||
<DynamicScroller
|
||||
ref="scroller"
|
||||
:items="items"
|
||||
:min-item-size="38"
|
||||
class="full-height scroller"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<VirtualSchemaHeader
|
||||
v-if="item.type === 'header'"
|
||||
v-bind="item"
|
||||
:collapsed="closedNodes.has(item.id)"
|
||||
@click:toggle="toggleLeaf(item.id)"
|
||||
@click="toggleNodeAndScrollTop(item.id)"
|
||||
/>
|
||||
<DynamicScrollerItem
|
||||
v-else
|
||||
:item="item"
|
||||
:active="active"
|
||||
:size-dependencies="[item.value]"
|
||||
:data-index="index"
|
||||
>
|
||||
<VirtualSchemaItem
|
||||
v-bind="item"
|
||||
:search="search"
|
||||
:draggable="mappingEnabled"
|
||||
:collapsed="closedNodes.has(item.id)"
|
||||
:highlight="ndvStore.highlightDraggables"
|
||||
@click="toggleLeaf(item.id)"
|
||||
>
|
||||
</VirtualSchemaItem>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</Draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
.run-data-schema {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
padding: 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
|
||||
}
|
||||
</style>
|
104
packages/editor-ui/src/components/VirtualSchemaHeader.vue
Normal file
104
packages/editor-ui/src/components/VirtualSchemaHeader.vue
Normal file
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
info?: string;
|
||||
collapsable: boolean;
|
||||
collapsed: boolean;
|
||||
nodeType: INodeTypeDescription;
|
||||
itemCount: number | null;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const isTrigger = computed(() => props.nodeType.group.includes('trigger'));
|
||||
const emit = defineEmits<{
|
||||
'click:toggle': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-header" data-test-id="run-data-schema-header">
|
||||
<div class="toggle" @click.capture.stop="emit('click:toggle')">
|
||||
<FontAwesomeIcon icon="angle-down" :class="{ 'collapse-icon': true, collapsed }" />
|
||||
</div>
|
||||
|
||||
<NodeIcon
|
||||
class="icon"
|
||||
:class="{ ['icon-trigger']: isTrigger }"
|
||||
:node-type="nodeType"
|
||||
:size="12"
|
||||
/>
|
||||
<div class="title">
|
||||
{{ title }}
|
||||
<span v-if="info" class="info">{{ info }}</span>
|
||||
</div>
|
||||
<FontAwesomeIcon v-if="isTrigger" class="trigger-icon" icon="bolt" size="xs" />
|
||||
<div v-if="itemCount" class="item-count" data-test-id="run-data-schema-node-item-count">
|
||||
{{ i18n.baseText('ndv.output.items', { interpolate: { count: itemCount } }) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.schema-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--spacing-2xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.collapse-icon {
|
||||
transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
.collapsed {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xs);
|
||||
border: 1px solid var(--color-foreground-light);
|
||||
border-radius: var(--border-radius-base);
|
||||
background-color: var(--color-background-xlight);
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.icon-trigger {
|
||||
border-radius: 16px 4px 4px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-left: var(--spacing-2xs);
|
||||
color: var(--color-text-light);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.trigger-icon {
|
||||
margin-left: var(--spacing-2xs);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.item-count {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
142
packages/editor-ui/src/components/VirtualSchemaItem.vue
Normal file
142
packages/editor-ui/src/components/VirtualSchemaItem.vue
Normal file
|
@ -0,0 +1,142 @@
|
|||
<script lang="ts" setup>
|
||||
import TextWithHighlights from './TextWithHighlights.vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
path?: string;
|
||||
level?: number;
|
||||
depth?: number;
|
||||
expression?: string;
|
||||
value?: string;
|
||||
id: string;
|
||||
icon: string;
|
||||
collapsable?: boolean;
|
||||
nodeType?: string;
|
||||
highlight?: boolean;
|
||||
draggable?: boolean;
|
||||
collapsed?: boolean;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-item" :class="{ draggable }" data-test-id="run-data-schema-item">
|
||||
<div class="toggle-container">
|
||||
<div v-if="collapsable" class="toggle" @click="emit('click')">
|
||||
<FontAwesomeIcon icon="angle-down" :class="{ 'collapse-icon': true, collapsed }" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="title"
|
||||
:data-name="title"
|
||||
:data-path="path"
|
||||
:data-depth="depth"
|
||||
:data-nest-level="level"
|
||||
:data-value="expression"
|
||||
:data-node-type="nodeType"
|
||||
data-target="mappable"
|
||||
class="pill"
|
||||
:class="{ 'pill--highlight': highlight }"
|
||||
data-test-id="run-data-schema-node-name"
|
||||
>
|
||||
<FontAwesomeIcon class="type-icon" :icon size="sm" />
|
||||
<TextWithHighlights class="title" :content="title" :search="props.search" />
|
||||
</div>
|
||||
<TextWithHighlights
|
||||
data-test-id="run-data-schema-item-value"
|
||||
class="text"
|
||||
:content="value"
|
||||
:search="props.search"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.schema-item {
|
||||
display: flex;
|
||||
margin-left: calc(var(--spacing-l) * v-bind(level));
|
||||
align-items: baseline;
|
||||
padding-bottom: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.toggle-container {
|
||||
min-width: var(--spacing-l);
|
||||
min-height: 17px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
padding: 0 var(--spacing-3xs);
|
||||
border: 1px solid var(--color-foreground-light);
|
||||
border-radius: var(--border-radius-base);
|
||||
background-color: var(--color-background-xlight);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
max-width: 50%;
|
||||
align-items: center;
|
||||
> *:not(:first-child) {
|
||||
margin-left: var(--spacing-3xs);
|
||||
padding-left: var(--spacing-3xs);
|
||||
border-left: 1px solid var(--color-foreground-light);
|
||||
}
|
||||
}
|
||||
|
||||
.draggable .pill.pill--highlight {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary-tint-1);
|
||||
background-color: var(--color-primary-tint-3);
|
||||
}
|
||||
|
||||
.draggable .pill.pill--highlight .type-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.draggable .pill {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.draggable .pill:hover {
|
||||
background-color: var(--color-background-light);
|
||||
border-color: var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--font-size-2xs);
|
||||
margin-left: var(--spacing-2xs);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform 0.2s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
.collapsed {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load diff
|
@ -1,5 +1,5 @@
|
|||
import jp from 'jsonpath';
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { useDataSchema, useFlattenSchema } from '@/composables/useDataSchema';
|
||||
import type { IExecutionResponse, INodeUi, Schema } from '@/Interface';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
@ -649,3 +649,39 @@ describe('useDataSchema', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useFlattenSchema', () => {
|
||||
it('flattens a schema', () => {
|
||||
const schema: Schema = {
|
||||
path: '',
|
||||
type: 'object',
|
||||
value: [
|
||||
{
|
||||
key: 'obj',
|
||||
path: '.obj',
|
||||
type: 'object',
|
||||
value: [
|
||||
{
|
||||
key: 'foo',
|
||||
path: '.obj.foo',
|
||||
type: 'object',
|
||||
value: [
|
||||
{
|
||||
key: 'nested',
|
||||
path: '.obj.foo.nested',
|
||||
type: 'string',
|
||||
value: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
useFlattenSchema().flattenSchema({
|
||||
schema,
|
||||
}).length,
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import { ref } from 'vue';
|
||||
import type { Optional, Primitives, Schema, INodeUi } from '@/Interface';
|
||||
import {
|
||||
type ITaskDataConnections,
|
||||
type IDataObject,
|
||||
type INodeExecutionData,
|
||||
type INodeTypeDescription,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import { merge } from 'lodash-es';
|
||||
import { generatePath } from '@/utils/mappingUtils';
|
||||
import { generatePath, getMappedExpression } from '@/utils/mappingUtils';
|
||||
import { isObj } from '@/utils/typeGuards';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { isPresent } from '@/utils/typesUtils';
|
||||
import { isPresent, shorten } from '@/utils/typesUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export function useDataSchema() {
|
||||
function getSchema(
|
||||
|
@ -166,3 +169,208 @@ export function useDataSchema() {
|
|||
filterSchema,
|
||||
};
|
||||
}
|
||||
|
||||
export type SchemaNode = {
|
||||
node: INodeUi;
|
||||
nodeType: INodeTypeDescription;
|
||||
depth: number;
|
||||
connectedOutputIndexes: number[];
|
||||
itemsCount: number;
|
||||
schema: Schema;
|
||||
};
|
||||
|
||||
export type RenderItem = {
|
||||
title?: string;
|
||||
path?: string;
|
||||
level?: number;
|
||||
depth?: number;
|
||||
expression?: string;
|
||||
value?: string;
|
||||
id: string;
|
||||
icon: string;
|
||||
collapsable?: boolean;
|
||||
nodeType?: INodeUi['type'];
|
||||
type: 'item';
|
||||
};
|
||||
|
||||
export type RenderHeader = {
|
||||
id: string;
|
||||
title: string;
|
||||
info?: string;
|
||||
collapsable: boolean;
|
||||
nodeType: INodeTypeDescription;
|
||||
itemCount: number | null;
|
||||
type: 'header';
|
||||
};
|
||||
|
||||
type Renders = RenderHeader | RenderItem;
|
||||
|
||||
const icons = {
|
||||
object: 'cube',
|
||||
array: 'list',
|
||||
['string']: 'font',
|
||||
null: 'font',
|
||||
['number']: 'hashtag',
|
||||
['boolean']: 'check-square',
|
||||
function: 'code',
|
||||
bigint: 'calculator',
|
||||
symbol: 'sun',
|
||||
['undefined']: 'ban',
|
||||
} as const;
|
||||
|
||||
const getIconBySchemaType = (type: Schema['type']): string => icons[type];
|
||||
|
||||
const emptyItem = (): RenderItem => ({
|
||||
id: `empty-${window.crypto.randomUUID()}`,
|
||||
icon: '',
|
||||
value: useI18n().baseText('dataMapping.schemaView.emptyData'),
|
||||
type: 'item',
|
||||
});
|
||||
|
||||
const isDataEmpty = (schema: Schema) => {
|
||||
// Utilize the generated schema instead of looping over the entire data again
|
||||
// The schema for empty data is { type: 'object', value: [] }
|
||||
const isObjectOrArray = schema.type === 'object';
|
||||
const isEmpty = Array.isArray(schema.value) && schema.value.length === 0;
|
||||
|
||||
return isObjectOrArray && isEmpty;
|
||||
};
|
||||
|
||||
const prefixTitle = (title: string, prefix?: string) => (prefix ? `${prefix}[${title}]` : title);
|
||||
|
||||
export const useFlattenSchema = () => {
|
||||
const closedNodes = ref<Set<string>>(new Set());
|
||||
const headerIds = ref<Set<string>>(new Set());
|
||||
const toggleLeaf = (id: string) => {
|
||||
if (closedNodes.value.has(id)) {
|
||||
closedNodes.value.delete(id);
|
||||
} else {
|
||||
closedNodes.value.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNode = (id: string) => {
|
||||
if (closedNodes.value.has(id)) {
|
||||
closedNodes.value = new Set(headerIds.value);
|
||||
closedNodes.value.delete(id);
|
||||
} else {
|
||||
closedNodes.value.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
const flattenSchema = ({
|
||||
schema,
|
||||
node = { name: '', type: '' },
|
||||
depth = 0,
|
||||
prefix = '',
|
||||
level = 0,
|
||||
}: {
|
||||
schema: Schema;
|
||||
node?: { name: string; type: string };
|
||||
depth?: number;
|
||||
prefix?: string;
|
||||
level?: number;
|
||||
}): RenderItem[] => {
|
||||
// only show empty item for the first level
|
||||
if (isDataEmpty(schema) && depth <= 0) {
|
||||
return [emptyItem()];
|
||||
}
|
||||
|
||||
const expression = getMappedExpression({
|
||||
nodeName: node.name,
|
||||
distanceFromActive: depth,
|
||||
path: schema.path,
|
||||
});
|
||||
|
||||
if (Array.isArray(schema.value)) {
|
||||
const items: RenderItem[] = [];
|
||||
|
||||
if (schema.key) {
|
||||
items.push({
|
||||
title: prefixTitle(schema.key, prefix),
|
||||
path: schema.path,
|
||||
expression,
|
||||
depth,
|
||||
level,
|
||||
icon: getIconBySchemaType(schema.type),
|
||||
id: expression,
|
||||
collapsable: true,
|
||||
nodeType: node.type,
|
||||
type: 'item',
|
||||
});
|
||||
}
|
||||
|
||||
if (closedNodes.value.has(expression)) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.concat(
|
||||
schema.value
|
||||
.map((item) => {
|
||||
const itemPrefix = schema.type === 'array' ? schema.key : '';
|
||||
return flattenSchema({
|
||||
schema: item,
|
||||
node,
|
||||
depth,
|
||||
prefix: itemPrefix,
|
||||
level: level + 1,
|
||||
});
|
||||
})
|
||||
.flat(),
|
||||
);
|
||||
} else if (schema.key) {
|
||||
return [
|
||||
{
|
||||
title: prefixTitle(schema.key, prefix),
|
||||
path: schema.path,
|
||||
expression,
|
||||
level,
|
||||
depth,
|
||||
value: shorten(schema.value, 600, 0),
|
||||
id: expression,
|
||||
icon: getIconBySchemaType(schema.type),
|
||||
collapsable: false,
|
||||
nodeType: node.type,
|
||||
type: 'item',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const flattenMultipleSchemas = (
|
||||
nodes: SchemaNode[],
|
||||
additionalInfo: (node: INodeUi) => string,
|
||||
) => {
|
||||
headerIds.value.clear();
|
||||
|
||||
return nodes.reduce<Renders[]>((acc, item) => {
|
||||
acc.push({
|
||||
title: item.node.name,
|
||||
id: item.node.name,
|
||||
collapsable: true,
|
||||
nodeType: item.nodeType,
|
||||
itemCount: item.itemsCount,
|
||||
info: additionalInfo(item.node),
|
||||
type: 'header',
|
||||
});
|
||||
|
||||
headerIds.value.add(item.node.name);
|
||||
|
||||
if (closedNodes.value.has(item.node.name)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (isDataEmpty(item.schema)) {
|
||||
acc.push(emptyItem());
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(...flattenSchema(item));
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
return { closedNodes, toggleLeaf, toggleNode, flattenSchema, flattenMultipleSchemas };
|
||||
};
|
||||
|
|
112
packages/editor-ui/src/vue-virtual-scroller.d.ts
vendored
Normal file
112
packages/editor-ui/src/vue-virtual-scroller.d.ts
vendored
Normal file
|
@ -0,0 +1,112 @@
|
|||
declare module 'vue-virtual-scroller' {
|
||||
import {
|
||||
type ObjectEmitsOptions,
|
||||
type PublicProps,
|
||||
type SetupContext,
|
||||
type SlotsType,
|
||||
type VNode,
|
||||
} from 'vue';
|
||||
|
||||
interface RecycleScrollerProps<T> {
|
||||
items: readonly T[];
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
itemSize?: number | null;
|
||||
gridItems?: number;
|
||||
itemSecondarySize?: number;
|
||||
minItemSize?: number;
|
||||
sizeField?: string;
|
||||
typeField?: string;
|
||||
keyField?: keyof T;
|
||||
pageMode?: boolean;
|
||||
prerender?: number;
|
||||
buffer?: number;
|
||||
emitUpdate?: boolean;
|
||||
updateInterval?: number;
|
||||
listClass?: string;
|
||||
itemClass?: string;
|
||||
listTag?: string;
|
||||
itemTag?: string;
|
||||
}
|
||||
|
||||
interface DynamicScrollerProps<T> extends RecycleScrollerProps<T> {
|
||||
minItemSize: number;
|
||||
}
|
||||
|
||||
interface RecycleScrollerEmitOptions extends ObjectEmitsOptions {
|
||||
resize: () => void;
|
||||
visible: () => void;
|
||||
hidden: () => void;
|
||||
update: (
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
visibleStartIndex: number,
|
||||
visibleEndIndex: number,
|
||||
) => void;
|
||||
'scroll-start': () => void;
|
||||
'scroll-end': () => void;
|
||||
}
|
||||
|
||||
interface RecycleScrollerSlotProps<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface RecycleScrollerSlots<T> {
|
||||
default(slotProps: RecycleScrollerSlotProps<T>): unknown;
|
||||
before(): unknown;
|
||||
empty(): unknown;
|
||||
after(): unknown;
|
||||
}
|
||||
|
||||
export interface RecycleScrollerInstance {
|
||||
getScroll(): { start: number; end: number };
|
||||
scrollToItem(index: number): void;
|
||||
scrollToPosition(position: number): void;
|
||||
}
|
||||
|
||||
export const RecycleScroller: <T>(
|
||||
props: RecycleScrollerProps<T> & PublicProps,
|
||||
ctx?: SetupContext<RecycleScrollerEmitOptions, SlotsType<RecycleScrollerSlots<T>>>,
|
||||
expose?: (exposed: RecycleScrollerInstance) => void,
|
||||
) => VNode & {
|
||||
__ctx?: {
|
||||
props: RecycleScrollerProps<T> & PublicProps;
|
||||
expose(exposed: RecycleScrollerInstance): void;
|
||||
slots: RecycleScrollerSlots<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export const DynamicScroller: <T>(
|
||||
props: DynamicScrollerProps<T> & PublicProps,
|
||||
ctx?: SetupContext<RecycleScrollerEmitOptions, SlotsType<RecycleScrollerSlots<T>>>,
|
||||
expose?: (exposed: RecycleScrollerInstance) => void,
|
||||
) => VNode & {
|
||||
__ctx?: {
|
||||
props: DynamicScrollerProps<T> & PublicProps;
|
||||
expose(exposed: RecycleScrollerInstance): void;
|
||||
slots: RecycleScrollerSlots<T>;
|
||||
};
|
||||
};
|
||||
|
||||
interface DynamicScrollerItemProps<T> {
|
||||
item: T;
|
||||
active: boolean;
|
||||
sizeDependencies?: unknown[];
|
||||
watchData?: boolean;
|
||||
tag?: string;
|
||||
emitResize?: boolean;
|
||||
onResize?: () => void;
|
||||
}
|
||||
|
||||
interface DynamicScrollerItemEmitOptions extends ObjectEmitsOptions {
|
||||
resize: () => void;
|
||||
}
|
||||
|
||||
export const DynamicScrollerItem: <T>(
|
||||
props: DynamicScrollerItemProps<T> & PublicProps,
|
||||
ctx?: SetupContext<DynamicScrollerItemEmitOptions>,
|
||||
) => VNode;
|
||||
|
||||
export function IdState(options?: { idProp?: (value: any) => unknown }): unknown;
|
||||
}
|
140
pnpm-lock.yaml
140
pnpm-lock.yaml
|
@ -1114,7 +1114,7 @@ importers:
|
|||
dependencies:
|
||||
'@langchain/core':
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.15(openai@4.69.0(zod@3.23.8))
|
||||
version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))
|
||||
'@n8n/client-oauth2':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/client-oauth2
|
||||
|
@ -1513,6 +1513,9 @@ importers:
|
|||
vue-router:
|
||||
specifier: catalog:frontend
|
||||
version: 4.4.5(vue@3.5.11(typescript@5.7.2))
|
||||
vue-virtual-scroller:
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8(vue@3.5.11(typescript@5.7.2))
|
||||
vue3-touch-events:
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3
|
||||
|
@ -1942,7 +1945,7 @@ importers:
|
|||
devDependencies:
|
||||
'@langchain/core':
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.15(openai@4.69.0)
|
||||
version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))
|
||||
'@types/deep-equal':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
|
@ -9244,6 +9247,9 @@ packages:
|
|||
resolution: {integrity: sha512-MGZAq0Q3OuRYgZKvlB69z4gLN4G3PvgC4A2zhkCXCXrLD5wm2cCnwNB59xOBVA+srZ0zEes6u+VylcPIkB4SqA==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
mitt@2.1.0:
|
||||
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
|
||||
|
||||
mjml-accordion@4.15.3:
|
||||
resolution: {integrity: sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==}
|
||||
|
||||
|
@ -11004,10 +11010,6 @@ packages:
|
|||
resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
source-map-js@1.0.2:
|
||||
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
@ -12134,6 +12136,16 @@ packages:
|
|||
peerDependencies:
|
||||
vue: ^3.3.4
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-resize@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-router@4.4.5:
|
||||
resolution: {integrity: sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==}
|
||||
peerDependencies:
|
||||
|
@ -12145,6 +12157,11 @@ packages:
|
|||
peerDependencies:
|
||||
typescript: ^5.7.2
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue3-touch-events@4.1.3:
|
||||
resolution: {integrity: sha512-uXTclRzn7de1mgiDIZ8N4J/wnWl1vBPLTWr60fqoLXu7ifhDKpl83Q2m9qA20KfEiAy+L4X/xXGc5ptGmdPh4A==}
|
||||
|
||||
|
@ -14667,38 +14684,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- openai
|
||||
|
||||
'@langchain/core@0.3.15(openai@4.69.0(zod@3.23.8))':
|
||||
dependencies:
|
||||
ansi-styles: 5.2.0
|
||||
camelcase: 6.3.0
|
||||
decamelize: 1.2.0
|
||||
js-tiktoken: 1.0.12
|
||||
langsmith: 0.2.3(openai@4.69.0(zod@3.23.8))
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
uuid: 10.0.0
|
||||
zod: 3.23.8
|
||||
zod-to-json-schema: 3.23.3(zod@3.23.8)
|
||||
transitivePeerDependencies:
|
||||
- openai
|
||||
|
||||
'@langchain/core@0.3.15(openai@4.69.0)':
|
||||
dependencies:
|
||||
ansi-styles: 5.2.0
|
||||
camelcase: 6.3.0
|
||||
decamelize: 1.2.0
|
||||
js-tiktoken: 1.0.12
|
||||
langsmith: 0.2.3(openai@4.69.0)
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
uuid: 10.0.0
|
||||
zod: 3.23.8
|
||||
zod-to-json-schema: 3.23.3(zod@3.23.8)
|
||||
transitivePeerDependencies:
|
||||
- openai
|
||||
|
||||
'@langchain/google-common@0.1.1(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@langchain/core': 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))
|
||||
|
@ -19264,7 +19249,7 @@ snapshots:
|
|||
|
||||
eslint-import-resolver-node@0.3.9:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
is-core-module: 2.13.1
|
||||
resolve: 1.22.8
|
||||
transitivePeerDependencies:
|
||||
|
@ -19289,7 +19274,7 @@ snapshots:
|
|||
|
||||
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2)
|
||||
eslint: 8.57.0
|
||||
|
@ -19309,7 +19294,7 @@ snapshots:
|
|||
array.prototype.findlastindex: 1.2.3
|
||||
array.prototype.flat: 1.3.2
|
||||
array.prototype.flatmap: 1.3.2
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
|
@ -20102,7 +20087,7 @@ snapshots:
|
|||
array-parallel: 0.1.3
|
||||
array-series: 0.1.5
|
||||
cross-spawn: 4.0.2
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -20430,7 +20415,7 @@ snapshots:
|
|||
isstream: 0.1.2
|
||||
jsonwebtoken: 9.0.2
|
||||
mime-types: 2.1.35
|
||||
retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7))
|
||||
retry-axios: 2.6.0(axios@1.7.4)
|
||||
tough-cookie: 4.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
@ -21272,7 +21257,7 @@ snapshots:
|
|||
form-data: 4.0.0
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.0
|
||||
https-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.5
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.7
|
||||
parse5: 7.1.2
|
||||
|
@ -21467,28 +21452,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
openai: 4.69.0(encoding@0.1.13)(zod@3.23.8)
|
||||
|
||||
langsmith@0.2.3(openai@4.69.0(zod@3.23.8)):
|
||||
dependencies:
|
||||
'@types/uuid': 10.0.0
|
||||
commander: 10.0.1
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
semver: 7.6.0
|
||||
uuid: 10.0.0
|
||||
optionalDependencies:
|
||||
openai: 4.69.0(zod@3.23.8)
|
||||
|
||||
langsmith@0.2.3(openai@4.69.0):
|
||||
dependencies:
|
||||
'@types/uuid': 10.0.0
|
||||
commander: 10.0.1
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
semver: 7.6.0
|
||||
uuid: 10.0.0
|
||||
optionalDependencies:
|
||||
openai: 4.69.0(zod@3.23.8)
|
||||
|
||||
lazy-ass@1.6.0: {}
|
||||
|
||||
ldapts@4.2.6:
|
||||
|
@ -22002,6 +21965,8 @@ snapshots:
|
|||
lodash: 4.17.21
|
||||
pretender: 3.4.7
|
||||
|
||||
mitt@2.1.0: {}
|
||||
|
||||
mjml-accordion@4.15.3(encoding@0.1.13):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.7
|
||||
|
@ -22823,22 +22788,6 @@ snapshots:
|
|||
- encoding
|
||||
- supports-color
|
||||
|
||||
openai@4.69.0(zod@3.23.8):
|
||||
dependencies:
|
||||
'@types/node': 18.16.16
|
||||
'@types/node-fetch': 2.6.4
|
||||
abort-controller: 3.0.0
|
||||
agentkeepalive: 4.2.1
|
||||
form-data-encoder: 1.7.2
|
||||
formdata-node: 4.4.1
|
||||
node-fetch: 2.7.0(encoding@0.1.13)
|
||||
optionalDependencies:
|
||||
zod: 3.23.8
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
openapi-sampler@1.5.1:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
@ -23019,7 +22968,7 @@ snapshots:
|
|||
|
||||
pdf-parse@1.1.1:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
node-ensure: 0.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
@ -23823,9 +23772,9 @@ snapshots:
|
|||
|
||||
ret@0.1.15: {}
|
||||
|
||||
retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)):
|
||||
retry-axios@2.6.0(axios@1.7.4):
|
||||
dependencies:
|
||||
axios: 1.7.4(debug@4.3.7)
|
||||
axios: 1.7.4
|
||||
|
||||
retry-request@7.0.2(encoding@0.1.13):
|
||||
dependencies:
|
||||
|
@ -23850,7 +23799,7 @@ snapshots:
|
|||
|
||||
rhea@1.0.24:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -23988,7 +23937,7 @@ snapshots:
|
|||
dependencies:
|
||||
chokidar: 4.0.1
|
||||
immutable: 4.2.2
|
||||
source-map-js: 1.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
sax@1.2.4: {}
|
||||
|
||||
|
@ -24270,8 +24219,6 @@ snapshots:
|
|||
smart-buffer: 4.2.0
|
||||
optional: true
|
||||
|
||||
source-map-js@1.0.2: {}
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
@ -25494,6 +25441,14 @@ snapshots:
|
|||
markdown-it: 13.0.2
|
||||
vue: 3.5.11(typescript@5.7.2)
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)):
|
||||
dependencies:
|
||||
vue: 3.5.11(typescript@5.7.2)
|
||||
|
||||
vue-resize@2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)):
|
||||
dependencies:
|
||||
vue: 3.5.11(typescript@5.7.2)
|
||||
|
||||
vue-router@4.4.5(vue@3.5.11(typescript@5.7.2)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
|
@ -25506,6 +25461,13 @@ snapshots:
|
|||
semver: 7.6.0
|
||||
typescript: 5.7.2
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8(vue@3.5.11(typescript@5.7.2)):
|
||||
dependencies:
|
||||
mitt: 2.1.0
|
||||
vue: 3.5.11(typescript@5.7.2)
|
||||
vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2))
|
||||
vue-resize: 2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2))
|
||||
|
||||
vue3-touch-events@4.1.3: {}
|
||||
|
||||
vue@3.5.11(typescript@5.7.2):
|
||||
|
|
Loading…
Reference in a new issue