perf(editor): SchemaView performance improvement by ≈90% 🚀 (#12180)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Raúl Gómez Morales 2024-12-12 14:03:59 +01:00 committed by GitHub
parent 07a6ae11b3
commit 6a5830959f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 13 additions and 3764 deletions

View file

@ -185,7 +185,6 @@ describe('Data mapping', () => {
workflowPage.actions.openNode('Set1');
ndv.actions.executePrevious();
ndv.actions.expandSchemaViewNode(SCHEDULE_TRIGGER_NODE_NAME);
const dataPill = ndv.getters
.inputDataContainer()

View file

@ -77,7 +77,7 @@ describe('NDV', () => {
workflowPage.actions.openNode('Switch');
cy.get('.cm-line').realMouseMove(100, 100);
cy.get('.fa-angle-down').click();
cy.get('.fa-angle-down').first().click();
ndv.getters.backToCanvas().click();
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Merge');
@ -204,7 +204,7 @@ describe('NDV', () => {
.contains(key)
.should('be.visible');
});
getObjectValueItem().find('label').click({ force: true });
getObjectValueItem().find('.toggle').click({ force: true });
expandedObjectProps.forEach((key) => {
ndv.getters
.outputPanel()
@ -245,8 +245,8 @@ describe('NDV', () => {
ndv.getters.outputPanel().find('[class*=_pagination]').should('not.exist');
ndv.getters
.outputPanel()
.find('[data-test-id=run-data-schema-item] [data-test-id=run-data-schema-item]')
.should('have.length', 20);
.find('[data-test-id=run-data-schema-item]')
.should('have.length.above', 10);
});
});

View file

@ -227,9 +227,6 @@ export class NDV extends BasePage {
this.getters.inputSelect().find('.el-select').click();
this.getters.inputOption().contains(nodeName).click();
},
expandSchemaViewNode: (nodeName: string) => {
this.getters.schemaViewNodeName().contains(nodeName).click();
},
addDefaultPinnedData: () => {
this.actions.editPinnedData();
this.actions.savePinnedData();

View file

@ -172,6 +172,5 @@ export interface FrontendSettings {
blockFileAccessToN8nFiles: boolean;
};
betaFeatures: FrontendBetaFeatures[];
virtualSchemaView: boolean;
easyAIWorkflowOnboarded: boolean;
}

View file

@ -405,11 +405,4 @@ 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',
},
};

View file

@ -231,7 +231,6 @@ export class FrontendService {
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
},
betaFeatures: this.frontendConfig.betaFeatures,
virtualSchemaView: config.getEnv('virtualSchemaView'),
easyAIWorkflowOnboarded: false,
};
}

View file

@ -126,6 +126,5 @@ export const defaultSettings: FrontendSettings = {
enabled: false,
},
betaFeatures: [],
virtualSchemaView: false,
easyAIWorkflowOnboarded: false,
};

View file

@ -14,7 +14,7 @@ import type { INodeProperties } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { outputTheme } from './ExpressionEditorModal/theme';
import ExpressionOutput from './InlineExpressionEditor/ExpressionOutput.vue';
import RunDataSchema from './RunDataSchema.vue';
import VirtualSchema from '@/components/VirtualSchema.vue';
import OutputItemSelect from './InlineExpressionEditor/OutputItemSelect.vue';
import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce';
@ -167,14 +167,13 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
</template>
</N8nInput>
<RunDataSchema
<VirtualSchema
:class="$style.schema"
:search="appliedSearch"
:nodes="parentNodes"
:mapping-enabled="!isReadOnly"
:connection-type="NodeConnectionType.Main"
pane-type="input"
context="modal"
/>
</div>
</N8nResizeWrapper>

View file

