mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Display schema preview for unexecuted nodes (#12901)
This commit is contained in:
parent
ce1deb8aea
commit
0063bbb30b
|
@ -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",
|
||||
|
|
|
@ -740,6 +740,8 @@ export interface CreateElementBase {
|
|||
export interface NodeCreateElement extends CreateElementBase {
|
||||
type: 'node';
|
||||
subcategory: string;
|
||||
resource?: string;
|
||||
operation?: string;
|
||||
properties: SimplifiedNodeType;
|
||||
}
|
||||
|
||||
|
|
28
packages/editor-ui/src/api/schemaPreview.ts
Normal file
28
packages/editor-ui/src/api/schemaPreview.ts
Normal 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,
|
||||
});
|
||||
};
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.",
|
||||
|
|
46
packages/editor-ui/src/stores/schemaPreview.store.ts
Normal file
46
packages/editor-ui/src/stores/schemaPreview.store.ts
Normal 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 };
|
||||
});
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue