mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(editor): Show multiple nodes in input pane schema view (#9816)
This commit is contained in:
parent
e33a47311f
commit
e51de9d391
|
@ -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');
|
||||
|
|
|
@ -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)');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<RunData
|
||||
:node="currentNode"
|
||||
:nodes="isMappingMode ? rootNodesParents : parentNodes"
|
||||
:workflow="workflow"
|
||||
:run-index="runIndex"
|
||||
:linked-runs="linkedRuns"
|
||||
|
|
|
@ -218,9 +218,9 @@ defineExpose({
|
|||
content: '';
|
||||
position: absolute;
|
||||
top: -35%;
|
||||
right: -30%;
|
||||
right: -15%;
|
||||
bottom: -35%;
|
||||
left: -30%;
|
||||
left: -15%;
|
||||
z-index: -1;
|
||||
}
|
||||
.outputMain &,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,6 @@ const visible = computed(() =>
|
|||
|
||||
<style lang="scss" module>
|
||||
.pinDataButton {
|
||||
margin-left: var(--spacing-2xs);
|
||||
svg {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 they’re 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",
|
||||
|
|
Loading…
Reference in a new issue