@ -46,7 +46,6 @@ 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';
@ -88,10 +87,8 @@ const LazyRunDataJson = defineAsyncComponent(
async () => await import('@/components/RunDataJson.vue'),
);
const LazyRunDataSchema = defineAsyncComponent(async () =>
useSettingsStore().settings.virtualSchemaView
? await import('@/components/VirtualSchema.vue')
: await import('@/components/RunDataSchema.vue'),
const LazyRunDataSchema = defineAsyncComponent(
async () => await import('@/components/VirtualSchema.vue'),
);
const LazyRunDataHtml = defineAsyncComponent(
async () => await import('@/components/RunDataHtml.vue'),

View file

@ -1,304 +0,0 @@
import { createComponentRenderer } from '@/__tests__/render';
import RunDataJsonSchema from '@/components/RunDataSchema.vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { userEvent } from '@testing-library/user-event';
import { cleanup, within, waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import {
createTestNode,
defaultNodeDescriptions,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { IF_NODE_TYPE, SET_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 } from 'n8n-workflow';
import * as nodeHelpers from '@/composables/useNodeHelpers';
const mockNode1 = createTestNode({
name: 'Set1',
type: SET_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,
});
async function setupStore() {
const workflow = mock<IWorkflowDb>({
id: '123',
name: 'Test Workflow',
connections: {},
active: true,
nodes: [mockNode1, mockNode2, disabledNode, ifNode, aiTool],
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
...defaultNodeDescriptions,
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>;
beforeEach(async () => {
cleanup();
renderComponent = createComponentRenderer(RunDataJsonSchema, {
global: {
stubs: ['font-awesome-icon'],
},
pinia: await setupStore(),
props: {
mappingEnabled: true,
runIndex: 1,
outputIndex: 0,
totalRuns: 2,
paneType: 'input',
connectionType: 'main',
search: '',
nodes: [
{ name: 'Set1', indicies: [], depth: 1 },
{ name: 'Set2', indicies: [], depth: 2 },
],
},
});
});
it('renders schema for empty data', async () => {
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('run-data-schema-empty').length).toBe(1);
// Expand second node
await userEvent.click(getAllByTestId('run-data-schema-node-name')[1]);
expect(getAllByTestId('run-data-schema-empty').length).toBe(2);
});
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 nodes = getAllByTestId('run-data-schema-node');
expect(nodes.length).toBe(2);
const firstNodeName = await within(nodes[0]).findByTestId('run-data-schema-node-name');
const firstNodeItemCount = await within(nodes[0]).findByTestId(
'run-data-schema-node-item-count',
);
expect(firstNodeName).toHaveTextContent('Set1');
expect(firstNodeItemCount).toHaveTextContent('2 items');
expect(within(nodes[0]).getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
const secondNodeName = await within(nodes[1]).findByTestId('run-data-schema-node-name');
expect(secondNodeName).toHaveTextContent('Set2');
// Expand second node
await userEvent.click(secondNodeName);
expect(within(nodes[1]).getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
});
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 { getAllByTestId } = renderComponent();
expect(getAllByTestId('run-data-schema-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-disabled')).toBeInTheDocument();
expect(getByTestId('run-data-schema-node-name')).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-node-name')).toHaveTextContent('If');
expect(getByTestId('run-data-schema-node-item-count')).toHaveTextContent('2 items');
expect(getByTestId('run-data-schema-node-schema')).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-node-name')).toHaveTextContent('If');
expect(getByTestId('run-data-schema-node-item-count')).toHaveTextContent('2 items');
expect(getByTestId('run-data-schema-node-schema')).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' }],
},
});
await waitFor(() => {
expect(getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
});
});
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 { queryByTestId } = renderComponent();
expect(queryByTestId('run-data-schema-empty')).not.toBeInTheDocument();
},
);
});

View file

