feat(editor): Improve schema view empty state when node has binary data (#14044)

This commit is contained in:
Elias Meire
2025-03-24 14:40:48 +01:00
committed by GitHub
parent b616ceb08b
commit 22ddf1b644
4 changed files with 87 additions and 62 deletions

View File

@@ -1,33 +1,33 @@
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 { import {
createTestNode, createTestNode,
defaultNodeDescriptions, defaultNodeDescriptions,
mockNodeTypeDescription, mockNodeTypeDescription,
} from '@/__tests__/mocks'; } from '@/__tests__/mocks';
import { IF_NODE_TYPE, SET_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants'; import { createComponentRenderer } from '@/__tests__/render';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import VirtualSchema from '@/components/VirtualSchema.vue';
import { mock } from 'vitest-mock-extended'; import * as nodeHelpers from '@/composables/useNodeHelpers';
import { useTelemetry } from '@/composables/useTelemetry';
import { IF_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent } from '@testing-library/dom';
import { userEvent } from '@testing-library/user-event';
import { cleanup, waitFor } from '@testing-library/vue';
import { import {
createResultOk, createResultOk,
NodeConnectionTypes, NodeConnectionTypes,
type IDataObject, type IBinaryData,
type INodeExecutionData, type INodeExecutionData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import * as nodeHelpers from '@/composables/useNodeHelpers'; import { setActivePinia } from 'pinia';
import * as workflowHelpers from '@/composables/useWorkflowHelpers'; import { mock } from 'vitest-mock-extended';
import { useNDVStore } from '@/stores/ndv.store';
import { fireEvent } from '@testing-library/dom';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSchemaPreviewStore } from '../stores/schemaPreview.store';
import { usePostHog } from '../stores/posthog.store';
import { useSettingsStore } from '../stores/settings.store';
import { defaultSettings } from '../__tests__/defaults'; import { defaultSettings } from '../__tests__/defaults';
import { usePostHog } from '../stores/posthog.store';
import { useSchemaPreviewStore } from '../stores/schemaPreview.store';
import { useSettingsStore } from '../stores/settings.store';
const mockNode1 = createTestNode({ const mockNode1 = createTestNode({
name: 'Manual Trigger', name: 'Manual Trigger',
@@ -65,6 +65,14 @@ const aiTool = createTestNode({
disabled: false, disabled: false,
}); });
const nodeWithCredential = createTestNode({
name: 'Notion',
type: 'n8n-nodes-base.notion',
typeVersion: 1,
credentials: { notionApi: { id: 'testId', name: 'testName' } },
disabled: false,
});
const unknownNodeType = createTestNode({ const unknownNodeType = createTestNode({
name: 'Unknown Node Type', name: 'Unknown Node Type',
type: 'unknown', type: 'unknown',
@@ -76,15 +84,23 @@ const defaultNodes = [
]; ];
async function setupStore() { async function setupStore() {
const workflow = mock<IWorkflowDb>({ const workflow = {
id: '123', id: '123',
name: 'Test Workflow', name: 'Test Workflow',
connections: {}, connections: {},
active: true, active: true,
nodes: [mockNode1, mockNode2, disabledNode, ifNode, aiTool, unknownNodeType], nodes: [
}); mockNode1,
mockNode2,
disabledNode,
ifNode,
aiTool,
unknownNodeType,
nodeWithCredential,
],
};
const pinia = createPinia(); const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia); setActivePinia(pinia);
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
@@ -102,20 +118,24 @@ async function setupStore() {
name: IF_NODE_TYPE, name: IF_NODE_TYPE,
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
}), }),
mockNodeTypeDescription({
name: 'n8n-nodes-base.notion',
outputs: [NodeConnectionTypes.Main],
}),
]); ]);
workflowsStore.workflow = workflow; workflowsStore.workflow = workflow as IWorkflowDb;
return pinia; return pinia;
} }
function mockNodeOutputData(nodeName: string, data: IDataObject[], outputIndex = 0) { function mockNodeOutputData(nodeName: string, data: INodeExecutionData[], outputIndex = 0) {
const originalNodeHelpers = nodeHelpers.useNodeHelpers(); const originalNodeHelpers = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => { vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
return { return {
...originalNodeHelpers, ...originalNodeHelpers,
getNodeInputData: vi.fn((node, _, output) => { getNodeInputData: vi.fn((node, _, output) => {
if (node.name === nodeName && output === outputIndex) { if (node.name === nodeName && output === outputIndex) {
return data.map((json) => ({ json })); return data;
} }
return []; return [];
}), }),
@@ -146,7 +166,7 @@ describe('VirtualSchema.vue', () => {
beforeEach(async () => { beforeEach(async () => {
cleanup(); cleanup();
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(123); vi.resetAllMocks();
vi.setSystemTime('2025-01-01'); vi.setSystemTime('2025-01-01');
renderComponent = createComponentRenderer(VirtualSchema, { renderComponent = createComponentRenderer(VirtualSchema, {
global: { global: {
@@ -183,6 +203,20 @@ describe('VirtualSchema.vue', () => {
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1); expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1);
}); });
it('renders schema for empty data with binary', async () => {
mockNodeOutputData(mockNode1.name, [{ json: {}, binary: { data: mock<IBinaryData>() } }]);
const { getByText } = renderComponent({
props: { nodes: [{ name: mockNode1.name, indicies: [], depth: 1 }] },
});
await waitFor(() =>
expect(
getByText("Only binary data exists. View it using the 'Binary' tab"),
).toBeInTheDocument(),
);
});
it('renders schema for data', async () => { it('renders schema for data', async () => {
useWorkflowsStore().pinData({ useWorkflowsStore().pinData({
node: mockNode1, node: mockNode1,
@@ -307,10 +341,7 @@ describe('VirtualSchema.vue', () => {
it('renders schema for correct output branch', async () => { it('renders schema for correct output branch', async () => {
mockNodeOutputData( mockNodeOutputData(
'If', 'If',
[ [{ json: { id: 1, name: 'John' } }, { json: { id: 2, name: 'Jane' } }],
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
1, 1,
); );
const { getAllByTestId } = renderComponent({ const { getAllByTestId } = renderComponent({
@@ -330,10 +361,7 @@ describe('VirtualSchema.vue', () => {
it('renders previous nodes schema for AI tools', async () => { it('renders previous nodes schema for AI tools', async () => {
mockNodeOutputData( mockNodeOutputData(
'If', 'If',
[ [{ json: { id: 1, name: 'John' } }, { json: { id: 2, name: 'Jane' } }],
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
0, 0,
); );
const { getAllByTestId } = renderComponent({ const { getAllByTestId } = renderComponent({
@@ -481,14 +509,6 @@ describe('VirtualSchema.vue', () => {
}); });
it('should track data pill drag and drop for schema preview', async () => { it('should track data pill drag and drop for schema preview', async () => {
useWorkflowsStore().pinData({
node: {
...mockNode2,
credentials: { myCredential: { id: 'myCredential', name: 'myCredential' } },
},
data: [],
});
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const trackSpy = vi.spyOn(telemetry, 'track'); const trackSpy = vi.spyOn(telemetry, 'track');
const posthogStore = usePostHog(); const posthogStore = usePostHog();
@@ -513,7 +533,7 @@ describe('VirtualSchema.vue', () => {
const { getAllByTestId } = renderComponent({ const { getAllByTestId } = renderComponent({
props: { props: {
nodes: [{ name: mockNode2.name, indicies: [], depth: 1 }], nodes: [{ name: nodeWithCredential.name, indicies: [], depth: 1 }],
}, },
}); });
@@ -639,4 +659,8 @@ describe('VirtualSchema.vue', () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('should do something', () => {
expect(true).toBe(true);
});
}); });

View File

@@ -105,21 +105,11 @@ const toggleNodeAndScrollTop = (id: string) => {
const getNodeSchema = async (fullNode: INodeUi, connectedNode: IConnectedNode) => { const getNodeSchema = async (fullNode: INodeUi, connectedNode: IConnectedNode) => {
const pinData = workflowsStore.pinDataByNodeName(connectedNode.name); const pinData = workflowsStore.pinDataByNodeName(connectedNode.name);
const connectedOutputIndexes = connectedNode.indicies.length > 0 ? connectedNode.indicies : [0]; const connectedOutputIndexes = connectedNode.indicies.length > 0 ? connectedNode.indicies : [0];
const data = const nodeData = connectedOutputIndexes.map((outputIndex) =>
pinData ?? getNodeInputData(fullNode, props.runIndex, outputIndex, props.paneType, props.connectionType),
connectedOutputIndexes );
.map((outputIndex) => const hasBinary = nodeData.flat().some((data) => !isEmpty(data.binary));
executionDataToJson( const data = pinData ?? nodeData.map(executionDataToJson).flat();
getNodeInputData(
fullNode,
props.runIndex,
outputIndex,
props.paneType,
props.connectionType,
),
),
)
.flat();
let schema = getSchemaForExecutionData(data); let schema = getSchemaForExecutionData(data);
let preview = false; let preview = false;
@@ -137,6 +127,7 @@ const getNodeSchema = async (fullNode: INodeUi, connectedNode: IConnectedNode) =
connectedOutputIndexes, connectedOutputIndexes,
itemsCount: data.length, itemsCount: data.length,
preview, preview,
hasBinary,
}; };
}; };
@@ -251,7 +242,7 @@ const nodesSchemas = asyncComputed<SchemaNode[]>(async () => {
const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion); const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion);
if (!nodeType) continue; if (!nodeType) continue;
const { schema, connectedOutputIndexes, itemsCount, preview } = await getNodeSchema( const { schema, connectedOutputIndexes, itemsCount, preview, hasBinary } = await getNodeSchema(
fullNode, fullNode,
node, node,
); );
@@ -268,6 +259,7 @@ const nodesSchemas = asyncComputed<SchemaNode[]>(async () => {
nodeType, nodeType,
schema: filteredSchema, schema: filteredSchema,
preview, preview,
hasBinary,
}); });
} }

View File

@@ -233,6 +233,7 @@ export type SchemaNode = {
itemsCount: number; itemsCount: number;
schema: Schema; schema: Schema;
preview: boolean; preview: boolean;
hasBinary: boolean;
}; };
export type RenderItem = { export type RenderItem = {
@@ -295,10 +296,12 @@ const icons = {
const getIconBySchemaType = (type: Schema['type']): string => icons[type]; const getIconBySchemaType = (type: Schema['type']): string => icons[type];
const emptyItem = (): RenderItem => ({ const emptyItem = (
message = useI18n().baseText('dataMapping.schemaView.emptyData'),
): RenderItem => ({
id: `empty-${window.crypto.randomUUID()}`, id: `empty-${window.crypto.randomUUID()}`,
icon: '', icon: '',
value: useI18n().baseText('dataMapping.schemaView.emptyData'), value: message,
type: 'item', type: 'item',
}); });
@@ -457,7 +460,12 @@ export const useFlattenSchema = () => {
} }
if (isDataEmpty(item.schema)) { if (isDataEmpty(item.schema)) {
acc.push(emptyItem()); const message = useI18n().baseText(
item.hasBinary
? 'dataMapping.schemaView.emptyDataWithBinary'
: 'dataMapping.schemaView.emptyData',
);
acc.push(emptyItem(message));
return acc; return acc;
} }

View File

@@ -667,6 +667,7 @@
"dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.", "dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.",
"dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view", "dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view",
"dataMapping.schemaView.emptyData": "No fields - item(s) exist, but they're empty", "dataMapping.schemaView.emptyData": "No fields - item(s) exist, but they're empty",
"dataMapping.schemaView.emptyDataWithBinary": "Only binary data exists. View it using the 'Binary' tab",
"dataMapping.schemaView.disabled": "This node is disabled and will just pass data through", "dataMapping.schemaView.disabled": "This node is disabled and will just pass data through",
"dataMapping.schemaView.noMatches": "No results for '{search}'", "dataMapping.schemaView.noMatches": "No results for '{search}'",
"dataMapping.schemaView.preview": "Usually outputs the following fields. Execute the node to see the actual ones. {link}", "dataMapping.schemaView.preview": "Usually outputs the following fields. Execute the node to see the actual ones. {link}",