diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index c20e298e21..4c9fa14918 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -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", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index bd207122b0..7753ba8e18 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -740,6 +740,8 @@ export interface CreateElementBase { export interface NodeCreateElement extends CreateElementBase { type: 'node'; subcategory: string; + resource?: string; + operation?: string; properties: SimplifiedNodeType; } diff --git a/packages/editor-ui/src/api/schemaPreview.ts b/packages/editor-ui/src/api/schemaPreview.ts new file mode 100644 index 0000000000..9250993929 --- /dev/null +++ b/packages/editor-ui/src/api/schemaPreview.ts @@ -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 => { + 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, + }); +}; diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index 0a64d01b5b..bf7175e68d 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -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 });
@@ -1781,7 +1819,7 @@ defineExpose({ enterEditMode }); - + { const DynamicScrollerItemStub = { template: '', }; + const NoticeStub = { + template: '
', + }; 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(); }); }); diff --git a/packages/editor-ui/src/components/VirtualSchema.vue b/packages/editor-ui/src/components/VirtualSchema.vue index 2a4c0ebedc..5aac9e57a8 100644 --- a/packages/editor-ui/src/components/VirtualSchema.vue +++ b/packages/editor-ui/src/components/VirtualSchema.vue @@ -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(() => { - return props.nodes.reduce((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(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" >