@ -1,643 +0,0 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { snakeCase } from 'lodash-es';
import type { INodeUi, Schema } from '@/Interface';
import RunDataSchemaItem from '@/components/RunDataSchemaItem.vue';
import NodeIcon from '@/components/NodeIcon.vue';
import Draggable from '@/components/Draggable.vue';
import { useNDVStore } from '@/stores/ndv.store';
import { telemetry } from '@/plugins/telemetry';
import {
NodeConnectionType,
type IConnectedNode,
type IDataObject,
type INodeTypeDescription,
} from 'n8n-workflow';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { i18n } from '@/plugins/i18n';
import MappingPill from './MappingPill.vue';
import { useDataSchema } 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 { useDebounce } from '@/composables/useDebounce';
type Props = {
nodes?: IConnectedNode[];
node?: INodeUi | null;
data?: IDataObject[];
mappingEnabled?: boolean;
runIndex?: number;
outputIndex?: number;
totalRuns?: number;
paneType: 'input' | 'output';
connectionType?: NodeConnectionType;
search?: string;
context?: 'ndv' | 'modal';
};
type SchemaNode = {
node: INodeUi;
nodeType: INodeTypeDescription;
depth: number;
loading: boolean;
open: boolean;
connectedOutputIndexes: number[];
itemsCount: number | null;
schema: Schema | null;
};
const props = withDefaults(defineProps<Props>(), {
nodes: () => [],
distanceFromActive: 1,
node: null,
data: undefined,
runIndex: 0,
outputIndex: 0,
totalRuns: 1,
connectionType: NodeConnectionType.Main,
search: '',
mappingEnabled: false,
context: 'ndv',
});
const draggingPath = ref<string>('');
const nodesOpen = ref<Partial<Record<string, boolean>>>({});
const nodesData = ref<Partial<Record<string, { schema: Schema; itemsCount: number }>>>({});
const nodesLoading = ref<Partial<Record<string, boolean>>>({});
const disableScrollInView = ref(false);
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const { getSchemaForExecutionData, filterSchema } = useDataSchema();
const { getNodeInputData } = useNodeHelpers();
const { debounce } = useDebounce();
const emit = defineEmits<{
'clear:search': [];
}>();
const nodeSchema = computed(() =>
filterSchema(getSchemaForExecutionData(props.data ?? []), props.search),
);
const nodes = computed(() => {
return props.nodes
.map((node) => {
const fullNode = workflowsStore.getNodeByName(node.name);
if (!fullNode) return null;
const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion);
const { itemsCount, schema } = nodesData.value[node.name] ?? {
itemsCount: null,
schema: null,
};
return {
node: fullNode,
connectedOutputIndexes: node.indicies.length > 0 ? node.indicies : [0],
depth: node.depth,
itemsCount,
nodeType,
schema: schema ? filterSchema(schema, props.search) : null,
loading: nodesLoading.value[node.name],
open: nodesOpen.value[node.name],
};
})
.filter((node): node is SchemaNode => !!(node?.node && node.nodeType));
});
const filteredNodes = computed(() =>
nodes.value.filter((node) => !props.search || !isDataEmpty(node.schema)),
);
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 isDataEmpty = (schema: Schema | null) => {
if (!schema) return true;
// Utilize the generated schema instead of looping over the entire data again
// The schema for empty data is { type: 'object' | 'array', value: [] }
const isObjectOrArray = schema.type === 'object' || schema.type === 'array';
const isEmpty = Array.isArray(schema.value) && schema.value.length === 0;
return isObjectOrArray && isEmpty;
};
const highlight = computed(() => ndvStore.highlightDraggables);
const allNodesOpen = computed(() => nodes.value.every((node) => node.open));
const noNodesOpen = computed(() => nodes.value.every((node) => !node.open));
const loadNodeData = async ({ node, connectedOutputIndexes }: SchemaNode) => {
const pinData = workflowsStore.pinDataByNodeName(node.name);
const data =
pinData ??
connectedOutputIndexes
.map((outputIndex) =>
executionDataToJson(
getNodeInputData(node, props.runIndex, outputIndex, props.paneType, props.connectionType),
),
)
.flat();
nodesData.value[node.name] = {
schema: getSchemaForExecutionData(data),
itemsCount: data.length,
};
};
const toggleOpenNode = async (schemaNode: SchemaNode, exclusive = false) => {
const { node, schema, open } = schemaNode;
disableScrollInView.value = false;
if (open) {
nodesOpen.value[node.name] = false;
return;
}
if (!schema) {
nodesLoading.value[node.name] = true;
await loadNodeData(schemaNode);
nodesLoading.value[node.name] = false;
}
if (exclusive) {
nodesOpen.value = { [node.name]: true };
} else {
nodesOpen.value[node.name] = true;
}
};
const openAllNodes = async () => {
const nodesToLoad = nodes.value.filter((node) => !node.schema);
await Promise.all(nodesToLoad.map(loadNodeData));
nodesOpen.value = Object.fromEntries(nodes.value.map(({ node }) => [node.name, true]));
};
const onDragStart = (el: HTMLElement) => {
if (el?.dataset?.path) {
draggingPath.value = el.dataset.path;
}
ndvStore.resetMappingTelemetry();
};
const onDragEnd = (el: HTMLElement, node: INodeUi, depth: number) => {
draggingPath.value = '';
setTimeout(() => {
const mappingTelemetry = ndvStore.mappingTelemetry;
const telemetryPayload = {
src_node_type: node.type,
src_field_name: el.dataset.name ?? '',
src_nodes_back: depth,
src_run_index: props.runIndex,
src_runs_total: props.totalRuns,
src_field_nest_level: el.dataset.depth ?? 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 });
}, 1000); // ensure dest data gets set if drop
};
const onTransitionStart = debounce(
(event: TransitionEvent, nodeName: string) => {
if (
nodesOpen.value[nodeName] &&
event.target instanceof HTMLElement &&
!disableScrollInView.value
) {
event.target.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
},
{ debounceTime: 100, trailing: true },
);
watch(
() => props.nodes,
() => {
if (noNodesOpen.value && nodes.value.length > 0) {
void toggleOpenNode(nodes.value[0]);
}
},
{ immediate: true },
);
watch(
() => props.search,
(search, prevSearch) => {
if (!prevSearch?.trim() && search.trim() && !allNodesOpen.value) {
disableScrollInView.value = true;
void openAllNodes();
}
if (prevSearch?.trim() && !search.trim() && allNodesOpen.value && nodes.value.length > 0) {
nodesOpen.value = { [nodes.value[0].node.name]: true };
}
},
{ immediate: true },
);
</script>
<template>
<div
v-if="paneType === 'input' && nodes.length > 0"
:class="[$style.schemaWrapper, { highlightSchema: highlight }]"
>
<div v-if="search && nodes.length > 0 && filteredNodes.length === 0" :class="$style.noMatch">
<n8n-text tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</n8n-text>
<n8n-text>
<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>
</n8n-text>
</div>
<div
v-for="currentNode in filteredNodes"
:key="currentNode.node.id"
data-test-id="run-data-schema-node"
:class="[$style.node, { [$style.open]: currentNode.open }]"
>
<div
:class="[
$style.header,
{
[$style.trigger]: currentNode.nodeType.group.includes('trigger'),
},
]"
data-test-id="run-data-schema-node-header"
>
<div :class="$style.expand" @click="toggleOpenNode(currentNode)">
<font-awesome-icon icon="angle-right" :class="$style.expandIcon" />
</div>
<div
:class="$style.titleContainer"
data-test-id="run-data-schema-node-name"
@click="toggleOpenNode(currentNode, true)"
>
<div :class="$style.nodeIcon">
<NodeIcon :node-type="currentNode.nodeType" :size="12" />
</div>
<div :class="$style.title">
{{ currentNode.node.name }}
<span v-if="nodeAdditionalInfo(currentNode.node)" :class="$style.subtitle">{{
nodeAdditionalInfo(currentNode.node)
}}</span>
</div>
<font-awesome-icon
v-if="currentNode.nodeType.group.includes('trigger')"
:class="$style.triggerIcon"
icon="bolt"
size="xs"
/>
</div>
<Transition name="items">
<div
v-if="currentNode.itemsCount && currentNode.open"
:class="$style.items"
data-test-id="run-data-schema-node-item-count"
>
{{
i18n.baseText('ndv.output.items', {
interpolate: { count: currentNode.itemsCount },
})
}}
</div>
</Transition>
</div>
<Draggable
type="mapping"
target-data-key="mappable"
:disabled="!mappingEnabled"
@dragstart="onDragStart"
@dragend="(el: HTMLElement) => onDragEnd(el, currentNode.node, currentNode.depth)"
>
<template #preview="{ canDrop, el }">
<MappingPill v-if="el" :html="el.outerHTML" :can-drop="canDrop" />
</template>
<Transition name="schema">
<div
v-if="currentNode.schema || search"
:class="[$style.schema, $style.animated]"
data-test-id="run-data-schema-node-schema"
@transitionstart="(event) => onTransitionStart(event, currentNode.node.name)"
>
<div :class="$style.innerSchema" @transitionstart.stop>
<div
v-if="currentNode.node.disabled"
:class="$style.notice"
data-test-id="run-data-schema-disabled"
>
{{ i18n.baseText('dataMapping.schemaView.disabled') }}
</div>
<div
v-else-if="isDataEmpty(currentNode.schema)"
:class="$style.notice"
data-test-id="run-data-schema-empty"
>
{{ i18n.baseText('dataMapping.schemaView.emptyData') }}
</div>
<RunDataSchemaItem
v-else-if="currentNode.schema"
:schema="currentNode.schema"
:level="0"
:parent="null"
:pane-type="paneType"
:sub-key="`${props.context}_${snakeCase(currentNode.node.name)}`"
:mapping-enabled="mappingEnabled"
:dragging-path="draggingPath"
:distance-from-active="currentNode.depth"
:node="currentNode.node"
:search="search"
/>
</div>
</div>
</Transition>
</Draggable>
</div>
</div>
<div v-else :class="[$style.schemaWrapper, { highlightSchema: highlight }]">
<div v-if="isDataEmpty(nodeSchema) && search" :class="$style.noMatch">
<n8n-text tag="h3" size="large">{{ i18n.baseText('ndv.search.noNodeMatch.title') }}</n8n-text>
<n8n-text>
<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>
</n8n-text>
<n8n-text>{{ i18n.baseText('ndv.search.noMatchSchema.description') }}</n8n-text>
</div>
<div v-else :class="$style.schema" data-test-id="run-data-schema-node-schema">
<n8n-info-tip
v-if="isDataEmpty(nodeSchema)"
:class="$style.tip"
data-test-id="run-data-schema-empty"
>
{{ i18n.baseText('dataMapping.schemaView.emptyData') }}
</n8n-info-tip>
<RunDataSchemaItem
v-else-if="nodeSchema"
:schema="nodeSchema"
:level="0"
:parent="null"
:pane-type="paneType"
:sub-key="`${props.context}_output_${nodeSchema.type}-0-0`"
:mapping-enabled="mappingEnabled"
:dragging-path="draggingPath"
:node="node"
:search="search"
/>
</div>
</div>
</template>
<style lang="scss" module>
@import '@/styles/variables';
.schemaWrapper {
--header-height: 38px;
--title-spacing-left: 38px;
display: flex;
flex-direction: column;
container: schema / inline-size;
&.animating {
overflow: hidden;
height: 100%;
}
}
.node {
.schema {
padding-left: var(--title-spacing-left);
scroll-margin-top: var(--header-height);
}
.notice {
padding-left: var(--spacing-l);
}
}
.schema {
display: grid;
grid-template-rows: 1fr;
&.animated {
grid-template-rows: 0fr;
transform: translateX(-8px);
opacity: 0;
transition:
grid-template-rows 0.2s $ease-out-expo,
opacity 0.2s $ease-out-expo 0s,
transform 0.2s $ease-out-expo 0s;
}
}
.notice {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
}
.innerSchema {
min-height: 0;
min-width: 0;
> div {
margin-bottom: var(--spacing-xs);
}
}
.titleContainer {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
flex-basis: 100%;
cursor: pointer;
}
.subtitle {
margin-left: auto;
padding-left: var(--spacing-2xs);
color: var(--color-text-light);
font-weight: var(--font-weight-regular);
}
.header {
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 1;
padding-bottom: var(--spacing-2xs);
background: var(--color-run-data-background);
}
.expand {
--expand-toggle-size: 30px;
width: var(--expand-toggle-size);
height: var(--expand-toggle-size);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover,
&:active {
color: var(--color-text-dark);
}
}
.expandIcon {
transition: transform 0.2s $ease-out-expo;
}
.open {
.expandIcon {
transform: rotate(90deg);
}
.schema {
transition:
grid-template-rows 0.2s $ease-out-expo,
opacity 0.2s $ease-out-expo,
transform 0.2s $ease-out-expo;
grid-template-rows: 1fr;
opacity: 1;
transform: translateX(0);
}
}
.nodeIcon {
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);
}
.noMatch {
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
text-align: center;
> * {
max-width: 316px;
margin-bottom: var(--spacing-2xs);
}
}
.title {
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
}
.items {
flex-shrink: 0;
font-size: var(--font-size-2xs);
color: var(--color-text-light);
margin-left: var(--spacing-2xs);
transition:
opacity 0.2s $ease-out-expo,
transform 0.2s $ease-out-expo;
}
.triggerIcon {
margin-left: var(--spacing-2xs);
color: var(--color-primary);
}
.trigger {
.nodeIcon {
border-radius: 16px 4px 4px 16px;
}
}
@container schema (max-width: 24em) {
.depth {
display: none;
}
}
</style>
<style lang="scss" scoped>
@import '@/styles/variables';
.items-enter-from,
.items-leave-to {
transform: translateX(-4px);
opacity: 0;
}
.items-enter-to,
.items-leave-from {
transform: translateX(0);
opacity: 1;
}
.schema-enter-from,
.schema-leave-to {
grid-template-rows: 0fr;
transform: translateX(-8px);
opacity: 0;
}
.schema-enter-to,
.schema-leave-from {
transform: translateX(0);
grid-template-rows: 1fr;
opacity: 1;
}
</style>

