feat(editor): Display schema preview for unexecuted nodes (#12901)

This commit is contained in:
Elias Meire 2025-02-03 08:50:33 +01:00 committed by GitHub
parent ce1deb8aea
commit 0063bbb30b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1560 additions and 347 deletions

View file

@ -98,6 +98,7 @@
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1",
"@types/humanize-duration": "^3.27.1",
"@types/json-schema": "^7.0.15",
"@types/jsonpath": "^0.2.0",
"@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.2.0",

View file

@ -740,6 +740,8 @@ export interface CreateElementBase {
export interface NodeCreateElement extends CreateElementBase {
type: 'node';
subcategory: string;
resource?: string;
operation?: string;
properties: SimplifiedNodeType;
}

View file

@ -0,0 +1,28 @@
import { request } from '@/utils/apiUtils';
import type { JSONSchema7 } from 'json-schema';
export type GetSchemaPreviewOptions = {
nodeType: string;
version: number;
resource?: string;
operation?: string;
};
const padVersion = (version: number) => {
return version.toString().split('.').concat(['0', '0']).slice(0, 3).join('.');
};
export const getSchemaPreview = async (
baseUrl: string,
options: GetSchemaPreviewOptions,
): Promise<JSONSchema7> => {
const { nodeType, version, resource, operation } = options;
const versionString = padVersion(version);
const path = ['schemas', nodeType, versionString, resource, operation].filter(Boolean).join('/');
return await request({
method: 'GET',
baseURL: baseUrl,
endpoint: `${path}.json`,
withCredentials: false,
});
};

View file

@ -27,6 +27,7 @@ import type {
} from '@/Interface';
import {
CORE_NODES_CATEGORY,
DATA_EDITING_DOCS_URL,
DATA_PINNING_DOCS_URL,
HTML_NODE_TYPE,
@ -35,6 +36,7 @@ import {
MAX_DISPLAY_DATA_SIZE,
MAX_DISPLAY_DATA_SIZE_SCHEMA_VIEW,
NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND,
SCHEMA_PREVIEW_EXPERIMENT,
TEST_PIN_DATA,
} from '@/constants';
@ -60,7 +62,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { getGenericHints } from '@/utils/nodeViewUtils';
import { searchInObject } from '@/utils/objectUtils';
import { clearJsonKey, isEmpty } from '@/utils/typesUtils';
import { clearJsonKey, isEmpty, isPresent } from '@/utils/typesUtils';
import { isEqual, isObject } from 'lodash-es';
import {
N8nBlockUi,
@ -81,6 +83,9 @@ import { storeToRefs } from 'pinia';
import { useRoute } from 'vue-router';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useUIStore } from '@/stores/ui.store';
import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
import { asyncComputed } from '@vueuse/core';
import { usePostHog } from '@/stores/posthog.store';
const LazyRunDataTable = defineAsyncComponent(
async () => await import('@/components/RunDataTable.vue'),
@ -181,6 +186,8 @@ const workflowsStore = useWorkflowsStore();
const sourceControlStore = useSourceControlStore();
const rootStore = useRootStore();
const uiStore = useUIStore();
const schemaPreviewStore = useSchemaPreviewStore();
const posthogStore = usePostHog();
const toast = useToast();
const route = useRoute();
@ -544,6 +551,36 @@ const hasInputOverwrite = computed((): boolean => {
return Boolean(taskData?.inputOverride);
});
const isSchemaPreviewEnabled = computed(
() =>
props.paneType === 'input' &&
!(nodeType.value?.codex?.categories ?? []).some(
(category) => category === CORE_NODES_CATEGORY,
) &&
posthogStore.isFeatureEnabled(SCHEMA_PREVIEW_EXPERIMENT),
);
const hasPreviewSchema = asyncComputed(async () => {
if (!isSchemaPreviewEnabled.value || props.nodes.length === 0) return false;
const nodes = props.nodes
.filter((n) => n.depth === 1)
.map((n) => workflowsStore.getNodeByName(n.name))
.filter(isPresent);
for (const connectedNode of nodes) {
const { type, typeVersion, parameters } = connectedNode;
const hasPreview = await schemaPreviewStore.getSchemaPreview({
nodeType: type,
version: typeVersion,
resource: parameters.resource as string,
operation: parameters.operation as string,
});
if (hasPreview.ok) return true;
}
return false;
}, false);
watch(node, (newNode, prevNode) => {
if (newNode?.id === prevNode?.id) return;
init();
@ -1346,7 +1383,8 @@ defineExpose({ enterEditMode });
<N8nRadioButtons
v-show="
hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled
hasPreviewSchema ||
(hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled)
"
:model-value="displayMode"
:options="displayModes"
@ -1590,7 +1628,7 @@ defineExpose({ enterEditMode });
</div>
<div
v-else-if="!hasNodeRun && !(displaysMultipleNodes && node?.disabled)"
v-else-if="!hasNodeRun && !(displaysMultipleNodes && (node?.disabled || hasPreviewSchema))"
:class="$style.center"
>
<slot name="node-not-run"></slot>
@ -1781,7 +1819,7 @@ defineExpose({ enterEditMode });
<LazyRunDataHtml :input-html="inputHtml" />
</Suspense>
<Suspense v-else-if="hasNodeRun && isSchemaView">
<Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView">
<LazyRunDataSchema
:nodes="nodes"
:mapping-enabled="mappingEnabled"

View file

@ -13,11 +13,18 @@ import { IF_NODE_TYPE, SET_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constan
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 {
createResultOk,
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';
import { useSchemaPreviewStore } from '../stores/schemaPreview.store';
import { usePostHog } from '../stores/posthog.store';
const mockNode1 = createTestNode({
name: 'Manual Trigger',
@ -127,6 +134,9 @@ describe('VirtualSchema.vue', () => {
const DynamicScrollerItemStub = {
template: '<slot></slot>',
};
const NoticeStub = {
template: '<div v-bind="$attrs"><slot></slot></div>',
};
beforeEach(async () => {
cleanup();
@ -136,6 +146,7 @@ describe('VirtualSchema.vue', () => {
DynamicScroller: DynamicScrollerStub,
DynamicScrollerItem: DynamicScrollerItemStub,
FontAwesomeIcon: true,
Notice: NoticeStub,
},
},
pinia: await setupStore(),
@ -155,7 +166,9 @@ describe('VirtualSchema.vue', () => {
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);
await waitFor(() =>
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]);
@ -179,23 +192,25 @@ describe('VirtualSchema.vue', () => {
});
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');
await waitFor(() => {
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');
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');
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({
const { container, getAllByTestId } = renderComponent({
props: {
nodes: [],
paneType: 'output',
@ -207,10 +222,15 @@ describe('VirtualSchema.vue', () => {
},
});
await waitFor(() => {
const items = getAllByTestId('run-data-schema-item');
expect(items).toHaveLength(5);
});
expect(container).toMatchSnapshot();
});
it('renders schema with spaces and dots', () => {
it('renders schema with spaces and dots', async () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [
@ -229,39 +249,51 @@ describe('VirtualSchema.vue', () => {
],
});
const { container } = renderComponent();
const { container, getAllByTestId } = renderComponent();
await waitFor(() => {
const items = getAllByTestId('run-data-schema-item');
expect(items).toHaveLength(6);
});
expect(container).toMatchSnapshot();
});
it('renders no data to show for data empty objects', () => {
it('renders no data to show for data empty objects', async () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [{ json: {} }, { json: {} }],
});
const { getAllByText } = renderComponent();
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(2);
await waitFor(() =>
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', () => {
it('renders empty state to show for empty data', async () => {
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);
await waitFor(() =>
expect(getAllByText("No fields - item(s) exist, but they're empty").length).toBe(1),
);
});
it('renders disabled nodes correctly', () => {
it('renders disabled nodes correctly', async () => {
const { getByTestId } = renderComponent({
props: {
nodes: [{ name: disabledNode.name, indicies: [], depth: 1 }],
},
});
expect(getByTestId('run-data-schema-header')).toHaveTextContent(
`${disabledNode.name} (Deactivated)`,
await waitFor(() =>
expect(getByTestId('run-data-schema-header')).toHaveTextContent(
`${disabledNode.name} (Deactivated)`,
),
);
});
@ -325,23 +357,27 @@ describe('VirtualSchema.vue', () => {
},
});
expect(getByTestId('run-data-schema-item')).toHaveTextContent('AI tool output');
await waitFor(() =>
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) => {
async (data) => {
useWorkflowsStore().pinData({
node: mockNode1,
data: data.map((item) => ({ json: item })),
});
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('run-data-schema-item')[0]).toHaveTextContent('tx');
await waitFor(() =>
expect(getAllByTestId('run-data-schema-item')[0]).toHaveTextContent('tx'),
);
},
);
it('should filter invalid connections', () => {
it('should filter invalid connections', async () => {
const { pinData } = useWorkflowsStore();
pinData({
node: mockNode1,
@ -363,10 +399,10 @@ describe('VirtualSchema.vue', () => {
},
});
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
await waitFor(() => expect(getAllByTestId('run-data-schema-item').length).toBe(2));
});
it('should show connections', () => {
it('should show connections', async () => {
const ndvStore = useNDVStore();
vi.spyOn(ndvStore, 'ndvNodeInputNumber', 'get').mockReturnValue({
[defaultNodes[0].name]: [0],
@ -374,10 +410,13 @@ describe('VirtualSchema.vue', () => {
});
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');
await waitFor(() => {
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 () => {
@ -391,8 +430,10 @@ describe('VirtualSchema.vue', () => {
const reset = vi.spyOn(ndvStore, 'resetMappingTelemetry');
const { getAllByTestId } = renderComponent();
await waitFor(() => {
expect(getAllByTestId('run-data-schema-item')).toHaveLength(6);
});
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');
@ -425,15 +466,56 @@ describe('VirtualSchema.vue', () => {
});
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);
await waitFor(async () => {
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)));
// 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);
expect(queryAllByTestId('run-data-schema-item').length).toBe(0);
await rerender({ search: 'John' });
expect(getAllByTestId('run-data-schema-item').length).toBe(2);
});
});
it('renders preview schema when enabled and available', async () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [],
});
useWorkflowsStore().pinData({
node: mockNode2,
data: [],
});
const posthogStore = usePostHog();
vi.spyOn(posthogStore, 'isFeatureEnabled').mockReturnValue(true);
const schemaPreviewStore = useSchemaPreviewStore();
vi.spyOn(schemaPreviewStore, 'getSchemaPreview').mockResolvedValue(
createResultOk({
type: 'object',
properties: {
account: {
type: 'object',
properties: {
id: {
type: 'string',
},
},
},
},
}),
);
const { getAllByTestId, queryAllByText, container } = renderComponent({});
await waitFor(() => {
expect(getAllByTestId('run-data-schema-header')).toHaveLength(2);
});
expect(queryAllByText("No fields - item(s) exist, but they're empty")).toHaveLength(0);
expect(getAllByTestId('schema-preview-warning')).toHaveLength(2);
expect(container).toMatchSnapshot();
});
});

View file

@ -7,7 +7,12 @@ 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 {
createResultError,
NodeConnectionType,
type IConnectedNode,
type IDataObject,
} from 'n8n-workflow';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@/composables/useI18n';
import MappingPill from './MappingPill.vue';
@ -23,6 +28,10 @@ import {
} from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
import { asyncComputed } from '@vueuse/core';
import { usePostHog } from '@/stores/posthog.store';
import { SCHEMA_PREVIEW_EXPERIMENT } from '@/constants';
type Props = {
nodes?: IConnectedNode[];
@ -55,7 +64,9 @@ const i18n = useI18n();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const { getSchemaForExecutionData, filterSchema } = useDataSchema();
const schemaPreviewStore = useSchemaPreviewStore();
const posthogStore = usePostHog();
const { getSchemaForExecutionData, getSchemaForJsonSchema, filterSchema } = useDataSchema();
const { closedNodes, flattenSchema, flattenMultipleSchemas, toggleLeaf, toggleNode } =
useFlattenSchema();
const { getNodeInputData } = useNodeHelpers();
@ -79,7 +90,7 @@ watch(
},
);
const getNodeSchema = (fullNode: INodeUi, connectedNode: IConnectedNode) => {
const getNodeSchema = async (fullNode: INodeUi, connectedNode: IConnectedNode) => {
const pinData = workflowsStore.pinDataByNodeName(connectedNode.name);
const connectedOutputIndexes = connectedNode.indicies.length > 0 ? connectedNode.indicies : [0];
const data =
@ -98,42 +109,88 @@ const getNodeSchema = (fullNode: INodeUi, connectedNode: IConnectedNode) => {
)
.flat();
let schema = getSchemaForExecutionData(data);
let preview = false;
if (data.length === 0 && isSchemaPreviewEnabled.value) {
const previewSchema = await getSchemaPreview(fullNode);
if (previewSchema.ok) {
schema = getSchemaForJsonSchema(previewSchema.result);
preview = true;
}
}
return {
schema: getSchemaForExecutionData(data),
schema,
connectedOutputIndexes,
itemsCount: data.length,
preview,
};
};
const nodeSchema = computed(() =>
filterSchema(getSchemaForExecutionData(props.data), props.search),
const isSchemaPreviewEnabled = computed(() =>
posthogStore.isFeatureEnabled(SCHEMA_PREVIEW_EXPERIMENT),
);
const nodesSchemas = computed<SchemaNode[]>(() => {
return props.nodes.reduce<SchemaNode[]>((acc, node) => {
const nodeSchema = asyncComputed(async () => {
if (props.data.length === 0 && isSchemaPreviewEnabled.value) {
const previewSchema = await getSchemaPreview(props.node);
if (previewSchema.ok) {
return filterSchema(getSchemaForJsonSchema(previewSchema.result), props.search);
}
}
return filterSchema(getSchemaForExecutionData(props.data), props.search);
});
async function getSchemaPreview(node: INodeUi | null) {
if (!node) return createResultError(new Error());
const {
type,
typeVersion,
parameters: { resource, operation },
} = node;
return await schemaPreviewStore.getSchemaPreview({
nodeType: type,
version: typeVersion,
resource: resource as string,
operation: operation as string,
});
}
const nodesSchemas = asyncComputed<SchemaNode[]>(async () => {
const result: SchemaNode[] = [];
for (const node of props.nodes) {
const fullNode = workflowsStore.getNodeByName(node.name);
if (!fullNode) return acc;
if (!fullNode) continue;
const nodeType = nodeTypesStore.getNodeType(fullNode.type, fullNode.typeVersion);
if (!nodeType) return acc;
if (!nodeType) continue;
const { schema, connectedOutputIndexes, itemsCount } = getNodeSchema(fullNode, node);
const { schema, connectedOutputIndexes, itemsCount, preview } = await getNodeSchema(
fullNode,
node,
);
const filteredSchema = filterSchema(schema, props.search);
if (!filteredSchema) return acc;
if (!filteredSchema) continue;
acc.push({
result.push({
node: fullNode,
connectedOutputIndexes,
depth: node.depth,
itemsCount,
nodeType,
schema: filteredSchema,
preview,
});
return acc;
}, []);
});
}
return result;
}, []);
const nodeAdditionalInfo = (node: INodeUi) => {
const returnData: string[] = [];
@ -243,21 +300,21 @@ const onDragEnd = (el: HTMLElement) => {
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]"
:size-dependencies="[item]"
:data-index="index"
>
<VirtualSchemaHeader
v-if="item.type === 'header'"
v-bind="item"
:collapsed="closedNodes.has(item.id)"
@click:toggle="toggleLeaf(item.id)"
@click="toggleNodeAndScrollTop(item.id)"
/>
<VirtualSchemaItem
v-else
v-bind="item"
:search="search"
:draggable="mappingEnabled"
@ -283,6 +340,7 @@ const onDragEnd = (el: HTMLElement) => {
.scroller {
padding: 0 var(--spacing-s);
padding-bottom: var(--spacing-2xl);
}
.no-results {

View file

@ -4,6 +4,8 @@ import NodeIcon from '@/components/NodeIcon.vue';
import { type INodeTypeDescription } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { DATA_EDITING_DOCS_URL } from '@/constants';
import { N8nNotice } from 'n8n-design-system';
const props = defineProps<{
title: string;
@ -12,6 +14,7 @@ const props = defineProps<{
collapsed: boolean;
nodeType: INodeTypeDescription;
itemCount: number | null;
preview?: boolean;
}>();
const i18n = useI18n();
@ -22,33 +25,51 @@ const emit = defineEmits<{
</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>
<div class="schema-header-wrapper">
<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 } }) }}
<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>
<N8nNotice
v-if="preview && !collapsed"
class="notice"
theme="warning"
data-test-id="schema-preview-warning"
>
<i18n-t keypath="dataMapping.schemaView.preview">
<template #link>
<N8nLink :to="DATA_EDITING_DOCS_URL" size="small">
{{ i18n.baseText('generic.learnMore') }}
</N8nLink>
</template>
</i18n-t>
</N8nNotice>
</div>
</template>
<style lang="scss" scoped>
.schema-header-wrapper {
padding-bottom: var(--spacing-2xs);
}
.schema-header {
display: flex;
align-items: center;
padding-bottom: var(--spacing-2xs);
cursor: pointer;
}
.toggle {
@ -101,4 +122,10 @@ const emit = defineEmits<{
color: var(--color-text-light);
margin-left: auto;
}
.notice {
margin-left: var(--spacing-2xl);
margin-top: var(--spacing-2xs);
margin-bottom: 0;
}
</style>

View file

@ -17,6 +17,7 @@ type Props = {
draggable?: boolean;
collapsed?: boolean;
search?: string;
preview?: boolean;
};
const props = defineProps<Props>();
@ -42,7 +43,7 @@ const emit = defineEmits<{
:data-node-type="nodeType"
data-target="mappable"
class="pill"
:class="{ 'pill--highlight': highlight }"
:class="{ 'pill--highlight': highlight, 'pill--preview': preview }"
data-test-id="run-data-schema-node-name"
>
<FontAwesomeIcon class="type-icon" :icon size="sm" />
@ -89,11 +90,22 @@ const emit = defineEmits<{
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);
}
&.pill--preview {
border-style: dashed;
border-width: 1.5px;
.title {
color: var(--color-text-light);
border-left: 1.5px dashed var(--color-foreground-light);
}
}
}
.draggable .pill.pill--highlight {

View file

@ -9,11 +9,13 @@ import {
type ITaskDataConnections,
} from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { JSONSchema7 } from 'json-schema';
vi.mock('@/stores/workflows.store');
describe('useDataSchema', () => {
const getSchema = useDataSchema().getSchema;
describe('getSchema', () => {
test.each([
[, { type: 'undefined', value: 'undefined', path: '' }],
@ -534,6 +536,7 @@ describe('useDataSchema', () => {
expect(filterSchema(flatSchema, '')).toEqual(flatSchema);
});
});
describe('getNodeInputData', () => {
const getNodeInputData = useDataSchema().getNodeInputData;
@ -648,6 +651,132 @@ describe('useDataSchema', () => {
},
);
});
describe('getSchemaForJsonSchema', () => {
const getSchemaForJsonSchema = useDataSchema().getSchemaForJsonSchema;
it('should convert JSON schema to Schema type', () => {
const jsonSchema: JSONSchema7 = {
type: 'object',
properties: {
id: {
type: 'string',
},
email: {
type: 'string',
},
address: {
type: 'object',
properties: {
line1: {
type: 'string',
},
country: {
type: 'string',
},
},
},
tags: {
type: 'array',
items: { type: 'string' },
},
workspaces: {
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
},
name: {
type: 'string',
},
},
required: ['gid', 'name', 'resource_type'],
},
},
},
required: ['gid', 'email', 'name', 'photo', 'resource_type', 'workspaces'],
};
expect(getSchemaForJsonSchema(jsonSchema)).toEqual({
path: '',
type: 'object',
value: [
{
key: 'id',
path: '.id',
type: 'string',
value: '',
},
{
key: 'email',
path: '.email',
type: 'string',
value: '',
},
{
key: 'address',
path: '.address',
type: 'object',
value: [
{
key: 'line1',
path: '.address.line1',
type: 'string',
value: '',
},
{
key: 'country',
path: '.address.country',
type: 'string',
value: '',
},
],
},
{
key: 'tags',
path: '.tags',
type: 'array',
value: [
{
key: '0',
path: '.tags[0]',
type: 'string',
value: '',
},
],
},
{
key: 'workspaces',
path: '.workspaces',
type: 'array',
value: [
{
key: '0',
path: '.workspaces[0]',
type: 'object',
value: [
{
key: 'id',
path: '.workspaces[0].id',
type: 'string',
value: '',
},
{
key: 'name',
path: '.workspaces[0].name',
type: 'string',
value: '',
},
],
},
],
},
],
});
});
});
});
describe('useFlattenSchema', () => {

View file

@ -1,5 +1,5 @@
import { ref } from 'vue';
import type { Optional, Primitives, Schema, INodeUi } from '@/Interface';
import type { Optional, Primitives, Schema, INodeUi, SchemaType } from '@/Interface';
import {
type ITaskDataConnections,
type IDataObject,
@ -13,6 +13,8 @@ import { isObj } from '@/utils/typeGuards';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { isPresent, shorten } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import type { JSONSchema7, JSONSchema7Definition, JSONSchema7TypeName } from 'json-schema';
import { isObject } from '@/utils/objectUtils';
export function useDataSchema() {
function getSchema(
@ -67,6 +69,58 @@ export function useDataSchema() {
return getSchema(merge({}, head, ...tail, head), undefined, excludeValues);
}
function getSchemaForJsonSchema(schema: JSONSchema7 | JSONSchema7Definition, path = ''): Schema {
if (typeof schema !== 'object') {
return {
type: 'null',
path,
value: 'null',
};
}
if (schema.type === 'array') {
return {
type: 'array',
value: isObject(schema.items)
? [{ ...getSchemaForJsonSchema(schema.items, `${path}[0]`), key: '0' }]
: [],
path,
};
}
if (schema.type === 'object') {
const properties = schema.properties ?? {};
const value = Object.entries(properties).map(([key, propSchema]) => {
const newPath = path ? `${path}.${key}` : `.${key}`;
const transformed = getSchemaForJsonSchema(propSchema, newPath);
return { ...transformed, key };
});
return {
type: 'object',
value,
path,
};
}
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
return {
type: JsonSchemaTypeToSchemaType(type),
value: '',
path,
};
}
function JsonSchemaTypeToSchemaType(type: JSONSchema7TypeName | undefined): SchemaType {
switch (type) {
case undefined:
return 'undefined';
case 'integer':
return 'number';
default:
return type;
}
}
// Returns the data of the main input
function getMainInputData(
connectionsData: ITaskDataConnections,
@ -164,6 +218,7 @@ export function useDataSchema() {
return {
getSchema,
getSchemaForExecutionData,
getSchemaForJsonSchema,
getNodeInputData,
getInputDataWithPinned,
filterSchema,
@ -177,6 +232,7 @@ export type SchemaNode = {
connectedOutputIndexes: number[];
itemsCount: number;
schema: Schema;
preview: boolean;
};
export type RenderItem = {
@ -190,6 +246,7 @@ export type RenderItem = {
icon: string;
collapsable?: boolean;
nodeType?: INodeUi['type'];
preview?: boolean;
type: 'item';
};
@ -201,6 +258,7 @@ export type RenderHeader = {
nodeType: INodeTypeDescription;
itemCount: number | null;
type: 'header';
preview?: boolean;
};
type Renders = RenderHeader | RenderItem;
@ -227,6 +285,15 @@ const emptyItem = (): RenderItem => ({
type: 'item',
});
const dummyItem = (): RenderItem => ({
id: `dummy-${window.crypto.randomUUID()}`,
icon: '',
level: 1,
title: '...',
type: 'item',
preview: true,
});
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: [] }
@ -264,12 +331,14 @@ export const useFlattenSchema = () => {
depth = 0,
prefix = '',
level = 0,
preview,
}: {
schema: Schema;
node?: { name: string; type: string };
depth?: number;
prefix?: string;
level?: number;
preview?: boolean;
}): RenderItem[] => {
// only show empty item for the first level
if (isDataEmpty(schema) && depth <= 0) {
@ -299,6 +368,7 @@ export const useFlattenSchema = () => {
collapsable: true,
nodeType: node.type,
type: 'item',
preview,
});
}
@ -316,6 +386,7 @@ export const useFlattenSchema = () => {
depth,
prefix: itemPrefix,
level: level + 1,
preview,
});
})
.flat(),
@ -334,6 +405,7 @@ export const useFlattenSchema = () => {
collapsable: false,
nodeType: node.type,
type: 'item',
preview,
},
];
}
@ -356,6 +428,7 @@ export const useFlattenSchema = () => {
itemCount: item.itemsCount,
info: additionalInfo(item.node),
type: 'header',
preview: item.preview,
});
headerIds.value.add(item.node.name);
@ -370,9 +443,20 @@ export const useFlattenSchema = () => {
}
acc.push(...flattenSchema(item));
if (item.preview) {
acc.push(dummyItem());
}
return acc;
}, []);
};
return { closedNodes, toggleLeaf, toggleNode, flattenSchema, flattenMultipleSchemas };
return {
closedNodes,
toggleLeaf,
toggleNode,
flattenSchema,
flattenMultipleSchemas,
};
};

View file

@ -731,6 +731,7 @@ export const EXPERIMENTS_TO_TRACK = [
];
export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation';
export const SCHEMA_PREVIEW_EXPERIMENT = '028_schema_preview';
export const MFA_FORM = {
MFA_TOKEN: 'MFA_TOKEN',

View file

@ -656,6 +656,8 @@
"dataMapping.schemaView.emptyData": "No fields - item(s) exist, but they're empty",
"dataMapping.schemaView.disabled": "This node is disabled and will just pass data through",
"dataMapping.schemaView.noMatches": "No results for '{search}'",
"dataMapping.schemaView.preview": "This is a preview of the schema, execute the node to see the exact schema and data. {link}",
"dataMapping.schemaView.previewNode": "(schema preview)",
"displayWithChange.cancelEdit": "Cancel Edit",
"displayWithChange.clickToChange": "Click to Change",
"displayWithChange.setValue": "Set Value",
@ -978,6 +980,8 @@
"ndv.input.noOutputData.executePrevious": "Execute previous nodes",
"ndv.input.noOutputData.title": "No input data yet",
"ndv.input.noOutputData.hint": "(From the earliest node that has no output data yet)",
"ndv.input.noOutputData.schemaPreviewHint": "switch to {schema} to use the schema preview",
"ndv.input.noOutputData.or": "or",
"ndv.input.executingPrevious": "Executing previous nodes...",
"ndv.input.notConnected.title": "Wire me up",
"ndv.input.notConnected.message": "This node can only receive input data if you connect it to another node.",

View file

@ -0,0 +1,46 @@
import * as schemaPreviewApi from '@/api/schemaPreview';
import { createResultError, createResultOk, type Result } from 'n8n-workflow';
import { defineStore } from 'pinia';
import { reactive } from 'vue';
import { useRootStore } from './root.store';
import type { JSONSchema7 } from 'json-schema';
export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
// Type cast to avoid 'Type instantiation is excessively deep and possibly infinite'
const schemaPreviews = reactive<Map<string, Result<JSONSchema7, Error>>>(new Map()) as Map<
string,
Result<JSONSchema7, Error>
>;
const rootStore = useRootStore();
function getSchemaPreviewKey({
nodeType,
version,
operation,
resource,
}: schemaPreviewApi.GetSchemaPreviewOptions) {
return `${nodeType}_${version}_${resource ?? 'all'}_${operation ?? 'all'}`;
}
async function getSchemaPreview(
options: schemaPreviewApi.GetSchemaPreviewOptions,
): Promise<Result<JSONSchema7, Error>> {
const key = getSchemaPreviewKey(options);
const cached = schemaPreviews.get(key);
if (cached) return cached;
try {
const preview = await schemaPreviewApi.getSchemaPreview(rootStore.baseUrl, options);
const result = createResultOk(preview);
schemaPreviews.set(key, result);
return result;
} catch (error) {
const result = createResultError(error);
schemaPreviews.set(key, result);
return result;
}
}
return { getSchemaPreview };
});

View file

@ -1,110 +1,51 @@
{
"type": "object",
"value": [
{
"key": "account",
"type": "Object",
"value": [
{
"key": "accountUrl",
"type": "string",
"value": "",
"path": ".account.accountUrl"
"properties": {
"account": {
"type": "object",
"properties": {
"accountUrl": {
"type": "string"
},
{
"key": "createdTimestamp",
"type": "string",
"value": "",
"path": ".account.createdTimestamp"
"createdTimestamp": {
"type": "string"
},
{
"key": "id",
"type": "string",
"value": "",
"path": ".account.id"
"updatedTimestamp": {
"type": "string"
},
{
"key": "links",
"type": "Object",
"value": [
{
"key": "accountContacts",
"type": "string",
"value": "",
"path": ".account.links.accountContacts"
"id": {
"type": "string"
},
"links": {
"type": "object",
"properties": {
"accountContacts": {
"type": "string"
},
{
"key": "accountCustomFieldData",
"type": "string",
"value": "",
"path": ".account.links.accountCustomFieldData"
"accountCustomFieldData": {
"type": "string"
},
{
"key": "contactEmails",
"type": "string",
"value": "",
"path": ".account.links.contactEmails"
"contactEmails": {
"type": "string"
},
{
"key": "emailActivities",
"type": "string",
"value": "",
"path": ".account.links.emailActivities"
"emailActivities": {
"type": "string"
},
{
"key": "notes",
"type": "string",
"value": "",
"path": ".account.links.notes"
"notes": {
"type": "string"
},
{
"key": "owner",
"type": "string",
"value": "",
"path": ".account.links.owner"
},
{
"key": "required",
"type": "string",
"value": "",
"path": ".account.links.required"
"owner": {
"type": "string"
}
],
"path": ".account.links"
}
},
{
"key": "name",
"type": "string",
"value": "",
"path": ".account.name"
"name": {
"type": "string"
},
{
"key": "owner",
"type": "string",
"value": "",
"path": ".account.owner"
},
{
"key": "updatedTimestamp",
"type": "string",
"value": "",
"path": ".account.updatedTimestamp"
},
{
"key": "required",
"type": "string",
"value": "",
"path": ".account.required"
"owner": {
"type": "string"
}
],
"path": ".account"
},
{
"key": "required",
"type": "string",
"value": "",
"path": ".required"
}
}
],
"path": ""
}
}

View file

@ -1,47 +1,36 @@
{
"type": "object",
"properties": {
"gid": {
"type": "string"
},
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"resource_type": {
"type": "string"
},
"workspaces": {
"type": "array",
"items": {
"type": "object",
"properties": {
"gid": {
"type": "string"
},
"name": {
"type": "string"
},
"resource_type": {
"type": "string"
}
},
"required": [
"gid",
"name",
"resource_type"
]
}
}
},
"required": [
"gid",
"email",
"name",
"photo",
"resource_type",
"workspaces"
]
"type": "object",
"properties": {
"gid": {
"type": "string"
},
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"resource_type": {
"type": "string"
},
"workspaces": {
"type": "array",
"items": {
"type": "object",
"properties": {
"gid": {
"type": "string"
},
"name": {
"type": "string"
},
"resource_type": {
"type": "string"
}
},
"required": ["gid", "name", "resource_type"]
}
}
},
"required": ["gid", "email", "name", "photo", "resource_type", "workspaces"]
}

View file

@ -1601,6 +1601,9 @@ importers:
'@types/humanize-duration':
specifier: ^3.27.1
version: 3.27.1
'@types/json-schema':
specifier: ^7.0.15
version: 7.0.15
'@types/jsonpath':
specifier: ^0.2.0
version: 0.2.0