feat(editor): Show multiple nodes in input pane schema view (#9816)

This commit is contained in:
Elias Meire 2024-06-24 18:09:28 +02:00 committed by GitHub
parent e33a47311f
commit e51de9d391
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2561 additions and 742 deletions

View file

@ -174,16 +174,22 @@ describe('Data mapping', () => {
workflowPage.actions.zoomToFit();
workflowPage.actions.openNode('Set1');
ndv.actions.selectInputNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.getters.inputDataContainer().find('span').contains('count').realMouseDown();
ndv.actions.executePrevious();
ndv.actions.expandSchemaViewNode(SCHEDULE_TRIGGER_NODE_NAME);
const dataPill = ndv.getters
.inputDataContainer()
.findChildByTestId('run-data-schema-item')
.contains('count')
.should('be.visible');
dataPill.realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }}`);
ndv.actions.switchInputMode('Table');
ndv.actions.selectInputNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.actions.mapDataFromHeader(1, 'value');
ndv.getters
.inlineExpressionEditorInput()
@ -194,7 +200,6 @@ describe('Data mapping', () => {
ndv.actions.selectInputNode('Set');
ndv.actions.executePrevious();
ndv.getters.executingLoader().should('not.exist');
ndv.getters.inputDataContainer().should('exist');
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
@ -291,14 +296,8 @@ describe('Data mapping', () => {
ndv.actions.executePrevious();
ndv.getters.executingLoader().should('not.exist');
ndv.getters.inputDataContainer().should('exist');
ndv.getters
.inputDataContainer()
.should('exist')
.find('span')
.contains('test_name')
.realMouseDown();
ndv.actions.mapToParameter('value');
ndv.actions.switchInputMode('Table');
ndv.actions.mapDataFromHeader(1, 'value');
ndv.actions.validateExpressionPreview('value', 'test_value');
ndv.actions.selectInputNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.actions.validateExpressionPreview('value', 'test_value');

View file

@ -112,6 +112,9 @@ describe('NDV', () => {
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Set3');
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
ndv.getters
.inputRunSelector()
.should('exist')
@ -123,9 +126,6 @@ describe('NDV', () => {
.find('input')
.should('include.value', '2 of 2 (6 items)');
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
ndv.actions.changeOutputRunSelector('1 of 2 (6 items)');
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 2 (6 items)');
ndv.getters.outputRunSelector().find('input').should('include.value', '1 of 2 (6 items)');

View file

@ -15,13 +15,13 @@ describe('Node IO Filter', () => {
workflowPage.getters.canvasNodes().first().dblclick();
ndv.actions.close();
workflowPage.getters.canvasNodes().first().dblclick();
cy.wait(500);
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputPanel().findChildByTestId('ndv-search').should('exist');
cy.document().trigger('keyup', { key: '/' });
const searchInput = ndv.getters.searchInput();
searchInput.filter(':focus').should('exist');
searchInput.should('have.focus');
ndv.getters.pagination().find('li').should('have.length', 3);
ndv.getters.outputDataContainer().find('mark').should('not.exist');
@ -36,19 +36,18 @@ describe('Node IO Filter', () => {
it('should filter input/output data separately', () => {
workflowPage.getters.canvasNodes().eq(1).dblclick();
cy.wait(500);
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.inputDataContainer().should('be.visible');
ndv.actions.switchInputMode('Table');
ndv.getters.outputPanel().findChildByTestId('ndv-search').should('exist');
cy.document().trigger('keyup', { key: '/' });
ndv.getters.outputPanel().findChildByTestId('ndv-search').filter(':focus').should('not.exist');
ndv.getters.outputPanel().findChildByTestId('ndv-search').should('not.have.focus');
let focusedInput = ndv.getters
.inputPanel()
.findChildByTestId('ndv-search')
.filter(':focus')
.should('exist');
.should('have.focus');
const getInputPagination = () =>
ndv.getters.inputPanel().findChildByTestId('ndv-data-pagination');
@ -82,13 +81,9 @@ describe('Node IO Filter', () => {
ndv.getters.outputDataContainer().trigger('mouseover');
cy.document().trigger('keyup', { key: '/' });
ndv.getters.inputPanel().findChildByTestId('ndv-search').filter(':focus').should('not.exist');
ndv.getters.inputPanel().findChildByTestId('ndv-search').should('not.have.focus');
focusedInput = ndv.getters
.outputPanel()
.findChildByTestId('ndv-search')
.filter(':focus')
.should('exist');
focusedInput = ndv.getters.outputPanel().findChildByTestId('ndv-search').should('have.focus');
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');

View file

@ -57,9 +57,10 @@ describe('NDV', () => {
cy.createFixtureWorkflow('NDV-test-select-input.json', 'NDV test select input');
workflowPage.actions.zoomToFit();
workflowPage.getters.canvasNodes().last().dblclick();
ndv.actions.switchInputMode('Table');
ndv.getters.inputSelect().click();
ndv.getters.inputOption().last().click();
ndv.getters.inputDataContainer().find('[class*=schema_]').should('exist');
ndv.getters.inputDataContainer().should('be.visible');
ndv.getters.inputDataContainer().should('contain', 'start');
ndv.getters.backToCanvas().click();
ndv.getters.container().should('not.be.visible');
@ -252,6 +253,9 @@ describe('NDV', () => {
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Set3');
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
ndv.getters
.inputRunSelector()
.should('exist')
@ -263,9 +267,6 @@ describe('NDV', () => {
.find('input')
.should('include.value', '2 of 2 (6 items)');
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
ndv.actions.changeOutputRunSelector('1 of 2 (6 items)');
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 2 (6 items)');
ndv.getters.inputTbodyCell(1, 0).should('have.text', '1111');

View file

@ -131,6 +131,8 @@ export class NDV extends BasePage {
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
nodeRunErrorMessage: () => cy.getByTestId('node-error-message'),
nodeRunErrorDescription: () => cy.getByTestId('node-error-description'),
schemaViewNode: () => cy.getByTestId('run-data-schema-node'),
schemaViewNodeName: () => cy.getByTestId('run-data-schema-node-name'),
};
actions = {
@ -212,6 +214,9 @@ export class NDV extends BasePage {
this.getters.inputSelect().find('.el-select').click();
this.getters.inputOption().contains(nodeName).click();
},
expandSchemaViewNode: (nodeName: string) => {
this.getters.schemaViewNodeName().contains(nodeName).click();
},
addDefaultPinnedData: () => {
this.actions.editPinnedData();
this.actions.savePinnedData();

View file

@ -42,13 +42,9 @@ Cypress.Commands.add(
},
);
Cypress.Commands.add(
'findChildByTestId',
{ prevSubject: true },
(subject: Cypress.Chainable<JQuery<HTMLElement>>, childTestId) => {
return subject.find(`[data-test-id="${childTestId}"]`);
},
);
Cypress.Commands.addQuery('findChildByTestId', function (testId: string) {
return (subject: Cypress.Chainable) => subject.find(`[data-test-id="${testId}"]`);
});
Cypress.Commands.add('waitForLoad', (waitForIntercepts = true) => {
// These aliases are set-up before each test in cypress/support/e2e.ts

View file

@ -240,6 +240,9 @@
--color-mfa-recovery-code-color: var(--color-text-dark);
--color-mfa-lose-access-text-color: var(--color-danger);
// Text highlight
--color-text-highlight-background: var(--prim-color-alt-d-shade-600);
// AI
--node-type-background-l: 20%;
--node-type-supplemental-label-color-h: 235;

View file

@ -305,6 +305,9 @@
--color-mfa-recovery-code-color: var(--prim-gray-490);
--color-mfa-lose-access-text-color: var(--color-danger);
// Text highlight
--color-text-highlight-background: var(--prim-color-alt-d-shade-150);
// AI
--node-type-background-l: 95%;
--node-type-supplemental-label-color-h: 235;

View file

@ -207,7 +207,7 @@ ins {
}
mark {
background-color: var(--color-warning);
background-color: var(--color-text-highlight-background);
color: var(--color-text-dark);
font-style: italic;
font-weight: bold;

View file

@ -157,7 +157,8 @@ function onInputNodeChange(value: string) {
<style lang="scss" module>
.select {
max-width: 224px;
--max-select-width: 224px;
max-width: var(--max-select-width);
:global(.el-input--suffix .el-input__inner) {
padding-left: calc(var(--spacing-l) + var(--spacing-4xs));
@ -180,6 +181,10 @@ function onInputNodeChange(value: string) {
.title {
color: var(--color-text-dark);
font-weight: var(--font-weight-regular);
max-width: var(--max-select-width);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.disabled .title {
@ -187,6 +192,8 @@ function onInputNodeChange(value: string) {
}
.subtitle {
margin-left: auto;
padding-left: var(--spacing-2xs);
color: var(--color-text-light);
font-weight: var(--font-weight-regular);
}

View file

@ -1,6 +1,7 @@
<template>
<RunData
:node="currentNode"
:nodes="isMappingMode ? rootNodesParents : parentNodes"
:workflow="workflow"
:run-index="runIndex"
:linked-runs="linkedRuns"

View file

@ -218,9 +218,9 @@ defineExpose({
content: '';
position: absolute;
top: -35%;
right: -30%;
right: -15%;
bottom: -35%;
left: -30%;
left: -15%;
z-index: -1;
}
.outputMain &,

View file

@ -50,12 +50,22 @@
data-test-id="run-data-pane-header"
@click.stop
>
<RunDataSearch
v-if="showIOSearch"
v-model="search"
:class="$style.search"
:pane-type="paneType"
:display-mode="displayMode"
:is-area-active="isPaneActive"
@focus="activatePane"
/>
<n8n-radio-buttons
v-show="
hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled
"
:model-value="displayMode"
:options="buttons"
:options="displayModes"
data-test-id="ndv-run-data-display-mode"
@update:model-value="onDisplayModeChange"
/>
@ -66,7 +76,6 @@
:title="$locale.baseText('runData.editOutput')"
:circle="false"
:disabled="node?.disabled"
class="ml-2xs"
icon="pencil-alt"
type="tertiary"
data-test-id="ndv-edit-pinned-data"
@ -107,12 +116,16 @@
</div>
</div>
<div v-if="extraControlsLocation === 'header'" :class="$style.inputSelect">
<div v-if="inputSelectLocation === 'header'" :class="$style.inputSelect">
<slot name="input-select"></slot>
</div>
<div v-if="maxRunIndex > 0" v-show="!editMode.enabled" :class="$style.runSelector">
<slot v-if="extraControlsLocation === 'runs'" name="input-select"></slot>
<div
v-if="maxRunIndex > 0 && !isInputSchemaView"
v-show="!editMode.enabled"
:class="$style.runSelector"
>
<slot v-if="inputSelectLocation === 'runs'" name="input-select"></slot>
<n8n-select
:model-value="runIndex"
@ -148,18 +161,9 @@
</n8n-tooltip>
<slot name="run-info"></slot>
<RunDataSearch
v-if="showIOSearch && extraControlsLocation === 'runs'"
v-model="search"
:class="$style.search"
:pane-type="paneType"
:is-area-active="isPaneActive"
@focus="activatePane"
/>
</div>
<slot name="before-data" />
<slot v-if="!isInputSchemaView" name="before-data" />
<n8n-callout
v-for="hint in getNodeHints()"
@ -171,11 +175,11 @@
</n8n-callout>
<div
v-if="maxOutputIndex > 0 && branches.length > 1"
v-if="maxOutputIndex > 0 && branches.length > 1 && !isInputSchemaView"
:class="$style.outputs"
data-test-id="branches"
>
<slot v-if="extraControlsLocation === 'outputs'" name="input-select"></slot>
<slot v-if="inputSelectLocation === 'outputs'" name="input-select"></slot>
<div :class="$style.tabs">
<n8n-tabs
@ -183,14 +187,6 @@
:options="branches"
@update:model-value="onBranchChange"
/>
<RunDataSearch
v-if="showIOSearch && extraControlsLocation === 'outputs'"
v-model="search"
:pane-type="paneType"
:is-area-active="isPaneActive"
@focus="activatePane"
/>
</div>
</div>
@ -199,13 +195,14 @@
!hasRunError &&
hasNodeRun &&
((dataCount > 0 && maxRunIndex === 0) || search) &&
!isArtificialRecoveredEventItem
!isArtificialRecoveredEventItem &&
!isSchemaView
"
v-show="!editMode.enabled && !hasRunError"
:class="[$style.itemsCount, { [$style.muted]: paneType === 'input' && maxRunIndex === 0 }]"
data-test-id="ndv-items-count"
>
<slot v-if="extraControlsLocation === 'items'" name="input-select"></slot>
<slot v-if="inputSelectLocation === 'items'" name="input-select"></slot>
<n8n-text v-if="search" :class="$style.itemsText">
{{
@ -223,15 +220,6 @@
})
}}
</n8n-text>
<RunDataSearch
v-if="showIOSearch && extraControlsLocation === 'items'"
v-model="search"
:class="$style.search"
:pane-type="paneType"
:is-area-active="isPaneActive"
@focus="activatePane"
/>
</div>
<div ref="dataContainer" :class="$style.dataContainer" data-test-id="ndv-data-container">
@ -426,14 +414,17 @@
<Suspense v-else-if="hasNodeRun && isSchemaView">
<RunDataSchema
:data="jsonData"
:nodes="nodes"
:mapping-enabled="mappingEnabled"
:distance-from-active="distanceFromActive"
:node="node"
:data="jsonData"
:pane-type="paneType"
:connection-type="connectionType"
:run-index="runIndex"
:output-index="currentOutputIndex"
:total-runs="maxRunIndex"
:search="search"
@clear:search="onSearchClear"
/>
</Suspense>
@ -587,6 +578,7 @@ import type {
NodeHint,
NodeError,
Workflow,
IConnectedNode,
} from 'n8n-workflow';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
@ -667,6 +659,10 @@ export default defineComponent({
type: Object as PropType<INodeUi | null>,
default: null,
},
nodes: {
type: Array as PropType<IConnectedNode[]>,
default: () => [],
},
workflow: {
type: Object as PropType<Workflow>,
required: true,
@ -795,6 +791,9 @@ export default defineComponent({
isSchemaView(): boolean {
return this.displayMode === 'schema';
},
isInputSchemaView(): boolean {
return this.isSchemaView && this.paneType === 'input';
},
isTriggerNode(): boolean {
if (this.node === null) {
return false;
@ -815,7 +814,7 @@ export default defineComponent({
!(this.binaryData && this.binaryData.length > 0)
);
},
buttons(): Array<{ label: string; value: string }> {
displayModes(): Array<{ label: string; value: string }> {
const defaults = [
{ label: this.$locale.baseText('runData.table'), value: 'table' },
{ label: this.$locale.baseText('runData.json'), value: 'json' },
@ -1046,7 +1045,8 @@ export default defineComponent({
showIOSearch(): boolean {
return this.hasNodeRun && !this.hasRunError && this.unfilteredInputData.length > 0;
},
extraControlsLocation() {
inputSelectLocation() {
if (this.isSchemaView) return 'none';
if (!this.hasNodeRun) return 'header';
if (this.maxRunIndex > 0) return 'runs';
if (this.maxOutputIndex > 0 && this.branches.length > 1) {
@ -1521,7 +1521,7 @@ export default defineComponent({
return inputData;
},
getFilteredData(inputData: INodeExecutionData[]): INodeExecutionData[] {
if (!this.search) {
if (!this.search || this.isSchemaView) {
return inputData;
}
@ -1795,7 +1795,7 @@ export default defineComponent({
.itemsText {
flex-shrink: 0;
overflow-x: hidden;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@ -1914,7 +1914,9 @@ export default defineComponent({
display: flex;
justify-content: flex-end;
flex-grow: 1;
gap: var(--spacing-2xs);
}
.tooltipContain {
max-width: 240px;
}

View file

@ -60,7 +60,6 @@ const visible = computed(() =>
<style lang="scss" module>
.pinDataButton {
margin-left: var(--spacing-2xs);
svg {
transition: transform 0.3s ease;
}

View file

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

View file

@ -14,7 +14,7 @@ type Props = {
paneType: 'input' | 'output';
mappingEnabled: boolean;
draggingPath: string;
distanceFromActive: number;
distanceFromActive?: number;
node: INodeUi | null;
search: string;
};
@ -26,12 +26,7 @@ const schemaArray = computed(
() => (isSchemaValueArray.value ? props.schema.value : []) as Schema[],
);
const isSchemaParentTypeArray = computed(() => props.parent?.type === 'array');
const isFlat = computed(
() =>
props.level === 0 &&
Array.isArray(props.schema.value) &&
props.schema.value.every((v) => !Array.isArray(v.value)),
);
const key = computed((): string | undefined => {
return isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key;
});
@ -48,12 +43,10 @@ const dragged = computed(() => props.draggingPath === props.schema.path);
const getJsonParameterPath = (path: string): string =>
getMappedExpression({
nodeName: props.node!.name,
distanceFromActive: props.distanceFromActive,
distanceFromActive: props.distanceFromActive ?? 1,
path,
});
const transitionDelay = (i: number) => `${i * 0.033}s`;
const getIconBySchemaType = (type: Schema['type']): string => {
switch (type) {
case 'object':
@ -115,31 +108,35 @@ const getIconBySchemaType = (type: Schema['type']): string => {
/>
</span>
</div>
<span v-if="text" :class="$style.text">
<template v-for="(line, index) in text.split('\n')" :key="`line-${index}`">
<span v-if="index > 0" :class="$style.newLine">\n</span>{{ line }}
<span v-if="index > 0" :class="$style.newLine">\n</span>
<TextWithHighlights :content="line" :search="props.search" />
</template>
</span>
<input v-if="level > 0 && isSchemaValueArray" :id="subKey" type="checkbox" checked />
<input v-if="level > 0 && isSchemaValueArray" :id="subKey" type="checkbox" inert checked />
<label v-if="level > 0 && isSchemaValueArray" :class="$style.toggle" :for="subKey">
<font-awesome-icon icon="angle-up" />
<font-awesome-icon icon="angle-right" />
</label>
<div v-if="isSchemaValueArray" :class="{ [$style.sub]: true, [$style.flat]: isFlat }">
<run-data-schema-item
v-for="(s, i) in schemaArray"
:key="`${s.type}-${level}-${i}`"
:schema="s"
:level="level + 1"
:parent="schema"
:pane-type="paneType"
:sub-key="`${paneType}_${s.type}-${level}-${i}`"
:mapping-enabled="mappingEnabled"
:dragging-path="draggingPath"
:distance-from-active="distanceFromActive"
:node="node"
:style="{ transitionDelay: transitionDelay(i) }"
:search="search"
/>
<div v-if="isSchemaValueArray" :class="$style.sub">
<div :class="$style.innerSub">
<run-data-schema-item
v-for="s in schemaArray"
:key="s.key ?? s.type"
:schema="s"
:level="level + 1"
:parent="schema"
:pane-type="paneType"
:sub-key="`${subKey}-${s.key ?? s.type}`"
:mapping-enabled="mappingEnabled"
:dragging-path="draggingPath"
:distance-from-active="distanceFromActive"
:node="node"
:search="search"
/>
</div>
</div>
</div>
</template>
@ -148,69 +145,67 @@ const getIconBySchemaType = (type: Schema['type']): string => {
@import '@/styles/variables';
.item {
display: block;
display: flex;
flex-wrap: wrap;
align-items: center;
line-height: var(--font-line-height-loose);
position: relative;
transition: all 0.3s $ease-out-expo;
column-gap: var(--spacing-2xs);
+ .item {
margin-top: var(--spacing-2xs);
}
.item {
padding-top: var(--spacing-2xs);
padding-left: var(--spacing-l);
}
input {
position: absolute;
left: -100%;
display: none;
~ .sub {
height: 0;
transition:
grid-template-rows 0.2s $ease-out-expo,
opacity 0.2s $ease-out-expo,
transform 0.2s $ease-out-expo;
transform: translateX(-8px);
opacity: 0;
margin-bottom: 0;
> .item {
transform: translateX(-100%);
.innerSub {
min-height: 0;
}
}
&:checked {
~ .toggle svg {
transform: rotate(180deg);
transform: rotate(90deg);
}
~ .sub {
height: auto;
> .item {
transform: translateX(0);
}
transform: translateX(0);
opacity: 1;
grid-template-rows: 1fr;
}
}
}
&::after {
content: '';
display: block;
clear: both;
}
}
.sub {
display: block;
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: all 0.2s $ease-out-expo;
clear: both;
flex-basis: 100%;
scroll-margin: 64px;
}
&.flat {
> .item {
padding-left: 0;
}
}
.innerSub {
display: inline-flex;
flex-direction: column;
order: -1;
&:nth-of-type(1) {
> .item:nth-of-type(1) {
padding-top: 0;
.toggle {
top: -2px;
}
}
.innerSub > div:first-child {
margin-top: var(--spacing-2xs);
}
}
@ -234,7 +229,6 @@ const getIconBySchemaType = (type: Schema['type']): string => {
}
.pill {
float: left;
display: inline-flex;
height: 24px;
padding: 0 var(--spacing-3xs);
@ -285,8 +279,6 @@ const getIconBySchemaType = (type: Schema['type']): string => {
.text {
display: block;
padding-top: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
font-weight: var(--font-weight-normal);
font-size: var(--font-size-2xs);
overflow: hidden;
@ -302,9 +294,9 @@ const getIconBySchemaType = (type: Schema['type']): string => {
.toggle {
display: flex;
position: absolute;
padding: var(--spacing-2xs);
padding: var(--spacing-4xs) var(--spacing-2xs);
left: 0;
top: 5px;
top: 0;
justify-content: center;
align-items: center;
cursor: pointer;
@ -314,7 +306,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
overflow: hidden;
svg {
transition: all 0.3s $ease-out-expo;
transition: transform 0.2s $ease-out-expo;
}
}
</style>

View file

@ -1,16 +1,18 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, type StyleValue } from 'vue';
import { computed, ref, onMounted, onUnmounted, type StyleValue, watch } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { NodePanelType } from '@/Interface';
import type { IRunDataDisplayMode, NodePanelType } from '@/Interface';
import { useDebounce } from '@/composables/useDebounce';
type Props = {
modelValue: string;
paneType?: NodePanelType;
displayMode?: IRunDataDisplayMode;
isAreaActive?: boolean;
};
const COLLAPSED_WIDTH = '34px';
const OPEN_WIDTH = '200px';
const COLLAPSED_WIDTH = '30px';
const OPEN_WIDTH = '204px';
const OPEN_MIN_WIDTH = '120px';
const emit = defineEmits<{
@ -20,18 +22,27 @@ const emit = defineEmits<{
const props = withDefaults(defineProps<Props>(), {
paneType: 'output',
displayMode: 'schema',
isAreaActive: false,
});
const locale = useI18n();
const { debounce } = useDebounce();
const inputRef = ref<HTMLInputElement | null>(null);
const search = ref(props.modelValue ?? '');
const opened = ref(false);
const placeholder = computed(() =>
props.paneType === 'input'
? locale.baseText('ndv.search.placeholder.input')
: locale.baseText('ndv.search.placeholder.output'),
);
const placeholder = computed(() => {
if (props.paneType === 'output') {
return locale.baseText('ndv.search.placeholder.output');
}
if (props.displayMode === 'schema') {
return locale.baseText('ndv.search.placeholder.input.schema');
}
return locale.baseText('ndv.search.placeholder.input');
});
const style = computed<StyleValue>(() =>
opened.value ? { maxWidth: OPEN_WIDTH, minWidth: OPEN_MIN_WIDTH } : { maxWidth: COLLAPSED_WIDTH },
@ -50,25 +61,42 @@ const documentKeyHandler = (event: KeyboardEvent) => {
}
};
const debouncedEmitUpdate = debounce(async (value: string) => emit('update:modelValue', value), {
debounceTime: 300,
trailing: true,
});
const onSearchUpdate = (value: string) => {
emit('update:modelValue', value);
search.value = value;
void debouncedEmitUpdate(value);
};
const onFocus = () => {
opened.value = true;
inputRef.value?.select();
emit('focus');
};
const onBlur = () => {
if (!props.modelValue) {
opened.value = false;
}
};
onMounted(() => {
document.addEventListener('keyup', documentKeyHandler);
});
onUnmounted(() => {
document.removeEventListener('keyup', documentKeyHandler);
});
watch(
() => props.modelValue,
(value) => {
search.value = value;
},
);
</script>
<template>
@ -80,7 +108,7 @@ onUnmounted(() => {
[$style.ioSearchOpened]: opened,
}"
:style="style"
:model-value="modelValue"
:model-value="search"
:placeholder="placeholder"
size="small"
@update:model-value="onSearchUpdate"
@ -104,8 +132,17 @@ onUnmounted(() => {
cursor: pointer;
}
:global(.el-input__prefix) {
left: 8px;
}
&:global(.el-input--prefix .el-input__inner) {
padding-left: 30px;
}
input {
border: 0;
opacity: 0;
background: transparent;
cursor: pointer;
}
@ -115,10 +152,12 @@ onUnmounted(() => {
.ioSearchIcon {
cursor: default;
}
input {
border: var(--input-border-color, var(--border-color-base))
var(--input-border-style, var(--border-style-base)) var(--border-width-base);
background: var(--input-background-color, var(--color-foreground-xlight));
opacity: 1;
cursor: text;
}
}

View file

@ -3,20 +3,21 @@ import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es';
import RunData from '@/components/RunData.vue';
import { STORES, VIEWS } from '@/constants';
import { SET_NODE_TYPE, STORES, VIEWS } from '@/constants';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render';
import type { INodeUi, IRunDataDisplayMode } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { setActivePinia } from 'pinia';
import { defaultNodeTypes } from '@/__tests__/mocks';
const nodes = [
{
id: '1',
typeVersion: 1,
typeVersion: 3,
name: 'Test Node',
position: [0, 0],
type: 'test',
type: SET_NODE_TYPE,
parameters: {},
},
] as INodeUi[];
@ -143,6 +144,9 @@ describe('RunData', () => {
},
},
},
[STORES.NODE_TYPES]: {
nodeTypes: defaultNodeTypes,
},
},
});
@ -177,6 +181,7 @@ describe('RunData', () => {
name: 'Test Node',
position: [0, 0],
},
nodes: [{ name: 'Test Node', indicies: [], depth: 1 }],
runIndex: 0,
paneType: 'output',
isExecuting: false,

View file

@ -1,75 +1,140 @@
import { createTestingPinia } from '@pinia/testing';
import { cleanup } from '@testing-library/vue';
import RunDataJsonSchema from '@/components/RunDataSchema.vue';
import { STORES } from '@/constants';
import { createComponentRenderer } from '@/__tests__/render';
import RunDataJsonSchema from '@/components/RunDataSchema.vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { userEvent } from '@testing-library/user-event';
import { cleanup, within } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import { createTestNode, defaultNodeDescriptions } from '@/__tests__/mocks';
import { SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { mock } from 'vitest-mock-extended';
import type { IWorkflowDb } from '@/Interface';
const renderComponent = createComponentRenderer(RunDataJsonSchema, {
global: {
stubs: ['font-awesome-icon'],
plugins: [
createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
},
},
},
},
}),
],
},
props: {
mappingEnabled: true,
distanceFromActive: 1,
runIndex: 1,
totalRuns: 2,
paneType: 'input',
node: {
parameters: {
keepOnlySet: false,
values: {},
options: {},
},
id: '820ea733-d8a6-4379-8e73-88a2347ea003',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [380, 1060],
disabled: false,
},
data: [{}],
},
const mockNode1 = createTestNode({
name: 'Set1',
type: SET_NODE_TYPE,
typeVersion: 1,
});
describe('RunDataJsonSchema.vue', () => {
beforeEach(cleanup);
const mockNode2 = createTestNode({
name: 'Set2',
type: SET_NODE_TYPE,
typeVersion: 1,
});
it('renders schema for empty data', () => {
const { container } = renderComponent();
expect(container).toMatchSnapshot();
async function setupStore() {
const workflow = mock<IWorkflowDb>({
id: '123',
name: 'Test Workflow',
connections: {},
active: true,
nodes: [mockNode1, mockNode2],
});
it('renders schema for data', () => {
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
return pinia;
}
describe('RunDataSchema.vue', () => {
let renderComponent: ReturnType<typeof createComponentRenderer>;
beforeEach(async () => {
cleanup();
renderComponent = createComponentRenderer(RunDataJsonSchema, {
global: {
stubs: ['font-awesome-icon'],
},
pinia: await setupStore(),
props: {
mappingEnabled: true,
runIndex: 1,
outputIndex: 0,
totalRuns: 2,
paneType: 'input',
connectionType: 'main',
search: '',
nodes: [
{ name: 'Set1', indicies: [], depth: 1 },
{ name: 'Set2', indicies: [], depth: 2 },
],
},
});
});
it('renders schema for empty data', async () => {
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('run-data-schema-empty').length).toBe(1);
// Expand second node
await userEvent.click(getAllByTestId('run-data-schema-node-name')[1]);
expect(getAllByTestId('run-data-schema-empty').length).toBe(2);
});
it('renders schema for data', async () => {
useWorkflowsStore().pinData({
node: mockNode1,
data: [
{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } },
{ json: { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] } },
],
});
useWorkflowsStore().pinData({
node: mockNode2,
data: [
{ json: { name: 'John', age: 22, hobbies: ['surfing', 'traveling'] } },
{ json: { name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] } },
],
});
const { getAllByTestId } = renderComponent();
const nodes = getAllByTestId('run-data-schema-node');
expect(nodes.length).toBe(2);
const firstNodeName = await within(nodes[0]).findByTestId('run-data-schema-node-name');
const firstNodeItemCount = await within(nodes[0]).findByTestId(
'run-data-schema-node-item-count',
);
expect(firstNodeName).toHaveTextContent('Set1');
expect(firstNodeItemCount).toHaveTextContent('2 items');
expect(within(nodes[0]).getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
const secondNodeName = await within(nodes[1]).findByTestId('run-data-schema-node-name');
expect(secondNodeName).toHaveTextContent('Set2');
// Expand second node
await userEvent.click(secondNodeName);
expect(within(nodes[1]).getByTestId('run-data-schema-node-schema')).toMatchSnapshot();
});
it('renders schema for in output pane', async () => {
const { container } = renderComponent({
props: {
nodes: [],
paneType: 'output',
node: mockNode1,
data: [
{ name: 'John', age: 22, hobbies: ['surfing', 'traveling'] },
{ name: 'Joe', age: 33, hobbies: ['skateboarding', 'gaming'] },
],
},
});
expect(container).toMatchSnapshot();
});
it('renders schema with spaces and dots', () => {
const { container } = renderComponent({
props: {
data: [
{
useWorkflowsStore().pinData({
node: mockNode1,
data: [
{
json: {
'hello world': [
{
test: {
@ -79,32 +144,34 @@ describe('RunDataJsonSchema.vue', () => {
},
],
},
],
},
},
],
});
const { container } = renderComponent();
expect(container).toMatchSnapshot();
});
it('renders no data to show for data empty objects', () => {
const renderResult = renderComponent({
props: {
data: [{}, {}],
},
useWorkflowsStore().pinData({
node: mockNode1,
data: [{ json: {} }, { json: {} }],
});
expect(renderResult.getByText(/No data to show/)).toBeInTheDocument();
const { getAllByTestId } = renderComponent();
expect(getAllByTestId('run-data-schema-empty').length).toBe(1);
});
test.each([[[{ tx: false }, { tx: false }]], [[{ tx: '' }, { tx: '' }]], [[{ tx: [] }]]])(
'renders schema instead of showing no data for %o',
(data) => {
const renderResult = renderComponent({
props: {
data,
},
useWorkflowsStore().pinData({
node: mockNode1,
data: data.map((item) => ({ json: item })),
});
expect(renderResult.queryByText(/No data to show/)).not.toBeInTheDocument();
const { queryByTestId } = renderComponent();
expect(queryByTestId('run-data-schema-empty')).not.toBeInTheDocument();
},
);
});

View file

@ -352,4 +352,176 @@ describe('useDataSchema', () => {
expect(pathData).toEqual([new Date('2022-11-22T00:00:00.000Z')]);
});
});
describe('filterSchema', () => {
const filterSchema = useDataSchema().filterSchema;
it('should correctly filter a flat schema', () => {
const flatSchema: Schema = {
type: 'object',
value: [
{
key: 'name',
type: 'string',
value: 'First item',
path: '.name',
},
{
key: 'code',
type: 'number',
value: '1',
path: '.code',
},
{
key: 'email',
type: 'string',
value: 'first item@gmail.com',
path: '.email',
},
],
path: '',
};
expect(filterSchema(flatSchema, 'mail')).toEqual({
path: '',
type: 'object',
value: [
{
key: 'email',
path: '.email',
type: 'string',
value: 'first item@gmail.com',
},
],
});
expect(filterSchema(flatSchema, '1')).toEqual({
path: '',
type: 'object',
value: [
{
key: 'code',
path: '.code',
type: 'number',
value: '1',
},
],
});
expect(filterSchema(flatSchema, 'no match')).toEqual(null);
});
it('should correctly filter a nested schema', () => {
const nestedSchema: Schema = {
type: 'object',
value: [
{
key: 'name',
type: 'string',
value: 'First item',
path: '.name',
},
{
key: 'code',
type: 'number',
value: '1',
path: '.code',
},
{
key: 'email',
type: 'string',
value: 'first item@gmail.com',
path: '.email',
},
{
key: 'obj',
type: 'object',
value: [
{
key: 'foo',
type: 'object',
value: [
{
key: 'nested',
type: 'string',
value: 'bar',
path: '.obj.foo.nested',
},
],
path: '.obj.foo',
},
],
path: '.obj',
},
],
path: '',
};
expect(filterSchema(nestedSchema, 'bar')).toEqual({
path: '',
type: 'object',
value: [
{
key: 'obj',
path: '.obj',
type: 'object',
value: [
{
key: 'foo',
path: '.obj.foo',
type: 'object',
value: [
{
key: 'nested',
path: '.obj.foo.nested',
type: 'string',
value: 'bar',
},
],
},
],
},
],
});
expect(filterSchema(nestedSchema, '1')).toEqual({
path: '',
type: 'object',
value: [
{
key: 'code',
path: '.code',
type: 'number',
value: '1',
},
],
});
expect(filterSchema(nestedSchema, 'no match')).toEqual(null);
});
it('should not filter schema with empty search', () => {
const flatSchema: Schema = {
type: 'object',
value: [
{
key: 'name',
type: 'string',
value: 'First item',
path: '.name',
},
{
key: 'code',
type: 'number',
value: '1',
path: '.code',
},
{
key: 'email',
type: 'string',
value: 'first item@gmail.com',
path: '.email',
},
],
path: '',
};
expect(filterSchema(flatSchema, '')).toEqual(flatSchema);
});
});
});

View file

@ -9,6 +9,7 @@ import { merge } from 'lodash-es';
import { generatePath } from '@/utils/mappingUtils';
import { isObj } from '@/utils/typeGuards';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { isPresent } from '@/utils/typesUtils';
export function useDataSchema() {
function getSchema(
@ -126,10 +127,40 @@ export function useDataSchema() {
return inputData;
}
function schemaMatches(schema: Schema, search: string): boolean {
const searchLower = search.toLocaleLowerCase();
return (
!!schema.key?.toLocaleLowerCase().includes(searchLower) ||
(typeof schema.value === 'string' && schema.value.toLocaleLowerCase().includes(searchLower))
);
}
function filterSchema(schema: Schema, search: string): Schema | null {
if (!search.trim()) return schema;
if (Array.isArray(schema.value)) {
const filteredValue = schema.value
.map((value) => filterSchema(value, search))
.filter(isPresent);
if (filteredValue.length === 0) {
return schemaMatches(schema, search) ? schema : null;
}
return {
...schema,
value: filteredValue,
};
}
return schemaMatches(schema, search) ? schema : null;
}
return {
getSchema,
getSchemaForExecutionData,
getNodeInputData,
getInputDataWithPinned,
filterSchema,
};
}

View file

@ -612,7 +612,8 @@
"dataMapping.tableView.tableColumnsExceeded": "Some columns are hidden",
"dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.",
"dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view",
"dataMapping.schemaView.emptyData": "No data to show - item(s) exist, but theyre empty",
"dataMapping.schemaView.emptyData": "No fields - item(s) exist, but they're empty",
"dataMapping.schemaView.noMatches": "No results for '{search}'",
"displayWithChange.cancelEdit": "Cancel Edit",
"displayWithChange.clickToChange": "Click to Change",
"displayWithChange.setValue": "Set Value",
@ -1334,6 +1335,8 @@
"parameterInput.formatHtml": "Format HTML",
"parameterInput.issues": "Issues",
"parameterInput.loadingOptions": "Loading options...",
"parameterInput.loadOptionsErrorService": "Error fetching options from {service}",
"parameterInput.loadOptionsError": "Error fetching options",
"parameterInput.openEditWindow": "Open Edit Window",
"parameterInput.parameter": "Parameter: \"{shortPath}\"",
"parameterInput.parameterHasExpression": "Parameter: \"{shortPath}\" has an expression",
@ -2012,9 +2015,11 @@
"ndv.trigger.pollingNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then n8n will regularly check {service} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.pollingNode.executionsHelp.active": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Your workflow will also execute automatically</b>, since it's activated. n8n will regularly check {app_name} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.webhookBasedNode.action": "Pull in events from {name}",
"ndv.search.placeholder.output": "Filter output",
"ndv.search.placeholder.input": "Filter input",
"ndv.search.placeholder.output": "Search output",
"ndv.search.placeholder.input": "Search selected node",
"ndv.search.placeholder.input.schema": "Search previous nodes",
"ndv.search.noMatch.title": "No matching items",
"ndv.search.noNodeMatch.title": "No matching nodes",
"ndv.search.noMatch.description": "Try changing or {link} the filter to see more",
"ndv.search.noMatch.description.link": "clearing",
"ndv.search.items": "{matched} of {total} item | {matched} of {total} items",