View file

@ -1,333 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { INodeUi, Schema } from '@/Interface';
import { checkExhaustive } from '@/utils/typeGuards';
import { shorten } from '@/utils/typesUtils';
import { getMappedExpression } from '@/utils/mappingUtils';
import TextWithHighlights from './TextWithHighlights.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
type Props = {
schema: Schema;
level: number;
parent: Schema | null;
subKey: string;
paneType: 'input' | 'output';
mappingEnabled: boolean;
draggingPath: string;
distanceFromActive?: number;
node: INodeUi | null;
search: string;
};
const props = defineProps<Props>();
const isSchemaValueArray = computed(() => Array.isArray(props.schema.value));
const schemaArray = computed(
() => (isSchemaValueArray.value ? props.schema.value : []) as Schema[],
);
const isSchemaParentTypeArray = computed(() => props.parent?.type === 'array');
const key = computed((): string | undefined => {
return isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key;
});
const schemaName = computed(() =>
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
);
const text = computed(() =>
Array.isArray(props.schema.value) ? '' : shorten(props.schema.value, 600, 0),
);
const dragged = computed(() => props.draggingPath === props.schema.path);
const getJsonParameterPath = (path: string): string =>
getMappedExpression({
nodeName: props.node!.name,
distanceFromActive: props.distanceFromActive ?? 1,
path,
});
const getIconBySchemaType = (type: Schema['type']): string => {
switch (type) {
case 'object':
return 'cube';
case 'array':
return 'list';
case 'string':
case 'null':
return 'font';
case 'number':
return 'hashtag';
case 'boolean':
return 'check-square';
case 'function':
return 'code';
case 'bigint':
return 'calculator';
case 'symbol':
return 'sun';
case 'undefined':
return 'ban';
default:
checkExhaustive(type);
return '';
}
};
</script>
<template>
<div :class="$style.item" data-test-id="run-data-schema-item">
<div :class="$style.itemContent">
<div
v-if="level > 0 || (level === 0 && !isSchemaValueArray)"
:title="schema.type"
:class="{
[$style.pill]: true,
[$style.mappable]: mappingEnabled,
[$style.highlight]: dragged,
}"
>
<span
:class="$style.label"
:data-value="getJsonParameterPath(schema.path)"
:data-name="schemaName"
:data-path="schema.path"
:data-depth="level"
data-target="mappable"
>
<FontAwesomeIcon :icon="getIconBySchemaType(schema.type)" size="sm" />
<TextWithHighlights
v-if="isSchemaParentTypeArray"
:content="props.parent?.key"
:search="props.search"
/>
<TextWithHighlights
v-if="key"
:class="{ [$style.arrayIndex]: isSchemaParentTypeArray }"
:content="key"
:search="props.search"
/>
</span>
</div>
<span v-if="text" :class="$style.text" data-test-id="run-data-schema-item-value">
<template v-for="(line, index) in text.split('\n')" :key="`line-${index}`">
<span v-if="index > 0" :class="$style.newLine">\n</span>
<TextWithHighlights :content="line" :search="props.search" />
</template>
</span>
</div>
<input v-if="level > 0 && isSchemaValueArray" :id="subKey" type="checkbox" inert checked />
<label v-if="level > 0 && isSchemaValueArray" :class="$style.toggle" :for="subKey">
<FontAwesomeIcon icon="angle-right" />
</label>
<div v-if="isSchemaValueArray" :class="$style.sub">
<div :class="$style.innerSub">
<RunDataSchemaItem
v-for="s in schemaArray"
:key="s.key ?? s.type"
:schema="s"
:level="level + 1"
:parent="schema"
:pane-type="paneType"
:sub-key="`${subKey}-${s.key ?? s.type}`"
:mapping-enabled="mappingEnabled"
:dragging-path="draggingPath"
:distance-from-active="distanceFromActive"
:node="node"
:search="search"
/>
</div>
</div>
</div>
</template>
<style lang="scss" module>
@import '@/styles/variables';
.item {
display: flex;
flex-wrap: wrap;
align-items: center;
line-height: var(--font-line-height-loose);
position: relative;
column-gap: var(--spacing-2xs);
+ .item {
margin-top: var(--spacing-2xs);
}
.item {
padding-left: var(--spacing-l);
}
input {
display: none;
~ .sub {
transition:
grid-template-rows 0.2s $ease-out-expo,
opacity 0.2s $ease-out-expo,
transform 0.2s $ease-out-expo;
transform: translateX(-8px);
opacity: 0;
margin-bottom: 0;
.innerSub {
min-height: 0;
}
}
&:checked {
~ .toggle svg {
transform: rotate(90deg);
}
~ .sub {
transform: translateX(0);
opacity: 1;
grid-template-rows: 1fr;
}
}
}
}
.itemContent {
display: flex;
gap: var(--spacing-2xs);
align-items: baseline;
flex-grow: 1;
min-width: 0;
}
.sub {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
flex-basis: 100%;
scroll-margin: 64px;
}
.innerSub {
display: inline-flex;
flex-direction: column;
order: -1;
min-width: 0;
.innerSub > div:first-child {
margin-top: var(--spacing-2xs);
}
}
:global(.highlightSchema) {
.pill.mappable {
&,
&:hover,
span,
&:hover span span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background-color: var(--color-primary-tint-3);
svg {
path {
fill: var(--color-primary);
}
}
}
}
}
.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%;
path {
fill: var(--color-text-light);
}
&.mappable {
cursor: grab;
&:hover {
&,
span span {
background-color: var(--color-background-light);
border-color: var(--color-foreground-base);
}
}
}
}
.label {
display: flex;
min-width: 0;
align-items: center;
> span {
display: flex;
align-items: center;
margin-left: var(--spacing-3xs);
padding-left: var(--spacing-3xs);
border-left: 1px solid var(--color-foreground-light);
overflow: hidden;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.arrayIndex {
border: 0;
padding-left: 0;
margin-left: 0;
}
}
}
.text {
display: block;
font-weight: var(--font-weight-normal);
font-size: var(--font-size-2xs);
overflow: hidden;
word-break: break-word;
.newLine {
font-family: var(--font-family-monospace);
color: var(--color-line-break);
padding-right: 2px;
}
}
.toggle {
display: flex;
position: absolute;
padding: var(--spacing-4xs) var(--spacing-2xs);
left: 0;
top: 0;
justify-content: center;
align-items: center;
cursor: pointer;
user-select: none;
font-weight: normal;
font-size: var(--font-size-s);
overflow: hidden;
svg {
transition: transform 0.2s $ease-out-expo;
}
}
</style>

View file

@ -110,7 +110,7 @@ function mockNodeOutputData(nodeName: string, data: IDataObject[], outputIndex =
});
}
describe('RunDataSchema.vue', () => {
describe('VirtualSchema.vue', () => {
let renderComponent: ReturnType<typeof createComponentRenderer>;
const DynamicScrollerStub = {

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RunDataSchema.vue > renders previous nodes schema for AI tools 1`] = `
exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = `
<div
class="schema-header"
data-test-id="run-data-schema-header"
@ -75,7 +75,7 @@ exports[`RunDataSchema.vue > renders previous nodes schema for AI tools 1`] = `
</div>
`;
exports[`RunDataSchema.vue > renders schema for correct output branch 1`] = `
exports[`VirtualSchema.vue > renders schema for correct output branch 1`] = `
<div
class="schema-header"
data-test-id="run-data-schema-header"
@ -150,7 +150,7 @@ exports[`RunDataSchema.vue > renders schema for correct output branch 1`] = `
</div>
`;
exports[`RunDataSchema.vue > renders schema in output pane 1`] = `
exports[`VirtualSchema.vue > renders schema in output pane 1`] = `
<div>
<div
class="run-data-schema full-height"
@ -584,7 +584,7 @@ exports[`RunDataSchema.vue > renders schema in output pane 1`] = `
</div>
`;
exports[`RunDataSchema.vue > renders schema with spaces and dots 1`] = `
exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
<div>
<div
class="run-data-schema full-height"